diff --git a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
index 6e75cf05d0..bb232a0c63 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
@@ -1837,6 +1837,54 @@ describe('Keyboard interactions', () => {
)
})
+ describe('`ArrowRight` key', () => {
+ it(
+ 'should be possible to use ArrowRight to navigate the listbox options',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option))
+ assertActiveListboxOption(options[0])
+
+ // We should be able to go right once
+ await press(Keys.ArrowRight)
+ assertActiveListboxOption(options[1])
+
+ // We should be able to go right again
+ await press(Keys.ArrowRight)
+ assertActiveListboxOption(options[2])
+
+ // We should NOT be able to go right again (because last option). Current implementation won't go around.
+ await press(Keys.ArrowRight)
+ assertActiveListboxOption(options[2])
+ })
+ )
+ })
+
describe('`ArrowUp` key', () => {
it(
'should be possible to open the listbox with ArrowUp and the last option should be active',
@@ -2127,6 +2175,64 @@ describe('Keyboard interactions', () => {
)
})
+ describe('`ArrowLeft` key', () => {
+ it(
+ 'should be possible to use ArrowLeft to navigate the listbox options',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.ArrowUp)
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({
+ state: ListboxState.Visible,
+ attributes: { id: 'headlessui-listbox-options-2' },
+ orientation: 'horizontal',
+ })
+ assertActiveElement(getListbox())
+ assertListboxButtonLinkedWithListbox()
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option))
+ assertActiveListboxOption(options[2])
+
+ // We should be able to go left once
+ await press(Keys.ArrowLeft)
+ assertActiveListboxOption(options[1])
+
+ // We should be able to go left again
+ await press(Keys.ArrowLeft)
+ assertActiveListboxOption(options[0])
+
+ // We should NOT be able to go left again (because first option). Current implementation won't go around.
+ await press(Keys.ArrowLeft)
+ assertActiveListboxOption(options[0])
+ })
+ )
+ })
+
describe('`End` key', () => {
it(
'should be possible to use the End key to go to the last listbox option',
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx
index 3f8b2ce60c..10b0e39b1c 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx
@@ -46,10 +46,14 @@ type ListboxOptionDataRef = MutableRefObject<{
interface StateDefinition {
listboxState: ListboxStates
+
+ orientation: 'horizontal' | 'vertical'
+
propsRef: MutableRefObject<{ value: unknown; onChange(value: unknown): void }>
labelRef: MutableRefObject
buttonRef: MutableRefObject
optionsRef: MutableRefObject
+
disabled: boolean
options: { id: string; dataRef: ListboxOptionDataRef }[]
searchQuery: string
@@ -61,6 +65,7 @@ enum ActionTypes {
CloseListbox,
SetDisabled,
+ SetOrientation,
GoToOption,
Search,
@@ -74,6 +79,7 @@ type Actions =
| { type: ActionTypes.CloseListbox }
| { type: ActionTypes.OpenListbox }
| { type: ActionTypes.SetDisabled; disabled: boolean }
+ | { type: ActionTypes.SetOrientation; orientation: StateDefinition['orientation'] }
| { type: ActionTypes.GoToOption; focus: Focus.Specific; id: string }
| { type: ActionTypes.GoToOption; focus: Exclude }
| { type: ActionTypes.Search; value: string }
@@ -101,6 +107,10 @@ let reducers: {
if (state.disabled === action.disabled) return state
return { ...state, disabled: action.disabled }
},
+ [ActionTypes.SetOrientation](state, action) {
+ if (state.orientation === action.orientation) return state
+ return { ...state, orientation: action.orientation }
+ },
[ActionTypes.GoToOption](state, action) {
if (state.disabled) return state
if (state.listboxState === ListboxStates.Closed) return state
@@ -193,9 +203,12 @@ export function Listbox dispatch({ type: ActionTypes.SetDisabled, disabled }), [disabled])
+ useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetOrientation, orientation }), [
+ orientation,
+ ])
// Handle outside click
useWindowEvent('mousedown', event => {
@@ -413,6 +430,7 @@ interface OptionsRenderPropArg {
type OptionsPropsWeControl =
| 'aria-activedescendant'
| 'aria-labelledby'
+ | 'aria-orientation'
| 'id'
| 'onKeyDown'
| 'role'
@@ -478,12 +496,12 @@ let Options = forwardRefWithAs(function Options<
disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
break
- case Keys.ArrowDown:
+ case match(state.orientation, { vertical: Keys.ArrowDown, horizontal: Keys.ArrowRight }):
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Next })
- case Keys.ArrowUp:
+ case match(state.orientation, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }):
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Previous })
@@ -535,6 +553,7 @@ let Options = forwardRefWithAs(function Options<
'aria-activedescendant':
state.activeOptionIndex === null ? undefined : state.options[state.activeOptionIndex]?.id,
'aria-labelledby': labelledby,
+ 'aria-orientation': state.orientation,
id,
onKeyDown: handleKeyDown,
role: 'listbox',
diff --git a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts
index 29ca3a8835..5a6fb38a87 100644
--- a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts
+++ b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts
@@ -263,9 +263,12 @@ export function assertListbox(
attributes?: Record
textContent?: string
state: ListboxState
+ orientation?: 'horizontal' | 'vertical'
},
listbox = getListbox()
) {
+ let { orientation = 'vertical' } = options
+
try {
switch (options.state) {
case ListboxState.InvisibleHidden:
@@ -274,6 +277,7 @@ export function assertListbox(
assertHidden(listbox)
expect(listbox).toHaveAttribute('aria-labelledby')
+ expect(listbox).toHaveAttribute('aria-orientation', orientation)
expect(listbox).toHaveAttribute('role', 'listbox')
if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
@@ -289,6 +293,7 @@ export function assertListbox(
assertVisible(listbox)
expect(listbox).toHaveAttribute('aria-labelledby')
+ expect(listbox).toHaveAttribute('aria-orientation', orientation)
expect(listbox).toHaveAttribute('role', 'listbox')
if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
index 398a4ba485..a67db2630f 100644
--- a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
+++ b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
@@ -1933,6 +1933,57 @@ describe('Keyboard interactions', () => {
)
})
+ describe('`ArrowRight` key', () => {
+ it(
+ 'should be possible to use ArrowRight to navigate the listbox options',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option))
+ assertActiveListboxOption(options[0])
+
+ // We should be able to go right once
+ await press(Keys.ArrowRight)
+ assertActiveListboxOption(options[1])
+
+ // We should be able to go right again
+ await press(Keys.ArrowRight)
+ assertActiveListboxOption(options[2])
+
+ // We should NOT be able to go right again (because last option). Current implementation won't go around.
+ await press(Keys.ArrowRight)
+ assertActiveListboxOption(options[2])
+ })
+ )
+ })
+
describe('`ArrowUp` key', () => {
it(
'should be possible to open the listbox with ArrowUp and the last option should be active',
@@ -2244,6 +2295,67 @@ describe('Keyboard interactions', () => {
)
})
+ describe('`ArrowLeft` key', () => {
+ it(
+ 'should be possible to use ArrowLeft to navigate the listbox options',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.ArrowUp)
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({
+ state: ListboxState.Visible,
+ attributes: { id: 'headlessui-listbox-options-2' },
+ orientation: 'horizontal',
+ })
+ assertActiveElement(getListbox())
+ assertListboxButtonLinkedWithListbox()
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option))
+ assertActiveListboxOption(options[2])
+
+ // We should be able to go left once
+ await press(Keys.ArrowLeft)
+ assertActiveListboxOption(options[1])
+
+ // We should be able to go left again
+ await press(Keys.ArrowLeft)
+ assertActiveListboxOption(options[0])
+
+ // We should NOT be able to go left again (because first option). Current implementation won't go around.
+ await press(Keys.ArrowLeft)
+ assertActiveListboxOption(options[0])
+ })
+ )
+ })
+
describe('`End` key', () => {
it(
'should be possible to use the End key to go to the last listbox option',
diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.ts b/packages/@headlessui-vue/src/components/listbox/listbox.ts
index 00173b8309..49b5d28343 100644
--- a/packages/@headlessui-vue/src/components/listbox/listbox.ts
+++ b/packages/@headlessui-vue/src/components/listbox/listbox.ts
@@ -38,9 +38,12 @@ type StateDefinition = {
// State
listboxState: Ref
value: ComputedRef
+ orientation: Ref<'vertical' | 'horizontal'>
+
labelRef: Ref
buttonRef: Ref
optionsRef: Ref
+
disabled: Ref
options: Ref<{ id: string; dataRef: ListboxOptionDataRef }[]>
searchQuery: Ref
@@ -79,6 +82,7 @@ export let Listbox = defineComponent({
props: {
as: { type: [Object, String], default: 'template' },
disabled: { type: [Boolean], default: false },
+ horizontal: { type: [Boolean], default: false },
modelValue: { type: [Object, String, Number, Boolean] },
},
setup(props, { slots, attrs, emit }) {
@@ -95,6 +99,7 @@ export let Listbox = defineComponent({
let api = {
listboxState,
value,
+ orientation: computed(() => (props.horizontal ? 'horizontal' : 'vertical')),
labelRef,
buttonRef,
optionsRef,
@@ -206,7 +211,7 @@ export let Listbox = defineComponent({
return () => {
let slot = { open: listboxState.value === ListboxStates.Open, disabled: props.disabled }
return render({
- props: omit(props, ['modelValue', 'onUpdate:modelValue', 'disabled']),
+ props: omit(props, ['modelValue', 'onUpdate:modelValue', 'disabled', 'horizontal']),
slot,
slots,
attrs,
@@ -362,6 +367,7 @@ export let ListboxOptions = defineComponent({
? undefined
: api.options.value[api.activeOptionIndex.value]?.id,
'aria-labelledby': dom(api.labelRef)?.id ?? dom(api.buttonRef)?.id,
+ 'aria-orientation': api.orientation.value,
id: this.id,
onKeydown: this.handleKeyDown,
role: 'listbox',
@@ -410,12 +416,15 @@ export let ListboxOptions = defineComponent({
nextTick(() => dom(api.buttonRef)?.focus({ preventScroll: true }))
break
- case Keys.ArrowDown:
+ case match(api.orientation.value, {
+ vertical: Keys.ArrowDown,
+ horizontal: Keys.ArrowRight,
+ }):
event.preventDefault()
event.stopPropagation()
return api.goToOption(Focus.Next)
- case Keys.ArrowUp:
+ case match(api.orientation.value, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }):
event.preventDefault()
event.stopPropagation()
return api.goToOption(Focus.Previous)
diff --git a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts
index 29ca3a8835..5a6fb38a87 100644
--- a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts
+++ b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts
@@ -263,9 +263,12 @@ export function assertListbox(
attributes?: Record
textContent?: string
state: ListboxState
+ orientation?: 'horizontal' | 'vertical'
},
listbox = getListbox()
) {
+ let { orientation = 'vertical' } = options
+
try {
switch (options.state) {
case ListboxState.InvisibleHidden:
@@ -274,6 +277,7 @@ export function assertListbox(
assertHidden(listbox)
expect(listbox).toHaveAttribute('aria-labelledby')
+ expect(listbox).toHaveAttribute('aria-orientation', orientation)
expect(listbox).toHaveAttribute('role', 'listbox')
if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
@@ -289,6 +293,7 @@ export function assertListbox(
assertVisible(listbox)
expect(listbox).toHaveAttribute('aria-labelledby')
+ expect(listbox).toHaveAttribute('aria-orientation', orientation)
expect(listbox).toHaveAttribute('role', 'listbox')
if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)