diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index 1d9d0bf..a0acd98 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix clicking `Label` component should open `` ([#3707](https://github.com/tailwindlabs/headlessui/pull/3707))
- Ensure clicking on interactive elements inside `Label` component works ([#3709](https://github.com/tailwindlabs/headlessui/pull/3709))
- Fix focus not returned to SVG Element ([#3704](https://github.com/tailwindlabs/headlessui/pull/3704))
+- Fix `Listbox` not focusing first or last option on ArrowUp / ArrowDown ([#3721](https://github.com/tailwindlabs/headlessui/pull/3721))
## [2.2.2] - 2025-04-17
diff --git a/packages/@headlessui-react/src/components/listbox/listbox-machine.ts b/packages/@headlessui-react/src/components/listbox/listbox-machine.ts
index 26b7fb3..e9de917 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox-machine.ts
+++ b/packages/@headlessui-react/src/components/listbox/listbox-machine.ts
@@ -61,6 +61,7 @@ interface State {
optionsElement: HTMLElement | null
pendingShouldSort: boolean
+ pendingFocus: { focus: Exclude } | { focus: Focus.Specific; id: string }
}
export enum ActionTypes {
@@ -111,7 +112,10 @@ function adjustOrderedState(
type Actions =
| { type: ActionTypes.CloseListbox }
- | { type: ActionTypes.OpenListbox }
+ | {
+ type: ActionTypes.OpenListbox
+ focus: { focus: Exclude } | { focus: Focus.Specific; id: string }
+ }
| { type: ActionTypes.GoToOption; focus: Focus.Specific; id: string; trigger?: ActivationTrigger }
| {
type: ActionTypes.GoToOption
@@ -138,11 +142,12 @@ let reducers: {
return {
...state,
activeOptionIndex: null,
+ pendingFocus: { focus: Focus.Nothing },
listboxState: ListboxStates.Closed,
__demoMode: false,
}
},
- [ActionTypes.OpenListbox](state) {
+ [ActionTypes.OpenListbox](state, action) {
if (state.dataRef.current.disabled) return state
if (state.listboxState === ListboxStates.Open) return state
@@ -155,7 +160,13 @@ let reducers: {
activeOptionIndex = optionIdx
}
- return { ...state, listboxState: ListboxStates.Open, activeOptionIndex, __demoMode: false }
+ return {
+ ...state,
+ pendingFocus: action.focus,
+ listboxState: ListboxStates.Open,
+ activeOptionIndex,
+ __demoMode: false,
+ }
},
[ActionTypes.GoToOption](state, action) {
if (state.dataRef.current.disabled) return state
@@ -311,6 +322,14 @@ let reducers: {
let options = state.options.concat(action.options)
let activeOptionIndex = state.activeOptionIndex
+ if (state.pendingFocus.focus !== Focus.Nothing) {
+ activeOptionIndex = calculateActiveIndex(state.pendingFocus, {
+ resolveItems: () => options,
+ resolveActiveIndex: () => state.activeOptionIndex,
+ resolveId: (item) => item.id,
+ resolveDisabled: (item) => item.dataRef.current.disabled,
+ })
+ }
// Check if we need to make the newly registered option active.
if (state.activeOptionIndex === null) {
@@ -325,6 +344,7 @@ let reducers: {
...state,
options,
activeOptionIndex,
+ pendingFocus: { focus: Focus.Nothing },
pendingShouldSort: true,
}
},
@@ -385,6 +405,8 @@ export class ListboxMachine extends Machine, Actions> {
activationTrigger: ActivationTrigger.Other,
buttonElement: null,
optionsElement: null,
+ pendingShouldSort: false,
+ pendingFocus: { focus: Focus.Nothing },
__demoMode,
})
}
@@ -464,8 +486,10 @@ export class ListboxMachine extends Machine, Actions> {
closeListbox: () => {
this.send({ type: ActionTypes.CloseListbox })
},
- openListbox: () => {
- this.send({ type: ActionTypes.OpenListbox })
+ openListbox: (
+ focus: { focus: Exclude } | { focus: Focus.Specific; id: string }
+ ) => {
+ this.send({ type: ActionTypes.OpenListbox, focus })
},
selectActiveOption: () => {
if (this.state.activeOptionIndex !== null) {
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx
index a5a1833..cc4f05a 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx
@@ -404,14 +404,12 @@ function ButtonFn(
case Keys.Space:
case Keys.ArrowDown:
event.preventDefault()
- flushSync(() => machine.actions.openListbox())
- if (!data.value) machine.actions.goToOption({ focus: Focus.First })
+ machine.actions.openListbox({ focus: data.value ? Focus.Nothing : Focus.First })
break
case Keys.ArrowUp:
event.preventDefault()
- flushSync(() => machine.actions.openListbox())
- if (!data.value) machine.actions.goToOption({ focus: Focus.Last })
+ machine.actions.openListbox({ focus: data.value ? Focus.Nothing : Focus.Last })
break
}
})
@@ -435,7 +433,7 @@ function ButtonFn(
machine.state.buttonElement?.focus({ preventScroll: true })
} else {
event.preventDefault()
- machine.actions.openListbox()
+ machine.actions.openListbox({ focus: Focus.Nothing })
}
})