Skip to content

Commit 0cc9728

Browse files
authored
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
1 parent 10110a9 commit 0cc9728

File tree

7 files changed

+264
-6
lines changed

7 files changed

+264
-6
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674))
1313
- Make `Disclosure.Button` close the disclosure inside a `Disclosure.Panel` ([#682](https://github.com/tailwindlabs/headlessui/pull/682))
14+
- Add `aria-orientation` to `Listbox`, which swaps Up/Down with Left/Right keys ([#683](https://github.com/tailwindlabs/headlessui/pull/683))
1415

1516
## [Unreleased - Vue]
1617

1718
### Added
1819

1920
- Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674))
2021
- Make `DisclosureButton` close the disclosure inside a `DisclosurePanel` ([#682](https://github.com/tailwindlabs/headlessui/pull/682))
22+
- Add `aria-orientation` to `Listbox`, which swaps Up/Down with Left/Right keys ([#683](https://github.com/tailwindlabs/headlessui/pull/683))
2123

2224
## [@headlessui/react@v1.3.0] - 2021-06-21
2325

packages/@headlessui-react/src/components/listbox/listbox.test.tsx

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1837,6 +1837,54 @@ describe('Keyboard interactions', () => {
18371837
)
18381838
})
18391839

1840+
describe('`ArrowRight` key', () => {
1841+
it(
1842+
'should be possible to use ArrowRight to navigate the listbox options',
1843+
suppressConsoleLogs(async () => {
1844+
render(
1845+
<Listbox value={undefined} onChange={console.log} horizontal>
1846+
<Listbox.Button>Trigger</Listbox.Button>
1847+
<Listbox.Options>
1848+
<Listbox.Option value="a">Option A</Listbox.Option>
1849+
<Listbox.Option value="b">Option B</Listbox.Option>
1850+
<Listbox.Option value="c">Option C</Listbox.Option>
1851+
</Listbox.Options>
1852+
</Listbox>
1853+
)
1854+
1855+
assertListboxButton({
1856+
state: ListboxState.InvisibleUnmounted,
1857+
attributes: { id: 'headlessui-listbox-button-1' },
1858+
})
1859+
assertListbox({ state: ListboxState.InvisibleUnmounted })
1860+
1861+
// Focus the button
1862+
getListboxButton()?.focus()
1863+
1864+
// Open listbox
1865+
await press(Keys.Enter)
1866+
1867+
// Verify we have listbox options
1868+
let options = getListboxOptions()
1869+
expect(options).toHaveLength(3)
1870+
options.forEach(option => assertListboxOption(option))
1871+
assertActiveListboxOption(options[0])
1872+
1873+
// We should be able to go right once
1874+
await press(Keys.ArrowRight)
1875+
assertActiveListboxOption(options[1])
1876+
1877+
// We should be able to go right again
1878+
await press(Keys.ArrowRight)
1879+
assertActiveListboxOption(options[2])
1880+
1881+
// We should NOT be able to go right again (because last option). Current implementation won't go around.
1882+
await press(Keys.ArrowRight)
1883+
assertActiveListboxOption(options[2])
1884+
})
1885+
)
1886+
})
1887+
18401888
describe('`ArrowUp` key', () => {
18411889
it(
18421890
'should be possible to open the listbox with ArrowUp and the last option should be active',
@@ -2127,6 +2175,64 @@ describe('Keyboard interactions', () => {
21272175
)
21282176
})
21292177

2178+
describe('`ArrowLeft` key', () => {
2179+
it(
2180+
'should be possible to use ArrowLeft to navigate the listbox options',
2181+
suppressConsoleLogs(async () => {
2182+
render(
2183+
<Listbox value={undefined} onChange={console.log} horizontal>
2184+
<Listbox.Button>Trigger</Listbox.Button>
2185+
<Listbox.Options>
2186+
<Listbox.Option value="a">Option A</Listbox.Option>
2187+
<Listbox.Option value="b">Option B</Listbox.Option>
2188+
<Listbox.Option value="c">Option C</Listbox.Option>
2189+
</Listbox.Options>
2190+
</Listbox>
2191+
)
2192+
2193+
assertListboxButton({
2194+
state: ListboxState.InvisibleUnmounted,
2195+
attributes: { id: 'headlessui-listbox-button-1' },
2196+
})
2197+
assertListbox({ state: ListboxState.InvisibleUnmounted })
2198+
2199+
// Focus the button
2200+
getListboxButton()?.focus()
2201+
2202+
// Open listbox
2203+
await press(Keys.ArrowUp)
2204+
2205+
// Verify it is visible
2206+
assertListboxButton({ state: ListboxState.Visible })
2207+
assertListbox({
2208+
state: ListboxState.Visible,
2209+
attributes: { id: 'headlessui-listbox-options-2' },
2210+
orientation: 'horizontal',
2211+
})
2212+
assertActiveElement(getListbox())
2213+
assertListboxButtonLinkedWithListbox()
2214+
2215+
// Verify we have listbox options
2216+
let options = getListboxOptions()
2217+
expect(options).toHaveLength(3)
2218+
options.forEach(option => assertListboxOption(option))
2219+
assertActiveListboxOption(options[2])
2220+
2221+
// We should be able to go left once
2222+
await press(Keys.ArrowLeft)
2223+
assertActiveListboxOption(options[1])
2224+
2225+
// We should be able to go left again
2226+
await press(Keys.ArrowLeft)
2227+
assertActiveListboxOption(options[0])
2228+
2229+
// We should NOT be able to go left again (because first option). Current implementation won't go around.
2230+
await press(Keys.ArrowLeft)
2231+
assertActiveListboxOption(options[0])
2232+
})
2233+
)
2234+
})
2235+
21302236
describe('`End` key', () => {
21312237
it(
21322238
'should be possible to use the End key to go to the last listbox option',

packages/@headlessui-react/src/components/listbox/listbox.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,14 @@ type ListboxOptionDataRef = MutableRefObject<{
4646

4747
interface StateDefinition {
4848
listboxState: ListboxStates
49+
50+
orientation: 'horizontal' | 'vertical'
51+
4952
propsRef: MutableRefObject<{ value: unknown; onChange(value: unknown): void }>
5053
labelRef: MutableRefObject<HTMLLabelElement | null>
5154
buttonRef: MutableRefObject<HTMLButtonElement | null>
5255
optionsRef: MutableRefObject<HTMLUListElement | null>
56+
5357
disabled: boolean
5458
options: { id: string; dataRef: ListboxOptionDataRef }[]
5559
searchQuery: string
@@ -61,6 +65,7 @@ enum ActionTypes {
6165
CloseListbox,
6266

6367
SetDisabled,
68+
SetOrientation,
6469

6570
GoToOption,
6671
Search,
@@ -74,6 +79,7 @@ type Actions =
7479
| { type: ActionTypes.CloseListbox }
7580
| { type: ActionTypes.OpenListbox }
7681
| { type: ActionTypes.SetDisabled; disabled: boolean }
82+
| { type: ActionTypes.SetOrientation; orientation: StateDefinition['orientation'] }
7783
| { type: ActionTypes.GoToOption; focus: Focus.Specific; id: string }
7884
| { type: ActionTypes.GoToOption; focus: Exclude<Focus, Focus.Specific> }
7985
| { type: ActionTypes.Search; value: string }
@@ -101,6 +107,10 @@ let reducers: {
101107
if (state.disabled === action.disabled) return state
102108
return { ...state, disabled: action.disabled }
103109
},
110+
[ActionTypes.SetOrientation](state, action) {
111+
if (state.orientation === action.orientation) return state
112+
return { ...state, orientation: action.orientation }
113+
},
104114
[ActionTypes.GoToOption](state, action) {
105115
if (state.disabled) return state
106116
if (state.listboxState === ListboxStates.Closed) return state
@@ -193,16 +203,20 @@ export function Listbox<TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG, T
193203
value: TType
194204
onChange(value: TType): void
195205
disabled?: boolean
206+
horizontal?: boolean
196207
}
197208
) {
198-
let { value, onChange, disabled = false, ...passThroughProps } = props
209+
let { value, onChange, disabled = false, horizontal = false, ...passThroughProps } = props
210+
const orientation = horizontal ? 'horizontal' : 'vertical'
211+
199212
let reducerBag = useReducer(stateReducer, {
200213
listboxState: ListboxStates.Closed,
201214
propsRef: { current: { value, onChange } },
202215
labelRef: createRef(),
203216
buttonRef: createRef(),
204217
optionsRef: createRef(),
205218
disabled,
219+
orientation,
206220
options: [],
207221
searchQuery: '',
208222
activeOptionIndex: null,
@@ -216,6 +230,9 @@ export function Listbox<TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG, T
216230
propsRef.current.onChange = onChange
217231
}, [onChange, propsRef])
218232
useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetDisabled, disabled }), [disabled])
233+
useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetOrientation, orientation }), [
234+
orientation,
235+
])
219236

220237
// Handle outside click
221238
useWindowEvent('mousedown', event => {
@@ -413,6 +430,7 @@ interface OptionsRenderPropArg {
413430
type OptionsPropsWeControl =
414431
| 'aria-activedescendant'
415432
| 'aria-labelledby'
433+
| 'aria-orientation'
416434
| 'id'
417435
| 'onKeyDown'
418436
| 'role'
@@ -478,12 +496,12 @@ let Options = forwardRefWithAs(function Options<
478496
disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
479497
break
480498

481-
case Keys.ArrowDown:
499+
case match(state.orientation, { vertical: Keys.ArrowDown, horizontal: Keys.ArrowRight }):
482500
event.preventDefault()
483501
event.stopPropagation()
484502
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Next })
485503

486-
case Keys.ArrowUp:
504+
case match(state.orientation, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }):
487505
event.preventDefault()
488506
event.stopPropagation()
489507
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Previous })
@@ -535,6 +553,7 @@ let Options = forwardRefWithAs(function Options<
535553
'aria-activedescendant':
536554
state.activeOptionIndex === null ? undefined : state.options[state.activeOptionIndex]?.id,
537555
'aria-labelledby': labelledby,
556+
'aria-orientation': state.orientation,
538557
id,
539558
onKeyDown: handleKeyDown,
540559
role: 'listbox',

packages/@headlessui-react/src/test-utils/accessibility-assertions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,9 +263,12 @@ export function assertListbox(
263263
attributes?: Record<string, string | null>
264264
textContent?: string
265265
state: ListboxState
266+
orientation?: 'horizontal' | 'vertical'
266267
},
267268
listbox = getListbox()
268269
) {
270+
let { orientation = 'vertical' } = options
271+
269272
try {
270273
switch (options.state) {
271274
case ListboxState.InvisibleHidden:
@@ -274,6 +277,7 @@ export function assertListbox(
274277
assertHidden(listbox)
275278

276279
expect(listbox).toHaveAttribute('aria-labelledby')
280+
expect(listbox).toHaveAttribute('aria-orientation', orientation)
277281
expect(listbox).toHaveAttribute('role', 'listbox')
278282

279283
if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
@@ -289,6 +293,7 @@ export function assertListbox(
289293
assertVisible(listbox)
290294

291295
expect(listbox).toHaveAttribute('aria-labelledby')
296+
expect(listbox).toHaveAttribute('aria-orientation', orientation)
292297
expect(listbox).toHaveAttribute('role', 'listbox')
293298

294299
if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)

packages/@headlessui-vue/src/components/listbox/listbox.test.tsx

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1933,6 +1933,57 @@ describe('Keyboard interactions', () => {
19331933
)
19341934
})
19351935

1936+
describe('`ArrowRight` key', () => {
1937+
it(
1938+
'should be possible to use ArrowRight to navigate the listbox options',
1939+
suppressConsoleLogs(async () => {
1940+
renderTemplate({
1941+
template: html`
1942+
<Listbox v-model="value" horizontal>
1943+
<ListboxButton>Trigger</ListboxButton>
1944+
<ListboxOptions>
1945+
<ListboxOption value="a">Option A</ListboxOption>
1946+
<ListboxOption value="b">Option B</ListboxOption>
1947+
<ListboxOption value="c">Option C</ListboxOption>
1948+
</ListboxOptions>
1949+
</Listbox>
1950+
`,
1951+
setup: () => ({ value: ref(null) }),
1952+
})
1953+
1954+
assertListboxButton({
1955+
state: ListboxState.InvisibleUnmounted,
1956+
attributes: { id: 'headlessui-listbox-button-1' },
1957+
})
1958+
assertListbox({ state: ListboxState.InvisibleUnmounted })
1959+
1960+
// Focus the button
1961+
getListboxButton()?.focus()
1962+
1963+
// Open listbox
1964+
await press(Keys.Enter)
1965+
1966+
// Verify we have listbox options
1967+
let options = getListboxOptions()
1968+
expect(options).toHaveLength(3)
1969+
options.forEach(option => assertListboxOption(option))
1970+
assertActiveListboxOption(options[0])
1971+
1972+
// We should be able to go right once
1973+
await press(Keys.ArrowRight)
1974+
assertActiveListboxOption(options[1])
1975+
1976+
// We should be able to go right again
1977+
await press(Keys.ArrowRight)
1978+
assertActiveListboxOption(options[2])
1979+
1980+
// We should NOT be able to go right again (because last option). Current implementation won't go around.
1981+
await press(Keys.ArrowRight)
1982+
assertActiveListboxOption(options[2])
1983+
})
1984+
)
1985+
})
1986+
19361987
describe('`ArrowUp` key', () => {
19371988
it(
19381989
'should be possible to open the listbox with ArrowUp and the last option should be active',
@@ -2244,6 +2295,67 @@ describe('Keyboard interactions', () => {
22442295
)
22452296
})
22462297

2298+
describe('`ArrowLeft` key', () => {
2299+
it(
2300+
'should be possible to use ArrowLeft to navigate the listbox options',
2301+
suppressConsoleLogs(async () => {
2302+
renderTemplate({
2303+
template: html`
2304+
<Listbox v-model="value" horizontal>
2305+
<ListboxButton>Trigger</ListboxButton>
2306+
<ListboxOptions>
2307+
<ListboxOption value="a">Option A</ListboxOption>
2308+
<ListboxOption value="b">Option B</ListboxOption>
2309+
<ListboxOption value="c">Option C</ListboxOption>
2310+
</ListboxOptions>
2311+
</Listbox>
2312+
`,
2313+
setup: () => ({ value: ref(null) }),
2314+
})
2315+
2316+
assertListboxButton({
2317+
state: ListboxState.InvisibleUnmounted,
2318+
attributes: { id: 'headlessui-listbox-button-1' },
2319+
})
2320+
assertListbox({ state: ListboxState.InvisibleUnmounted })
2321+
2322+
// Focus the button
2323+
getListboxButton()?.focus()
2324+
2325+
// Open listbox
2326+
await press(Keys.ArrowUp)
2327+
2328+
// Verify it is visible
2329+
assertListboxButton({ state: ListboxState.Visible })
2330+
assertListbox({
2331+
state: ListboxState.Visible,
2332+
attributes: { id: 'headlessui-listbox-options-2' },
2333+
orientation: 'horizontal',
2334+
})
2335+
assertActiveElement(getListbox())
2336+
assertListboxButtonLinkedWithListbox()
2337+
2338+
// Verify we have listbox options
2339+
let options = getListboxOptions()
2340+
expect(options).toHaveLength(3)
2341+
options.forEach(option => assertListboxOption(option))
2342+
assertActiveListboxOption(options[2])
2343+
2344+
// We should be able to go left once
2345+
await press(Keys.ArrowLeft)
2346+
assertActiveListboxOption(options[1])
2347+
2348+
// We should be able to go left again
2349+
await press(Keys.ArrowLeft)
2350+
assertActiveListboxOption(options[0])
2351+
2352+
// We should NOT be able to go left again (because first option). Current implementation won't go around.
2353+
await press(Keys.ArrowLeft)
2354+
assertActiveListboxOption(options[0])
2355+
})
2356+
)
2357+
})
2358+
22472359
describe('`End` key', () => {
22482360
it(
22492361
'should be possible to use the End key to go to the last listbox option',

0 commit comments

Comments
 (0)