Skip to content

Commit c9f8f30

Browse files
authored
Fix Listbox not focusing first or last option on ArrowUp/ArrowDown (#3721)
This PR fixes an issue where if a Listbox does not have a value yet, and it's opened via an ArrowUp or ArrowDown (on the ListboxButton) then it didn't correctly go to the firs or last option. Before, we were opening the listbox in a `flushSync()` call, after that call we were focusing the first or last option depending on if you used the ArrowDown or ArrowUp keys. However, the options can and will be registered at a later point in time, which means that the focus of first or last option is technically going to fail because no options are available yet. With this fix we don't need the `flushSync` call, and instead we passthrough a pending focus. Once the options are registered, if a pending focus is present, only then will we focus the correct option. This gets rid of timing issues.
1 parent 0b8deaf commit c9f8f30

File tree

3 files changed

+33
-10
lines changed

3 files changed

+33
-10
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- Fix clicking `Label` component should open `<Input type="file">` ([#3707](https://github.com/tailwindlabs/headlessui/pull/3707))
1717
- Ensure clicking on interactive elements inside `Label` component works ([#3709](https://github.com/tailwindlabs/headlessui/pull/3709))
1818
- Fix focus not returned to SVG Element ([#3704](https://github.com/tailwindlabs/headlessui/pull/3704))
19+
- Fix `Listbox` not focusing first or last option on ArrowUp / ArrowDown ([#3721](https://github.com/tailwindlabs/headlessui/pull/3721))
1920

2021
## [2.2.2] - 2025-04-17
2122

packages/@headlessui-react/src/components/listbox/listbox-machine.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ interface State<T> {
6161
optionsElement: HTMLElement | null
6262

6363
pendingShouldSort: boolean
64+
pendingFocus: { focus: Exclude<Focus, Focus.Specific> } | { focus: Focus.Specific; id: string }
6465
}
6566

6667
export enum ActionTypes {
@@ -111,7 +112,10 @@ function adjustOrderedState<T>(
111112

112113
type Actions<T> =
113114
| { type: ActionTypes.CloseListbox }
114-
| { type: ActionTypes.OpenListbox }
115+
| {
116+
type: ActionTypes.OpenListbox
117+
focus: { focus: Exclude<Focus, Focus.Specific> } | { focus: Focus.Specific; id: string }
118+
}
115119
| { type: ActionTypes.GoToOption; focus: Focus.Specific; id: string; trigger?: ActivationTrigger }
116120
| {
117121
type: ActionTypes.GoToOption
@@ -138,11 +142,12 @@ let reducers: {
138142
return {
139143
...state,
140144
activeOptionIndex: null,
145+
pendingFocus: { focus: Focus.Nothing },
141146
listboxState: ListboxStates.Closed,
142147
__demoMode: false,
143148
}
144149
},
145-
[ActionTypes.OpenListbox](state) {
150+
[ActionTypes.OpenListbox](state, action) {
146151
if (state.dataRef.current.disabled) return state
147152
if (state.listboxState === ListboxStates.Open) return state
148153

@@ -155,7 +160,13 @@ let reducers: {
155160
activeOptionIndex = optionIdx
156161
}
157162

158-
return { ...state, listboxState: ListboxStates.Open, activeOptionIndex, __demoMode: false }
163+
return {
164+
...state,
165+
pendingFocus: action.focus,
166+
listboxState: ListboxStates.Open,
167+
activeOptionIndex,
168+
__demoMode: false,
169+
}
159170
},
160171
[ActionTypes.GoToOption](state, action) {
161172
if (state.dataRef.current.disabled) return state
@@ -311,6 +322,14 @@ let reducers: {
311322
let options = state.options.concat(action.options)
312323

313324
let activeOptionIndex = state.activeOptionIndex
325+
if (state.pendingFocus.focus !== Focus.Nothing) {
326+
activeOptionIndex = calculateActiveIndex(state.pendingFocus, {
327+
resolveItems: () => options,
328+
resolveActiveIndex: () => state.activeOptionIndex,
329+
resolveId: (item) => item.id,
330+
resolveDisabled: (item) => item.dataRef.current.disabled,
331+
})
332+
}
314333

315334
// Check if we need to make the newly registered option active.
316335
if (state.activeOptionIndex === null) {
@@ -325,6 +344,7 @@ let reducers: {
325344
...state,
326345
options,
327346
activeOptionIndex,
347+
pendingFocus: { focus: Focus.Nothing },
328348
pendingShouldSort: true,
329349
}
330350
},
@@ -385,6 +405,8 @@ export class ListboxMachine<T> extends Machine<State<T>, Actions<T>> {
385405
activationTrigger: ActivationTrigger.Other,
386406
buttonElement: null,
387407
optionsElement: null,
408+
pendingShouldSort: false,
409+
pendingFocus: { focus: Focus.Nothing },
388410
__demoMode,
389411
})
390412
}
@@ -464,8 +486,10 @@ export class ListboxMachine<T> extends Machine<State<T>, Actions<T>> {
464486
closeListbox: () => {
465487
this.send({ type: ActionTypes.CloseListbox })
466488
},
467-
openListbox: () => {
468-
this.send({ type: ActionTypes.OpenListbox })
489+
openListbox: (
490+
focus: { focus: Exclude<Focus, Focus.Specific> } | { focus: Focus.Specific; id: string }
491+
) => {
492+
this.send({ type: ActionTypes.OpenListbox, focus })
469493
},
470494
selectActiveOption: () => {
471495
if (this.state.activeOptionIndex !== null) {

packages/@headlessui-react/src/components/listbox/listbox.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -404,14 +404,12 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
404404
case Keys.Space:
405405
case Keys.ArrowDown:
406406
event.preventDefault()
407-
flushSync(() => machine.actions.openListbox())
408-
if (!data.value) machine.actions.goToOption({ focus: Focus.First })
407+
machine.actions.openListbox({ focus: data.value ? Focus.Nothing : Focus.First })
409408
break
410409

411410
case Keys.ArrowUp:
412411
event.preventDefault()
413-
flushSync(() => machine.actions.openListbox())
414-
if (!data.value) machine.actions.goToOption({ focus: Focus.Last })
412+
machine.actions.openListbox({ focus: data.value ? Focus.Nothing : Focus.Last })
415413
break
416414
}
417415
})
@@ -435,7 +433,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
435433
machine.state.buttonElement?.focus({ preventScroll: true })
436434
} else {
437435
event.preventDefault()
438-
machine.actions.openListbox()
436+
machine.actions.openListbox({ focus: Focus.Nothing })
439437
}
440438
})
441439

0 commit comments

Comments
 (0)