Skip to content

Commit

Permalink
Add aria-orientation to the Listbox component (#683)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
RobinMalfait authored Jul 13, 2021
1 parent 10110a9 commit 0cc9728
Show file tree
Hide file tree
Showing 7 changed files with 264 additions and 6 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ 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]

### Added

- 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

Expand Down
106 changes: 106 additions & 0 deletions packages/@headlessui-react/src/components/listbox/listbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
25 changes: 22 additions & 3 deletions packages/@headlessui-react/src/components/listbox/listbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -61,6 +65,7 @@ enum ActionTypes {
CloseListbox,

SetDisabled,
SetOrientation,

GoToOption,
Search,
Expand All @@ -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 }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -193,16 +203,20 @@ 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 } },
labelRef: createRef(),
buttonRef: createRef(),
optionsRef: createRef(),
disabled,
orientation,
options: [],
searchQuery: '',
activeOptionIndex: null,
Expand All @@ -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 => {
Expand Down Expand Up @@ -413,6 +430,7 @@ interface OptionsRenderPropArg {
type OptionsPropsWeControl =
| 'aria-activedescendant'
| 'aria-labelledby'
| 'aria-orientation'
| 'id'
| 'onKeyDown'
| 'role'
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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)
Expand Down
112 changes: 112 additions & 0 deletions packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Loading

0 comments on commit 0cc9728

Please sign in to comment.