@@ -15,7 +15,7 @@ import {
1515} from '@headlessui/react'
1616import cn from 'classnames'
1717import { matchSorter } from 'match-sorter'
18- import { useEffect , useId , useState , type ReactNode , type Ref } from 'react'
18+ import { useEffect , useId , useRef , useState , type ReactNode , type Ref } from 'react'
1919
2020import { SelectArrows6Icon } from '@oxide/design-system/icons/react'
2121
@@ -145,146 +145,161 @@ export const Combobox = ({
145145 }
146146 const zIndex = usePopoverZIndex ( )
147147 const id = useId ( )
148+ // Tracks whether the dropdown is open so the onKeyDown handler can
149+ // distinguish Enter-to-select (dropdown open, let HUI handle it) from
150+ // Enter-to-submit (dropdown closed, fire onEnter). We use a ref instead
151+ // of HUI's `open` render prop because our handler runs before HUI's
152+ // (useRender merges user props first) and the render prop can be stale.
153+ // The ref stays current because onClose sets it synchronously during
154+ // HUI's own keydown handler. With the stale render prop, the handler
155+ // could see the combobox as closed one keydown too late, firing onEnter
156+ // instead of letting HUI select — hard to notice manually but caused
157+ // consistent e2e flakes in Firefox.
158+ const isOpenRef = useRef ( false )
148159 return (
149160 < HCombobox
150161 // necessary, as the displayed "value" is not the same as the actual selected item's *value*
151162 value = { selectedItemValue }
152163 // fallback to '' allows clearing field to work
153164 onChange = { ( val ) => onChange ( val || '' ) }
154- // we only want to keep the query on close when arbitrary values are allowed
155- onClose = { allowArbitraryValues ? undefined : ( ) => setQuery ( '' ) }
165+ onClose = { ( ) => {
166+ isOpenRef . current = false
167+ if ( ! allowArbitraryValues ) setQuery ( '' )
168+ } }
156169 disabled = { disabled || isLoading }
157170 immediate
158171 { ...props }
159172 >
160- { ( { open } ) => (
161- < div >
162- { label && (
163- // TODO: FieldLabel needs a real ID
164- < div className = "mb-2" >
165- < FieldLabel
166- id = { `${ id } -label` }
167- htmlFor = { `${ id } -input` }
168- optional = { ! required && ! hideOptionalTag }
169- >
170- { label }
171- </ FieldLabel >
172- { description && (
173- < TextInputHint id = { `${ id } -help-text` } > { description } </ TextInputHint >
174- ) }
175- </ div >
176- ) }
177- < div
178- className = { cn (
179- `flex rounded-md border focus-within:ring-2` ,
180- hasError
181- ? 'focus-error border-error-secondary focus-within:ring-error-secondary hover:border-error'
182- : 'border-default focus-within:ring-accent-secondary hover:border-hover' ,
183- disabled
184- ? 'text-disabled bg-disabled border-default! cursor-not-allowed'
185- : 'bg-default' ,
186- disabled && hasError && 'border-error-secondary!'
173+ { ( { open } ) => {
174+ // Sync open state to ref on render (handles the opening side)
175+ if ( open ) isOpenRef . current = true
176+ return (
177+ < div >
178+ { label && (
179+ // TODO: FieldLabel needs a real ID
180+ < div className = "mb-2" >
181+ < FieldLabel
182+ id = { `${ id } -label` }
183+ htmlFor = { `${ id } -input` }
184+ optional = { ! required && ! hideOptionalTag }
185+ >
186+ { label }
187+ </ FieldLabel >
188+ { description && (
189+ < TextInputHint id = { `${ id } -help-text` } > { description } </ TextInputHint >
190+ ) }
191+ </ div >
187192 ) }
188- // Putting the inputRef on the div makes it so the div can be focused by RHF when there's an error.
189- // We want to focus on the div (rather than the input) so the combobox doesn't open automatically
190- // and obscure the error message.
191- ref = { inputRef }
192- // tabIndex=-1 is necessary to make the div focusable
193- tabIndex = { - 1 }
194- >
195- < ComboboxInput
196- id = { `${ id } -input` }
197- // If an option has been selected, display either the selected item's label or value.
198- // If no option has been selected yet, or the user has started editing the input, display the query.
199- // We are using value here, as opposed to Headless UI's displayValue, so we can normalize
200- // the value entered into the input (via the onChange event).
201- value = {
202- selectedItemValue
203- ? allowArbitraryValues
204- ? selectedItemValue
205- : selectedItemLabel
206- : query
207- }
208- onChange = { ( event ) => {
209- const value = transform ? transform ( event . target . value ) : event . target . value
210- // updates the query state as the user types, in order to filter the list of items
211- setQuery ( value )
212- // if the parent component wants to know about input changes, call the callback
213- onInputChange ?.( value )
214- } }
215- onKeyDown = { ( e ) => {
216- // When the combobox is open, Enter is handled internally by
217- // Headless UI (selects the highlighted item). When closed,
218- // we need to prevent the default behavior to avoid submitting
219- // the containing form. We also considered always preventing
220- // default on Enter regardless of open status, but it appears
221- // to break the combobox handling. Headless UI likely checks
222- // e.defaultPrevented before processing item selection.
223- if ( e . key === 'Enter' && ! open ) {
224- e . preventDefault ( )
225- onEnter ?.( e )
226- }
227- } }
228- placeholder = { placeholder }
229- disabled = { disabled || isLoading }
193+ < div
230194 className = { cn (
231- `text-sans-md text-raise placeholder:text-tertiary h-10 w-full rounded-md border-none! px-3 py-2 outline-hidden!` ,
195+ `flex rounded-md border focus-within:ring-2` ,
196+ hasError
197+ ? 'focus-error border-error-secondary focus-within:ring-error-secondary hover:border-error'
198+ : 'border-default focus-within:ring-accent-secondary hover:border-hover' ,
232199 disabled
233200 ? 'text-disabled bg-disabled border-default! cursor-not-allowed'
234201 : 'bg-default' ,
235- hasError && 'focus -error'
202+ disabled && hasError && 'border -error-secondary! '
236203 ) }
237- />
238- { items . length > 0 && (
239- < ComboboxButton
204+ // Putting the inputRef on the div makes it so the div can be focused by RHF when there's an error.
205+ // We want to focus on the div (rather than the input) so the combobox doesn't open automatically
206+ // and obscure the error message.
207+ ref = { inputRef }
208+ // tabIndex=-1 is necessary to make the div focusable
209+ tabIndex = { - 1 }
210+ >
211+ < ComboboxInput
212+ id = { `${ id } -input` }
213+ // If an option has been selected, display either the selected item's label or value.
214+ // If no option has been selected yet, or the user has started editing the input, display the query.
215+ // We are using value here, as opposed to Headless UI's displayValue, so we can normalize
216+ // the value entered into the input (via the onChange event).
217+ value = {
218+ selectedItemValue
219+ ? allowArbitraryValues
220+ ? selectedItemValue
221+ : selectedItemLabel
222+ : query
223+ }
224+ onChange = { ( event ) => {
225+ const value = transform
226+ ? transform ( event . target . value )
227+ : event . target . value
228+ // updates the query state as the user types, in order to filter the list of items
229+ setQuery ( value )
230+ // if the parent component wants to know about input changes, call the callback
231+ onInputChange ?.( value )
232+ } }
233+ onKeyDown = { ( e ) => {
234+ // When the dropdown is open, Enter should select the
235+ // highlighted option (HUI handles this). When closed,
236+ // Enter should submit the subform via onEnter.
237+ if ( e . key === 'Enter' && ! isOpenRef . current ) {
238+ e . preventDefault ( )
239+ onEnter ?.( e )
240+ }
241+ } }
242+ placeholder = { placeholder }
243+ disabled = { disabled || isLoading }
240244 className = { cn (
241- 'border-secondary my-1.5 flex items-center border-l px-3' ,
242- disabled ? 'bg-disabled cursor-not-allowed' : 'bg-default'
245+ `text-sans-md text-raise placeholder:text-tertiary h-10 w-full rounded-md border-none! px-3 py-2 outline-hidden!` ,
246+ disabled
247+ ? 'text-disabled bg-disabled border-default! cursor-not-allowed'
248+ : 'bg-default' ,
249+ hasError && 'focus-error'
243250 ) }
244- aria-hidden
251+ />
252+ { items . length > 0 && (
253+ < ComboboxButton
254+ className = { cn (
255+ 'border-secondary my-1.5 flex items-center border-l px-3' ,
256+ disabled ? 'bg-disabled cursor-not-allowed' : 'bg-default'
257+ ) }
258+ aria-hidden
259+ >
260+ < SelectArrows6Icon title = "Select" className = "text-secondary w-2" />
261+ </ ComboboxButton >
262+ ) }
263+ </ div >
264+ { ( items . length > 0 || allowArbitraryValues ) && (
265+ < ComboboxOptions
266+ anchor = "bottom start"
267+ // 13px gap is presumably because it's measured from inside the outline or something
268+ className = { `ox-menu pointer-events-auto ${ zIndex } border-secondary relative w-[calc(var(--input-width)+var(--button-width))] overflow-y-auto border outline-hidden! [--anchor-gap:13px] empty:hidden` }
269+ modal = { false }
245270 >
246- < SelectArrows6Icon title = "Select" className = "text-secondary w-2" />
247- </ ComboboxButton >
271+ { filteredItems . map ( ( item ) => (
272+ < ComboboxOption
273+ key = { item . value }
274+ value = { item . value }
275+ className = "border-secondary relative border-b last:border-0"
276+ >
277+ { ( { focus, selected } ) => (
278+ // This *could* be done with data-[focus] and data-[selected] instead, but
279+ // it would be a lot more verbose. those can only be used with TW classes,
280+ // not our .is-selected and .is-highlighted, so we'd have to copy the pieces
281+ // of those rules one by one. Better to rely on the shared classes.
282+ < div
283+ className = { cn ( 'ox-menu-item' , {
284+ 'is-selected' : selected && query !== item . value ,
285+ 'is-highlighted' : focus ,
286+ } ) }
287+ >
288+ { item . label }
289+ </ div >
290+ ) }
291+ </ ComboboxOption >
292+ ) ) }
293+ { ! allowArbitraryValues && filteredItems . length === 0 && (
294+ < ComboboxOption disabled value = "no-matches" className = "relative" >
295+ < div className = "ox-menu-item text-disabled!" > No items match</ div >
296+ </ ComboboxOption >
297+ ) }
298+ </ ComboboxOptions >
248299 ) }
249300 </ div >
250- { ( items . length > 0 || allowArbitraryValues ) && (
251- < ComboboxOptions
252- anchor = "bottom start"
253- // 13px gap is presumably because it's measured from inside the outline or something
254- className = { `ox-menu pointer-events-auto ${ zIndex } border-secondary relative w-[calc(var(--input-width)+var(--button-width))] overflow-y-auto border outline-hidden! [--anchor-gap:13px] empty:hidden` }
255- modal = { false }
256- >
257- { filteredItems . map ( ( item ) => (
258- < ComboboxOption
259- key = { item . value }
260- value = { item . value }
261- className = "border-secondary relative border-b last:border-0"
262- >
263- { ( { focus, selected } ) => (
264- // This *could* be done with data-[focus] and data-[selected] instead, but
265- // it would be a lot more verbose. those can only be used with TW classes,
266- // not our .is-selected and .is-highlighted, so we'd have to copy the pieces
267- // of those rules one by one. Better to rely on the shared classes.
268- < div
269- className = { cn ( 'ox-menu-item' , {
270- 'is-selected' : selected && query !== item . value ,
271- 'is-highlighted' : focus ,
272- } ) }
273- >
274- { item . label }
275- </ div >
276- ) }
277- </ ComboboxOption >
278- ) ) }
279- { ! allowArbitraryValues && filteredItems . length === 0 && (
280- < ComboboxOption disabled value = "no-matches" className = "relative" >
281- < div className = "ox-menu-item text-disabled!" > No items match</ div >
282- </ ComboboxOption >
283- ) }
284- </ ComboboxOptions >
285- ) }
286- </ div >
287- ) }
301+ )
302+ } }
288303 </ HCombobox >
289304 )
290305}
0 commit comments