Skip to content

Commit da4b94f

Browse files
committed
Refactor SelectNext into [role=combobox] to improve a11y
1 parent 795843c commit da4b94f

File tree

4 files changed

+174
-109
lines changed

4 files changed

+174
-109
lines changed

src/components/input/SelectNext.tsx

+17-8
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ import { useFocusAway } from '../../hooks/use-focus-away';
1515
import { useKeyPress } from '../../hooks/use-key-press';
1616
import { useSyncedRef } from '../../hooks/use-synced-ref';
1717
import type { PresentationalProps } from '../../types';
18+
import { downcastRef } from '../../util/typing';
1819
import { MenuCollapseIcon, MenuExpandIcon } from '../icons';
19-
import Button from './Button';
2020
import { inputGroupStyles } from './InputGroup';
2121
import SelectContext from './SelectContext';
2222

@@ -149,6 +149,9 @@ export type SelectProps<T> = PresentationalProps & {
149149
*/
150150
buttonId?: string;
151151

152+
'aria-label'?: string;
153+
'aria-labelledby'?: string;
154+
152155
/** @deprecated Use buttonContent instead */
153156
label?: ComponentChildren;
154157
};
@@ -163,6 +166,8 @@ function SelectMain<T>({
163166
elementRef,
164167
classes,
165168
buttonId,
169+
'aria-label': ariaLabel,
170+
'aria-labelledby': ariaLabelledBy,
166171
}: SelectProps<T>) {
167172
const [listboxOpen, setListboxOpen] = useState(false);
168173
const closeListbox = useCallback(() => setListboxOpen(false), []);
@@ -212,11 +217,11 @@ function SelectMain<T>({
212217
className={classnames('relative w-full border rounded', inputGroupStyles)}
213218
ref={wrapperRef}
214219
>
215-
<Button
220+
<button
216221
id={buttonId ?? defaultButtonId}
217-
variant="custom"
218-
classes={classnames(
219-
'w-full flex justify-between',
222+
className={classnames(
223+
'focus-visible-ring transition-colors whitespace-nowrap',
224+
'w-full flex items-center justify-between gap-x-2 p-2',
220225
'bg-grey-0 disabled:bg-grey-1 disabled:text-grey-6',
221226
// Add inherited rounded corners so that the toggle is consistent with
222227
// the wrapper, which is the element rendering borders.
@@ -225,11 +230,15 @@ function SelectMain<T>({
225230
'rounded-[inherit]',
226231
classes,
227232
)}
228-
expanded={listboxOpen}
233+
type="button"
234+
role="combobox"
229235
disabled={disabled}
236+
aria-expanded={listboxOpen}
230237
aria-haspopup="listbox"
231238
aria-controls={listboxId}
232-
elementRef={buttonRef}
239+
aria-label={ariaLabel}
240+
aria-labelledby={ariaLabelledBy}
241+
ref={downcastRef(buttonRef)}
233242
onClick={() => setListboxOpen(prev => !prev)}
234243
onKeyDown={e => {
235244
if (e.key === 'ArrowDown' && !listboxOpen) {
@@ -243,7 +252,7 @@ function SelectMain<T>({
243252
<div className="text-grey-6">
244253
{listboxOpen ? <MenuCollapseIcon /> : <MenuExpandIcon />}
245254
</div>
246-
</Button>
255+
</button>
247256
<SelectContext.Provider value={{ selectValue, value }}>
248257
<ul
249258
className={classnames(

src/components/input/test/SelectNext-test.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -289,15 +289,15 @@ describe('SelectNext', () => {
289289
name: 'Closed Select listbox',
290290
content: () =>
291291
createComponent(
292-
{ buttonContent: 'Select' },
292+
{ buttonContent: 'Select', 'aria-label': 'Select' },
293293
{ optionsChildrenAsCallback: false },
294294
),
295295
},
296296
{
297297
name: 'Open Select listbox',
298298
content: () => {
299299
const wrapper = createComponent(
300-
{ buttonContent: 'Select' },
300+
{ buttonContent: 'Select', 'aria-label': 'Select' },
301301
{ optionsChildrenAsCallback: false },
302302
);
303303
toggleListbox(wrapper);

src/pattern-library/components/Library.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ export type LibraryDemoProps = {
171171
classes?: string | string[];
172172
/** Inline styles to apply to the demo container */
173173
style?: JSX.CSSProperties;
174-
title?: string;
174+
title?: ComponentChildren;
175175
/**
176176
* Should the demo also render the source? When true, a "Source" tab will be
177177
* rendered, which will display the JSX source of the Demo's children.

src/pattern-library/components/patterns/prototype/SelectNextPage.tsx

+154-98
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import classnames from 'classnames';
2-
import { useCallback, useMemo, useState } from 'preact/hooks';
2+
import { useCallback, useId, useMemo, useState } from 'preact/hooks';
33

44
import { ArrowLeftIcon, ArrowRightIcon } from '../../../../components/icons';
5+
import type { SelectNextProps } from '../../../../components/input';
56
import { IconButton, InputGroup } from '../../../../components/input';
67
import SelectNext from '../../../../components/input/SelectNext';
78
import Library from '../../Library';
@@ -23,65 +24,77 @@ const defaultItems: ItemType[] = [
2324
function SelectExample({
2425
disabled,
2526
textOnly,
26-
classes,
2727
items = defaultItems,
28-
}: {
29-
disabled?: boolean;
28+
...rest
29+
}: Pick<
30+
SelectNextProps<ItemType>,
31+
'aria-label' | 'aria-labelledby' | 'classes' | 'disabled'
32+
> & {
3033
textOnly?: boolean;
31-
classes?: string;
32-
items?: typeof defaultItems;
34+
items?: ItemType[];
3335
}) {
3436
const [value, setValue] = useState<ItemType>();
37+
const buttonId = useId();
3538

3639
return (
37-
<SelectNext
38-
value={value}
39-
onChange={setValue}
40-
classes={classes}
41-
disabled={disabled}
42-
buttonContent={
43-
value ? (
44-
<>
45-
{textOnly && value.name}
46-
{!textOnly && (
47-
<div className="flex">
48-
<div className="truncate">{value.name}</div>
49-
<div className="rounded px-2 ml-2 bg-grey-7 text-white">
50-
{value.id}
51-
</div>
52-
</div>
53-
)}
54-
</>
55-
) : disabled ? (
56-
<>This is disabled</>
57-
) : (
58-
<>Select one...</>
59-
)
60-
}
61-
>
62-
{items.map(item => (
63-
<SelectNext.Option value={item} key={item.id} disabled={item.disabled}>
64-
{({ disabled }) =>
65-
textOnly ? (
66-
item.name
67-
) : (
68-
<>
69-
{item.name}
70-
<div className="grow" />
71-
<div
72-
className={classnames('rounded px-2 ml-2 text-white', {
73-
'bg-grey-7': !disabled,
74-
'bg-grey-4': disabled,
75-
})}
76-
>
77-
{item.id}
40+
<>
41+
{!rest['aria-label'] && !rest['aria-labelledby'] && (
42+
<label htmlFor={buttonId}>Select a person</label>
43+
)}
44+
<SelectNext
45+
{...rest}
46+
buttonId={buttonId}
47+
value={value}
48+
onChange={setValue}
49+
disabled={disabled}
50+
buttonContent={
51+
value ? (
52+
<>
53+
{textOnly && value.name}
54+
{!textOnly && (
55+
<div className="flex">
56+
<div className="truncate">{value.name}</div>
57+
<div className="rounded px-2 ml-2 bg-grey-7 text-white">
58+
{value.id}
59+
</div>
7860
</div>
79-
</>
80-
)
81-
}
82-
</SelectNext.Option>
83-
))}
84-
</SelectNext>
61+
)}
62+
</>
63+
) : disabled ? (
64+
<>This is disabled</>
65+
) : (
66+
<>Select one…</>
67+
)
68+
}
69+
>
70+
{items.map(item => (
71+
<SelectNext.Option
72+
value={item}
73+
key={item.id}
74+
disabled={item.disabled}
75+
>
76+
{({ disabled }) =>
77+
textOnly ? (
78+
item.name
79+
) : (
80+
<>
81+
{item.name}
82+
<div className="grow" />
83+
<div
84+
className={classnames('rounded px-2 ml-2 text-white', {
85+
'bg-grey-7': !disabled,
86+
'bg-grey-4': disabled,
87+
})}
88+
>
89+
{item.id}
90+
</div>
91+
</>
92+
)
93+
}
94+
</SelectNext.Option>
95+
))}
96+
</SelectNext>
97+
</>
8598
);
8699
}
87100

@@ -99,53 +112,58 @@ function InputGroupSelectExample({ classes }: { classes?: string }) {
99112
const newIndex = selectedIndex - 1;
100113
setSelected(defaultItems[newIndex] ?? selected);
101114
}, [selected, selectedIndex]);
115+
const buttonId = useId();
102116

103117
return (
104-
<InputGroup>
105-
<IconButton
106-
icon={ArrowLeftIcon}
107-
title="Previous student"
108-
variant="dark"
109-
onClick={previous}
110-
disabled={selectedIndex <= 0}
111-
/>
112-
<SelectNext
113-
value={selected}
114-
onChange={setSelected}
115-
classes={classes}
116-
buttonContent={
117-
selected ? (
118-
<div className="flex">
119-
<div className="truncate">{selected.name}</div>
120-
<div className="rounded px-2 ml-2 bg-grey-7 text-white">
121-
{selected.id}
118+
<>
119+
<label htmlFor={buttonId}>Select a person</label>
120+
<InputGroup>
121+
<IconButton
122+
icon={ArrowLeftIcon}
123+
title="Previous student"
124+
variant="dark"
125+
onClick={previous}
126+
disabled={selectedIndex <= 0}
127+
/>
128+
<SelectNext
129+
buttonId={buttonId}
130+
value={selected}
131+
onChange={setSelected}
132+
classes={classes}
133+
buttonContent={
134+
selected ? (
135+
<div className="flex">
136+
<div className="truncate">{selected.name}</div>
137+
<div className="rounded px-2 ml-2 bg-grey-7 text-white">
138+
{selected.id}
139+
</div>
122140
</div>
123-
</div>
124-
) : (
125-
<>Select one...</>
126-
)
127-
}
128-
>
129-
{defaultItems.map(item => (
130-
<SelectNext.Option value={item} key={item.id}>
131-
{item.name}
132-
<div className="grow" />
133-
<div
134-
className={classnames('rounded px-2 ml-2 text-white bg-grey-7')}
135-
>
136-
{item.id}
137-
</div>
138-
</SelectNext.Option>
139-
))}
140-
</SelectNext>
141-
<IconButton
142-
icon={ArrowRightIcon}
143-
title="Next student"
144-
variant="dark"
145-
onClick={next}
146-
disabled={selectedIndex >= defaultItems.length - 1}
147-
/>
148-
</InputGroup>
141+
) : (
142+
<>Select one…</>
143+
)
144+
}
145+
>
146+
{defaultItems.map(item => (
147+
<SelectNext.Option value={item} key={item.id}>
148+
{item.name}
149+
<div className="grow" />
150+
<div
151+
className={classnames('rounded px-2 ml-2 text-white bg-grey-7')}
152+
>
153+
{item.id}
154+
</div>
155+
</SelectNext.Option>
156+
))}
157+
</SelectNext>
158+
<IconButton
159+
icon={ArrowRightIcon}
160+
title="Next student"
161+
variant="dark"
162+
onClick={next}
163+
disabled={selectedIndex >= defaultItems.length - 1}
164+
/>
165+
</InputGroup>
166+
</>
149167
);
150168
}
151169

@@ -228,6 +246,44 @@ export default function SelectNextPage() {
228246
</Library.Demo>
229247
</Library.Example>
230248

249+
<Library.Example title="Labeling SelectNext">
250+
<p>
251+
There are three ways to label a <code>SelectNext</code>. Make sure
252+
you always use one of them.
253+
</p>
254+
255+
<Library.Demo
256+
title={
257+
<>
258+
Via{' '}
259+
<code>
260+
{'<'}label {'/>'}
261+
</code>{' '}
262+
linked to <code>buttonId</code>
263+
</>
264+
}
265+
>
266+
<div className="w-96 mx-auto">
267+
<SelectExample />
268+
</div>
269+
</Library.Demo>
270+
271+
<Library.Demo title="Via aria-label">
272+
<div className="w-96 mx-auto">
273+
<SelectExample aria-label="Select a person with aria label" />
274+
</div>
275+
</Library.Demo>
276+
277+
<Library.Demo title="Via aria-labelledby">
278+
<div className="w-96 mx-auto">
279+
<p id="select-next-meta-label">
280+
Select a person with aria labelledby
281+
</p>
282+
<SelectExample aria-labelledby="select-next-meta-label" />
283+
</div>
284+
</Library.Demo>
285+
</Library.Example>
286+
231287
<Library.Example title="Select with long content">
232288
<p>
233289
<code>SelectNext</code> makes sure the button content never
@@ -403,7 +459,7 @@ export default function SelectNextPage() {
403459
</div>
404460
</>
405461
) : (
406-
<>Select one...</>
462+
<>Select one</>
407463
)
408464
}
409465
>

0 commit comments

Comments
 (0)