Add aria-orientation to the Listbox component (#683)

* add `aria-orientation` to the Listbox component

By default the `Listbox` will have an orientation of `vertical`. When
you pass the `horizontal` prop to the `Listbox` component then the
`aria-orientation` will be set to `horizontal`.

Additionally, we swap the previous/next keys:

- Vertical: ArrowUp/ArrowDown
- Horizontal: ArrowLeft/ArrowRight

* update changelog
This commit is contained in:
Robin Malfait
2021-07-13 23:36:15 +02:00
committed by GitHub
parent 10110a928f
commit 0cc9728694
7 changed files with 264 additions and 6 deletions
+2
View File
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674))
- Make `Disclosure.Button` close the disclosure inside a `Disclosure.Panel` ([#682](https://github.com/tailwindlabs/headlessui/pull/682))
- Add `aria-orientation` to `Listbox`, which swaps Up/Down with Left/Right keys ([#683](https://github.com/tailwindlabs/headlessui/pull/683))
## [Unreleased - Vue]
@@ -18,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674))
- Make `DisclosureButton` close the disclosure inside a `DisclosurePanel` ([#682](https://github.com/tailwindlabs/headlessui/pull/682))
- Add `aria-orientation` to `Listbox`, which swaps Up/Down with Left/Right keys ([#683](https://github.com/tailwindlabs/headlessui/pull/683))
## [@headlessui/react@v1.3.0] - 2021-06-21
@@ -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(
<Listbox value={undefined} onChange={console.log} horizontal>
<Listbox.Button>Trigger</Listbox.Button>
<Listbox.Options>
<Listbox.Option value="a">Option A</Listbox.Option>
<Listbox.Option value="b">Option B</Listbox.Option>
<Listbox.Option value="c">Option C</Listbox.Option>
</Listbox.Options>
</Listbox>
)
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(
<Listbox value={undefined} onChange={console.log} horizontal>
<Listbox.Button>Trigger</Listbox.Button>
<Listbox.Options>
<Listbox.Option value="a">Option A</Listbox.Option>
<Listbox.Option value="b">Option B</Listbox.Option>
<Listbox.Option value="c">Option C</Listbox.Option>
</Listbox.Options>
</Listbox>
)
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',
@@ -46,10 +46,14 @@ type ListboxOptionDataRef = MutableRefObject<{
interface StateDefinition {
listboxState: ListboxStates
orientation: 'horizontal' | 'vertical'
propsRef: MutableRefObject<{ value: unknown; onChange(value: unknown): void }>
labelRef: MutableRefObject<HTMLLabelElement | null>
buttonRef: MutableRefObject<HTMLButtonElement | null>
optionsRef: MutableRefObject<HTMLUListElement | null>
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<Focus, Focus.Specific> }
| { 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<TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG, T
value: TType
onChange(value: TType): void
disabled?: boolean
horizontal?: boolean
}
) {
let { value, onChange, disabled = false, ...passThroughProps } = props
let { value, onChange, disabled = false, horizontal = false, ...passThroughProps } = props
const orientation = horizontal ? 'horizontal' : 'vertical'
let reducerBag = useReducer(stateReducer, {
listboxState: ListboxStates.Closed,
propsRef: { current: { value, onChange } },
@@ -203,6 +216,7 @@ export function Listbox<TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG, T
buttonRef: createRef(),
optionsRef: createRef(),
disabled,
orientation,
options: [],
searchQuery: '',
activeOptionIndex: null,
@@ -216,6 +230,9 @@ export function Listbox<TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG, T
propsRef.current.onChange = onChange
}, [onChange, propsRef])
useIsoMorphicEffect(() => 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',
@@ -263,9 +263,12 @@ export function assertListbox(
attributes?: Record<string, string | null>
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)
@@ -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`
<Listbox v-model="value" horizontal>
<ListboxButton>Trigger</ListboxButton>
<ListboxOptions>
<ListboxOption value="a">Option A</ListboxOption>
<ListboxOption value="b">Option B</ListboxOption>
<ListboxOption value="c">Option C</ListboxOption>
</ListboxOptions>
</Listbox>
`,
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`
<Listbox v-model="value" horizontal>
<ListboxButton>Trigger</ListboxButton>
<ListboxOptions>
<ListboxOption value="a">Option A</ListboxOption>
<ListboxOption value="b">Option B</ListboxOption>
<ListboxOption value="c">Option C</ListboxOption>
</ListboxOptions>
</Listbox>
`,
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',
@@ -38,9 +38,12 @@ type StateDefinition = {
// State
listboxState: Ref<ListboxStates>
value: ComputedRef<unknown>
orientation: Ref<'vertical' | 'horizontal'>
labelRef: Ref<HTMLLabelElement | null>
buttonRef: Ref<HTMLButtonElement | null>
optionsRef: Ref<HTMLDivElement | null>
disabled: Ref<boolean>
options: Ref<{ id: string; dataRef: ListboxOptionDataRef }[]>
searchQuery: Ref<string>
@@ -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)
@@ -263,9 +263,12 @@ export function assertListbox(
attributes?: Record<string, string | null>
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)