Skip to content

Commit e80b0af

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(studio): open the icon picker in a roomy modal (#1834)
The inline icon popover was cramped for browsing ~2000 Lucide icons. Open the picker in a centered Dialog instead: wider layout, larger search box, an 8-column grid showing up to 120 results, a 'none' clear cell, and a 'showing first N of M — type to narrow' hint. Selecting writes the name and closes the modal; out-of-catalog values stay reachable. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d951065 commit e80b0af

2 files changed

Lines changed: 65 additions & 46 deletions

File tree

packages/app-shell/src/views/metadata-admin/i18n.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,8 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
511511
'engine.form.none': '— None —',
512512
'engine.form.notInObject': '(not in object)',
513513
'engine.form.searchIcons': 'Search icons…',
514+
'engine.form.chooseIcon': 'Choose an icon',
515+
'engine.form.iconsTruncated': 'Showing the first {shown} of {total} — type to narrow.',
514516
'engine.form.noMatchingIcons': 'No matching icons.',
515517
'engine.form.keep': 'Keep',
516518
'engine.form.addField': 'Add field…',
@@ -1159,6 +1161,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
11591161
'engine.form.none': '— 无 —',
11601162
'engine.form.notInObject': '(不在对象中)',
11611163
'engine.form.searchIcons': '搜索图标…',
1164+
'engine.form.chooseIcon': '选择图标',
1165+
'engine.form.iconsTruncated': '显示前 {shown} 个,共 {total} 个 —— 继续输入以缩小范围。',
11621166
'engine.form.noMatchingIcons': '没有匹配的图标。',
11631167
'engine.form.keep': '保留',
11641168
'engine.form.addField': '添加字段…',

packages/app-shell/src/views/metadata-admin/widgets.tsx

Lines changed: 61 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,15 @@ import {
3636
PopoverTrigger,
3737
PopoverContent,
3838
FilterBuilder,
39+
Dialog,
40+
DialogContent,
41+
DialogHeader,
42+
DialogTitle,
3943
} from '@object-ui/components';
4044
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus, Search, Trash2 } from 'lucide-react';
4145
// @ts-ignore - lucide-react has no `exports` field; subpath types live alongside dynamic.mjs
4246
import { iconNames } from 'lucide-react/dynamic.mjs';
43-
import { detectLocale, t } from './i18n';
47+
import { detectLocale, t, tFormat } from './i18n';
4448
import { ColorVariantPicker } from './color-variant-field';
4549

4650
export interface WidgetContext {
@@ -1020,7 +1024,7 @@ const LUCIDE_ICON_NAMES: readonly string[] = iconNames as string[];
10201024
const LUCIDE_ICON_SET: Set<string> = new Set(LUCIDE_ICON_NAMES);
10211025
// Cap the rendered grid — each cell mounts a lazily-loaded icon, so showing all
10221026
// ~1500 at once would fire a flood of chunk requests. The search box narrows it.
1023-
const ICON_RESULT_LIMIT = 60;
1027+
const ICON_RESULT_LIMIT = 120;
10241028

10251029
/**
10261030
* Searchable icon picker for `widget: 'icon'` string fields (page/app/object
@@ -1042,46 +1046,35 @@ export function IconPickerWidget({ id, value, onChange, readOnly }: WidgetProps)
10421046
const current = value == null ? '' : String(value);
10431047
const [open, setOpen] = React.useState(false);
10441048
const [query, setQuery] = React.useState('');
1045-
const rootRef = React.useRef<HTMLDivElement>(null);
10461049

10471050
const currentKebab = current ? toKebabIconName(current) : '';
10481051
const inCatalog = !current || LUCIDE_ICON_SET.has(currentKebab);
10491052

1050-
// Close when focus/click leaves the widget.
1051-
React.useEffect(() => {
1052-
if (!open) return;
1053-
const onDocPointer = (e: MouseEvent) => {
1054-
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
1055-
};
1056-
document.addEventListener('mousedown', onDocPointer);
1057-
return () => document.removeEventListener('mousedown', onDocPointer);
1058-
}, [open]);
1059-
1060-
const results = React.useMemo(() => {
1061-
const q = toKebabIconName(query.trim());
1062-
const matches = q
1063-
? LUCIDE_ICON_NAMES.filter((n) => n.includes(q))
1064-
: LUCIDE_ICON_NAMES;
1065-
return matches.slice(0, ICON_RESULT_LIMIT);
1066-
}, [query]);
1067-
1068-
const select = (name: string) => {
1053+
const q = toKebabIconName(query.trim());
1054+
const allMatches = React.useMemo(
1055+
() => (q ? LUCIDE_ICON_NAMES.filter((n) => n.includes(q)) : LUCIDE_ICON_NAMES),
1056+
[q],
1057+
);
1058+
const results = allMatches.slice(0, ICON_RESULT_LIMIT);
1059+
const truncated = allMatches.length > results.length;
1060+
1061+
const select = (name: string | undefined) => {
10691062
onChange(name || undefined);
10701063
setOpen(false);
10711064
setQuery('');
10721065
};
10731066

10741067
return (
1075-
<div ref={rootRef} className="relative">
1068+
<>
10761069
<button
10771070
id={id}
10781071
type="button"
10791072
role="combobox"
1073+
aria-haspopup="dialog"
10801074
aria-expanded={open}
1081-
aria-controls={id ? `${id}-listbox` : undefined}
10821075
disabled={readOnly}
1083-
onClick={() => setOpen((o) => !o)}
1084-
className="flex w-full items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm text-left disabled:opacity-60 disabled:cursor-not-allowed"
1076+
onClick={() => !readOnly && setOpen(true)}
1077+
className="flex w-full items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm text-left disabled:cursor-not-allowed disabled:opacity-60"
10851078
>
10861079
<LazyIcon name={inCatalog ? current : undefined} className="h-4 w-4 shrink-0 text-muted-foreground" />
10871080
<span className={'flex-1 truncate ' + (current ? 'font-mono' : 'text-muted-foreground')}>
@@ -1093,38 +1086,54 @@ export function IconPickerWidget({ id, value, onChange, readOnly }: WidgetProps)
10931086
<ChevronsUpDown aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-50" />
10941087
</button>
10951088

1096-
{open && !readOnly && (
1097-
<div
1098-
id={id ? `${id}-listbox` : undefined}
1099-
role="listbox"
1100-
className="absolute z-50 mt-1 w-full rounded-md border border-border bg-popover p-1 shadow-md"
1101-
>
1102-
<div className="flex items-center gap-2 border-b border-border/50 px-2 pb-1.5">
1103-
<Search aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
1089+
<Dialog open={open} onOpenChange={(o) => { setOpen(o); if (!o) setQuery(''); }}>
1090+
<DialogContent className="max-w-2xl">
1091+
<DialogHeader>
1092+
<DialogTitle>{t('engine.form.chooseIcon', locale)}</DialogTitle>
1093+
</DialogHeader>
1094+
1095+
<div className="flex items-center gap-2 rounded-md border border-input bg-background px-3 py-2">
1096+
<Search aria-hidden className="h-4 w-4 shrink-0 text-muted-foreground" />
11041097
<input
11051098
type="text"
11061099
autoFocus
11071100
value={query}
11081101
aria-label={t('engine.form.searchIcons', locale)}
11091102
placeholder={t('engine.form.searchIcons', locale)}
11101103
onChange={(e) => setQuery(e.target.value)}
1111-
className="w-full bg-transparent py-1 text-sm outline-none"
1104+
className="w-full bg-transparent text-sm outline-none"
11121105
/>
11131106
</div>
1114-
<div className="mt-1 grid max-h-56 grid-cols-6 gap-1 overflow-y-auto">
1115-
{/* Keep an unknown value reachable so re-opening never drops it. */}
1107+
1108+
<div
1109+
id={id ? `${id}-listbox` : undefined}
1110+
role="listbox"
1111+
className="grid max-h-[52vh] grid-cols-6 gap-1.5 overflow-y-auto pr-1 sm:grid-cols-8"
1112+
>
1113+
{current && (
1114+
<button
1115+
type="button"
1116+
role="option"
1117+
aria-selected={false}
1118+
title={t('engine.form.none', locale)}
1119+
onClick={() => select(undefined)}
1120+
className="flex aspect-square flex-col items-center justify-center gap-1 rounded border border-dashed border-border p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
1121+
>
1122+
<Trash2 className="h-4 w-4" />
1123+
<span className="w-full truncate text-center text-[9px] leading-tight">none</span>
1124+
</button>
1125+
)}
11161126
{!inCatalog && current && (
11171127
<button
11181128
type="button"
11191129
role="option"
11201130
aria-selected
11211131
title={current}
11221132
onClick={() => select(current)}
1123-
className="col-span-6 flex items-center gap-2 rounded px-2 py-1 text-left text-xs hover:bg-accent hover:text-accent-foreground"
1133+
className="flex aspect-square flex-col items-center justify-center gap-1 rounded bg-accent p-1 text-accent-foreground ring-1 ring-primary"
11241134
>
1125-
<LazyIcon name={undefined} className="h-4 w-4 shrink-0" />
1126-
<span className="font-mono">{current}</span>
1127-
<span className="ml-auto text-muted-foreground">{t('engine.form.keep', locale)}</span>
1135+
<LazyIcon name={undefined} className="h-4 w-4" />
1136+
<span className="w-full truncate text-center text-[9px] leading-tight">{current}</span>
11281137
</button>
11291138
)}
11301139
{results.map((name) => {
@@ -1142,20 +1151,26 @@ export function IconPickerWidget({ id, value, onChange, readOnly }: WidgetProps)
11421151
(selected ? 'bg-accent text-accent-foreground ring-1 ring-primary' : '')
11431152
}
11441153
>
1145-
<LazyIcon name={name} className="h-4 w-4" />
1154+
<LazyIcon name={name} className="h-5 w-5" />
11461155
<span className="w-full truncate text-center text-[9px] leading-tight">{name}</span>
11471156
</button>
11481157
);
11491158
})}
11501159
{results.length === 0 && (
1151-
<p className="col-span-6 px-2 py-3 text-center text-xs text-muted-foreground">
1160+
<p className="col-span-full px-2 py-6 text-center text-sm text-muted-foreground">
11521161
{t('engine.form.noMatchingIcons', locale)}
11531162
</p>
11541163
)}
11551164
</div>
1156-
</div>
1157-
)}
1158-
</div>
1165+
1166+
{truncated && (
1167+
<p className="text-center text-xs text-muted-foreground">
1168+
{tFormat('engine.form.iconsTruncated', locale, { shown: results.length, total: allMatches.length })}
1169+
</p>
1170+
)}
1171+
</DialogContent>
1172+
</Dialog>
1173+
</>
11591174
);
11601175
}
11611176

0 commit comments

Comments
 (0)