11"use client" ;
22
3- import { useMemo , useState } from "react" ;
3+ import { useId , useMemo , useState } from "react" ;
44import { References } from "@/lib/types" ;
55import {
66 parseBrandSetRows ,
@@ -20,6 +20,154 @@ interface AdminCatalogSectionProps {
2020 onReferencesChange : ( references : References ) => void ;
2121}
2222
23+ interface CatalogComboboxProps {
24+ value : string ;
25+ onChange : ( value : string ) => void ;
26+ placeholder : string ;
27+ suggestions : string [ ] ;
28+ disabled ?: boolean ;
29+ }
30+
31+ function uniqueSorted ( values : string [ ] ) : string [ ] {
32+ return Array . from (
33+ new Set ( values . map ( ( value ) => value . trim ( ) ) . filter ( Boolean ) )
34+ ) . sort ( ( a , b ) => a . localeCompare ( b , undefined , { numeric : true } ) ) ;
35+ }
36+
37+ function setsLinkedToBrand ( references : References , brand : string ) : string [ ] {
38+ const query = brand . trim ( ) . toLowerCase ( ) ;
39+ if ( ! query ) return references . sets ;
40+
41+ const exactBrand = references . brands . find (
42+ ( item ) => item . toLowerCase ( ) === query
43+ ) ;
44+ if ( exactBrand ) {
45+ return references . brandSets [ exactBrand ] ?? [ ] ;
46+ }
47+
48+ return uniqueSorted (
49+ references . brands
50+ . filter ( ( item ) => item . toLowerCase ( ) . includes ( query ) )
51+ . flatMap ( ( item ) => references . brandSets [ item ] ?? [ ] )
52+ ) ;
53+ }
54+
55+ function variationsLinkedToSet ( references : References , setName : string ) : string [ ] {
56+ const query = setName . trim ( ) . toLowerCase ( ) ;
57+ if ( ! query ) return references . variations ;
58+
59+ const exactSet = references . sets . find ( ( item ) => item . toLowerCase ( ) === query ) ;
60+ if ( exactSet ) {
61+ return references . setVariations [ exactSet ] ?? [ ] ;
62+ }
63+
64+ return uniqueSorted (
65+ references . sets
66+ . filter ( ( item ) => item . toLowerCase ( ) . includes ( query ) )
67+ . flatMap ( ( item ) => references . setVariations [ item ] ?? [ ] )
68+ ) ;
69+ }
70+
71+ function CatalogCombobox ( {
72+ value,
73+ onChange,
74+ placeholder,
75+ suggestions,
76+ disabled,
77+ } : CatalogComboboxProps ) {
78+ const inputId = useId ( ) ;
79+ const listboxId = `${ inputId } -listbox` ;
80+ const [ open , setOpen ] = useState ( false ) ;
81+ const [ activeIndex , setActiveIndex ] = useState ( 0 ) ;
82+ const query = value . trim ( ) . toLowerCase ( ) ;
83+ const visibleSuggestions = useMemo (
84+ ( ) =>
85+ suggestions
86+ . filter ( ( suggestion ) => suggestion . toLowerCase ( ) . includes ( query ) )
87+ . slice ( 0 , 8 ) ,
88+ [ query , suggestions ]
89+ ) ;
90+
91+ function selectSuggestion ( nextValue : string ) : void {
92+ onChange ( nextValue ) ;
93+ setOpen ( false ) ;
94+ setActiveIndex ( 0 ) ;
95+ }
96+
97+ return (
98+ < div className = "relative" >
99+ < Input
100+ id = { inputId }
101+ value = { value }
102+ onChange = { ( event ) => {
103+ onChange ( event . target . value ) ;
104+ setOpen ( true ) ;
105+ setActiveIndex ( 0 ) ;
106+ } }
107+ onFocus = { ( ) => setOpen ( true ) }
108+ onBlur = { ( ) => setOpen ( false ) }
109+ onKeyDown = { ( event ) => {
110+ if ( ! open && [ "ArrowDown" , "ArrowUp" ] . includes ( event . key ) ) {
111+ setOpen ( true ) ;
112+ return ;
113+ }
114+ if ( event . key === "Escape" ) {
115+ setOpen ( false ) ;
116+ return ;
117+ }
118+ if ( visibleSuggestions . length === 0 ) return ;
119+ if ( event . key === "ArrowDown" ) {
120+ event . preventDefault ( ) ;
121+ setActiveIndex ( ( index ) => ( index + 1 ) % visibleSuggestions . length ) ;
122+ }
123+ if ( event . key === "ArrowUp" ) {
124+ event . preventDefault ( ) ;
125+ setActiveIndex (
126+ ( index ) =>
127+ ( index - 1 + visibleSuggestions . length ) %
128+ visibleSuggestions . length
129+ ) ;
130+ }
131+ if ( event . key === "Enter" && open ) {
132+ event . preventDefault ( ) ;
133+ selectSuggestion ( visibleSuggestions [ activeIndex ] ) ;
134+ }
135+ } }
136+ placeholder = { placeholder }
137+ disabled = { disabled }
138+ role = "combobox"
139+ aria-expanded = { open && visibleSuggestions . length > 0 }
140+ aria-controls = { listboxId }
141+ aria-autocomplete = "list"
142+ />
143+ { open && visibleSuggestions . length > 0 && ! disabled && (
144+ < div
145+ id = { listboxId }
146+ role = "listbox"
147+ className = "absolute left-0 right-0 top-full z-30 mt-1 max-h-56 overflow-auto rounded-md border border-border bg-popover p-1 text-xs shadow-lg"
148+ >
149+ { visibleSuggestions . map ( ( suggestion , index ) => (
150+ < button
151+ key = { suggestion }
152+ type = "button"
153+ role = "option"
154+ aria-selected = { index === activeIndex }
155+ className = "block w-full rounded px-2 py-1.5 text-left hover:bg-accent aria-selected:bg-accent"
156+ onMouseDown = { ( event ) => {
157+ event . preventDefault ( ) ;
158+ selectSuggestion ( suggestion ) ;
159+ } }
160+ onMouseEnter = { ( ) => setActiveIndex ( index ) }
161+ >
162+ { suggestion }
163+ </ button >
164+ ) ) }
165+ </ div >
166+ ) }
167+ </ div >
168+ ) ;
169+ }
170+
23171export function AdminCatalogSection ( {
24172 references,
25173 onReferencesChange,
@@ -28,22 +176,26 @@ export function AdminCatalogSection({
28176 const [ brand , setBrand ] = useState ( "" ) ;
29177 const [ brandForSet , setBrandForSet ] = useState ( "" ) ;
30178 const [ setName , setSetName ] = useState ( "" ) ;
179+ const [ brandForVariation , setBrandForVariation ] = useState ( "" ) ;
31180 const [ setForVariation , setSetForVariation ] = useState ( "" ) ;
32181 const [ variation , setVariation ] = useState ( "" ) ;
33182 const [ error , setError ] = useState < string | null > ( null ) ;
34183 const [ loading , setLoading ] = useState ( false ) ;
35184
36- const setsForBrand = useMemo ( ( ) => {
37- const key = brandForSet . trim ( ) ;
38- if ( ! key ) return [ ] ;
39- return references . brandSets [ key ] ?? [ ] ;
40- } , [ references . brandSets , brandForSet ] ) ;
185+ const setsForBrand = useMemo (
186+ ( ) => setsLinkedToBrand ( references , brandForSet ) ,
187+ [ references , brandForSet ]
188+ ) ;
41189
42- const variationsForSet = useMemo ( ( ) => {
43- const key = setForVariation . trim ( ) ;
44- if ( ! key ) return [ ] ;
45- return references . setVariations [ key ] ?? [ ] ;
46- } , [ references . setVariations , setForVariation ] ) ;
190+ const setsForVariationBrand = useMemo (
191+ ( ) => setsLinkedToBrand ( references , brandForVariation ) ,
192+ [ references , brandForVariation ]
193+ ) ;
194+
195+ const variationsForSet = useMemo (
196+ ( ) => variationsLinkedToSet ( references , setForVariation ) ,
197+ [ references , setForVariation ]
198+ ) ;
47199
48200 async function run ( action : ( ) => Promise < References > ) {
49201 setError ( null ) ;
@@ -119,35 +271,22 @@ export function AdminCatalogSection({
119271 < TabsContent value = "sets" className = "space-y-4 pt-4" >
120272 < div className = "grid gap-6 lg:grid-cols-2" >
121273 < div className = "space-y-3 rounded-lg border border-border p-4" >
122- < Label htmlFor = "admin-set-brand" > { t ( "admin.players.unitAdd" ) } </ Label >
274+ < Label > { t ( "admin.players.unitAdd" ) } </ Label >
123275 < div className = "space-y-2" >
124- < Input
125- id = "admin-set-brand"
126- list = "admin-brands-list"
276+ < CatalogCombobox
127277 value = { brandForSet }
128- onChange = { ( e ) => setBrandForSet ( e . target . value ) }
278+ onChange = { setBrandForSet }
129279 placeholder = { t ( "admin.catalog.setBrandPlaceholder" ) }
280+ suggestions = { references . brands }
130281 disabled = { loading }
131282 />
132- < datalist id = "admin-brands-list" >
133- { references . brands . map ( ( item ) => (
134- < option key = { item } value = { item } />
135- ) ) }
136- </ datalist >
137- < Input
138- list = { setsForBrand . length > 0 ? "admin-sets-list" : undefined }
283+ < CatalogCombobox
139284 value = { setName }
140- onChange = { ( e ) => setSetName ( e . target . value ) }
285+ onChange = { setSetName }
141286 placeholder = { t ( "admin.catalog.setNamePlaceholder" ) }
287+ suggestions = { setsForBrand }
142288 disabled = { loading }
143289 />
144- { setsForBrand . length > 0 && (
145- < datalist id = "admin-sets-list" >
146- { setsForBrand . map ( ( item ) => (
147- < option key = { item } value = { item } />
148- ) ) }
149- </ datalist >
150- ) }
151290 < Button
152291 type = "button"
153292 disabled = { loading }
@@ -186,35 +325,29 @@ export function AdminCatalogSection({
186325 < TabsContent value = "variations" className = "space-y-4 pt-4" >
187326 < div className = "grid gap-6 lg:grid-cols-2" >
188327 < div className = "space-y-3 rounded-lg border border-border p-4" >
189- < Label htmlFor = "admin-variation-set" > { t ( "admin.players.unitAdd" ) } </ Label >
328+ < Label > { t ( "admin.players.unitAdd" ) } </ Label >
190329 < div className = "space-y-2" >
191- < Input
192- id = "admin-variation-set"
193- list = "admin-variation-sets-list"
330+ < CatalogCombobox
331+ value = { brandForVariation }
332+ onChange = { setBrandForVariation }
333+ placeholder = { t ( "admin.catalog.variationBrandPlaceholder" ) }
334+ suggestions = { references . brands }
335+ disabled = { loading }
336+ />
337+ < CatalogCombobox
194338 value = { setForVariation }
195- onChange = { ( e ) => setSetForVariation ( e . target . value ) }
339+ onChange = { setSetForVariation }
196340 placeholder = { t ( "admin.catalog.variationSetPlaceholder" ) }
341+ suggestions = { setsForVariationBrand }
197342 disabled = { loading }
198343 />
199- < datalist id = "admin-variation-sets-list" >
200- { references . sets . map ( ( item ) => (
201- < option key = { item } value = { item } />
202- ) ) }
203- </ datalist >
204- < Input
205- list = { variationsForSet . length > 0 ? "admin-variations-list" : undefined }
344+ < CatalogCombobox
206345 value = { variation }
207- onChange = { ( e ) => setVariation ( e . target . value ) }
346+ onChange = { setVariation }
208347 placeholder = { t ( "admin.catalog.variationPlaceholder" ) }
348+ suggestions = { variationsForSet }
209349 disabled = { loading }
210350 />
211- { variationsForSet . length > 0 && (
212- < datalist id = "admin-variations-list" >
213- { variationsForSet . map ( ( item ) => (
214- < option key = { item } value = { item } />
215- ) ) }
216- </ datalist >
217- ) }
218351 < Button
219352 type = "button"
220353 disabled = { loading }
0 commit comments