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)