@@ -36,11 +36,15 @@ import {
3636 PopoverTrigger ,
3737 PopoverContent ,
3838 FilterBuilder ,
39+ Dialog ,
40+ DialogContent ,
41+ DialogHeader ,
42+ DialogTitle ,
3943} from '@object-ui/components' ;
4044import { ChevronDown , ChevronsUpDown , ChevronUp , Plus , Search , Trash2 } from 'lucide-react' ;
4145// @ts -ignore - lucide-react has no `exports` field; subpath types live alongside dynamic.mjs
4246import { iconNames } from 'lucide-react/dynamic.mjs' ;
43- import { detectLocale , t } from './i18n' ;
47+ import { detectLocale , t , tFormat } from './i18n' ;
4448import { ColorVariantPicker } from './color-variant-field' ;
4549
4650export interface WidgetContext {
@@ -1020,7 +1024,7 @@ const LUCIDE_ICON_NAMES: readonly string[] = iconNames as string[];
10201024const 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