Skip to content

Commit 2f4834e

Browse files
authored
refactor(experience): improve a11y and keyboard control on the select field (#7693)
1 parent 7ce938e commit 2f4834e

File tree

6 files changed

+322
-29
lines changed

6 files changed

+322
-29
lines changed

packages/experience/src/components/Dropdown/DropdownItem.module.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
align-items: center;
1010
overflow: hidden;
1111
gap: _.unit(4);
12+
border: 1px solid transparent;
1213

1314
&:hover {
1415
background: var(--color-overlay-neutral-hover);
@@ -18,6 +19,11 @@
1819
color: var(--color-overlay-danger-hover);
1920
}
2021

22+
&:focus-visible {
23+
border: solid 1px var(--color-brand-default);
24+
outline: none;
25+
}
26+
2127
.icon {
2228
display: flex;
2329
align-items: center;
Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,59 @@
11
import classNames from 'classnames';
2-
import type { MouseEvent, KeyboardEvent, ReactNode } from 'react';
2+
import type { MouseEvent, KeyboardEvent, ReactNode, Ref } from 'react';
3+
import { forwardRef } from 'react';
34

45
import { onKeyDownHandler } from '@/utils/a11y';
56

67
import styles from './DropdownItem.module.scss';
78

89
export type Props = {
9-
readonly onClick?: (event: MouseEvent<HTMLDivElement> | KeyboardEvent<HTMLDivElement>) => void;
1010
readonly className?: string;
1111
readonly children: ReactNode;
1212
readonly icon?: ReactNode;
1313
readonly iconClassName?: string;
1414
readonly type?: 'default' | 'danger';
15+
readonly onClick?: (event: MouseEvent<HTMLDivElement> | KeyboardEvent<HTMLDivElement>) => void;
16+
readonly onArrowNavigate?: (direction: 1 | -1) => void;
1517
};
1618

17-
const DropdownItem = ({
18-
onClick,
19-
className,
20-
children,
21-
icon,
22-
iconClassName,
23-
type = 'default',
24-
}: Props) => {
25-
return (
26-
<div
27-
className={classNames(styles.item, styles[type], className)}
28-
role="menuitem"
29-
tabIndex={0}
30-
onMouseDown={(event) => {
19+
const DropdownItem = (
20+
{
21+
className = '',
22+
children,
23+
icon,
24+
iconClassName = '',
25+
type = 'default',
26+
onClick,
27+
onArrowNavigate,
28+
}: Props,
29+
ref: Ref<HTMLDivElement>
30+
) => (
31+
<div
32+
ref={ref}
33+
className={classNames(styles.item, styles[type], className)}
34+
role="menuitem"
35+
tabIndex={0}
36+
onMouseDown={(event) => {
37+
event.preventDefault();
38+
}}
39+
onKeyDown={(event) => {
40+
if (event.key === 'ArrowDown') {
41+
onArrowNavigate?.(1);
3142
event.preventDefault();
32-
}}
33-
onKeyDown={onKeyDownHandler(onClick)}
34-
onClick={onClick}
35-
>
36-
{icon && <span className={classNames(styles.icon, iconClassName)}>{icon}</span>}
37-
{children}
38-
</div>
39-
);
40-
};
43+
return;
44+
}
45+
if (event.key === 'ArrowUp') {
46+
onArrowNavigate?.(-1);
47+
event.preventDefault();
48+
return;
49+
}
50+
onKeyDownHandler(onClick)(event);
51+
}}
52+
onClick={onClick}
53+
>
54+
{icon && <span className={classNames(styles.icon, iconClassName)}>{icon}</span>}
55+
{children}
56+
</div>
57+
);
4158

42-
export default DropdownItem;
59+
export default forwardRef<HTMLDivElement, Props>(DropdownItem);

packages/experience/src/components/InputFields/SelectField/index.module.scss

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@
2929
color: var(--color-type-secondary);
3030
transition: transform 0.2s ease-in-out;
3131

32+
&:focus-visible {
33+
border-radius: 6px;
34+
outline: solid 1px var(--color-brand-default);
35+
}
36+
3237
&.up {
3338
transform: translateY(-50%) rotate(180deg);
3439
}
@@ -50,3 +55,7 @@
5055
color: var(--color-danger-default);
5156
}
5257
}
58+
59+
.focused {
60+
background: var(--color-overlay-neutral-hover);
61+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { render, fireEvent, act, waitFor } from '@testing-library/react';
2+
import { useState } from 'react';
3+
4+
import SelectField from '.';
5+
6+
jest.mock('react-i18next', () => ({
7+
useTranslation: () => ({
8+
t: (key: string) => key,
9+
i18n: { dir: () => 'ltr' },
10+
}),
11+
}));
12+
13+
const options = [
14+
{ value: 'a', label: 'Apple' },
15+
{ value: 'b', label: 'Banana' },
16+
{ value: 'c', label: 'Cherry' },
17+
];
18+
19+
// Controlled wrapper
20+
const Controlled = (
21+
props: Readonly<
22+
Omit<React.ComponentProps<typeof SelectField>, 'onChange'> & {
23+
onChange?: (value: string) => void;
24+
}
25+
>
26+
) => {
27+
const [value, setValue] = useState(props.value ?? '');
28+
return (
29+
<SelectField
30+
{...props}
31+
value={value}
32+
onChange={(value) => {
33+
setValue(value);
34+
props.onChange?.(value);
35+
}}
36+
/>
37+
);
38+
};
39+
40+
const queryOption = (label: string) =>
41+
Array.from(document.querySelectorAll('[role="menuitem"], li, div')).find(
42+
(element) => element.textContent === label
43+
);
44+
45+
describe('SelectField', () => {
46+
test('click open and select by mouse', () => {
47+
const handleChange = jest.fn();
48+
const { container } = render(
49+
<Controlled label="Favorite" name="favorite" options={options} onChange={handleChange} />
50+
);
51+
const input = container.querySelector('input[name="favorite"]')!;
52+
act(() => {
53+
fireEvent.click(input);
54+
});
55+
const banana = queryOption('Banana');
56+
expect(banana).toBeTruthy();
57+
act(() => {
58+
fireEvent.click(banana!);
59+
});
60+
expect(handleChange).toHaveBeenCalledWith('b');
61+
expect((input as HTMLInputElement).value).toBe('Banana');
62+
});
63+
64+
test('ArrowDown first focuses first item then Enter selects', () => {
65+
const handleChange = jest.fn();
66+
const { container } = render(
67+
<Controlled label="Favorite" name="favorite" options={options} onChange={handleChange} />
68+
);
69+
const input = container.querySelector('input[name="favorite"]')!;
70+
act(() => {
71+
fireEvent.keyDown(input, { key: 'ArrowDown' });
72+
});
73+
const first = queryOption('Apple');
74+
expect(first).toBeTruthy();
75+
act(() => {
76+
fireEvent.keyDown(input, { key: 'Enter' });
77+
});
78+
expect(handleChange).toHaveBeenLastCalledWith('a');
79+
expect((input as HTMLInputElement).value).toBe('Apple');
80+
});
81+
82+
test('ArrowUp first focuses last item then Space selects', () => {
83+
const handleChange = jest.fn();
84+
const { container } = render(
85+
<Controlled label="Favorite" name="favorite" options={options} onChange={handleChange} />
86+
);
87+
const input = container.querySelector('input[name="favorite"]')!;
88+
act(() => {
89+
fireEvent.keyDown(input, { key: 'ArrowUp' });
90+
});
91+
const last = queryOption('Cherry');
92+
expect(last).toBeTruthy();
93+
act(() => {
94+
fireEvent.keyDown(input, { key: ' ' });
95+
});
96+
expect(handleChange).toHaveBeenLastCalledWith('c');
97+
expect((input as HTMLInputElement).value).toBe('Cherry');
98+
});
99+
100+
test('wrap-around ArrowDown navigation', async () => {
101+
const handleChange = jest.fn();
102+
const { container } = render(
103+
<Controlled label="Favorite" name="favorite" options={options} onChange={handleChange} />
104+
);
105+
const input = container.querySelector('input[name="favorite"]')!;
106+
act(() => {
107+
fireEvent.keyDown(input, { key: 'ArrowDown' });
108+
}); // Apple
109+
act(() => {
110+
fireEvent.keyDown(input, { key: 'ArrowDown' });
111+
}); // Banana
112+
act(() => {
113+
fireEvent.keyDown(input, { key: 'ArrowDown' });
114+
}); // Cherry
115+
act(() => {
116+
fireEvent.keyDown(input, { key: 'ArrowDown' });
117+
}); // Wrap to Apple
118+
act(() => {
119+
fireEvent.keyDown(input, { key: 'Enter' });
120+
}); // Select Apple
121+
await waitFor(() => {
122+
expect(handleChange).toHaveBeenLastCalledWith('a');
123+
});
124+
// Reopen and ArrowUp wrap from first to last
125+
act(() => {
126+
fireEvent.keyDown(input, { key: 'ArrowDown' });
127+
}); // Reopen -> Apple
128+
act(() => {
129+
fireEvent.keyDown(input, { key: 'ArrowUp' });
130+
}); // Wrap -> Cherry
131+
act(() => {
132+
fireEvent.keyDown(input, { key: 'Enter' });
133+
});
134+
await waitFor(() => {
135+
expect(handleChange).toHaveBeenLastCalledWith('c');
136+
});
137+
});
138+
139+
test('Escape closes without selection', async () => {
140+
const handleChange = jest.fn();
141+
const { container } = render(
142+
<Controlled label="Favorite" name="favorite" options={options} onChange={handleChange} />
143+
);
144+
const input = container.querySelector('input[name="favorite"]')!;
145+
act(() => {
146+
fireEvent.keyDown(input, { key: 'ArrowDown' });
147+
fireEvent.keyDown(input, { key: 'Escape' });
148+
});
149+
expect(handleChange).not.toHaveBeenCalled();
150+
// Ensure still functional after close
151+
act(() => {
152+
fireEvent.keyDown(input, { key: 'ArrowDown' });
153+
});
154+
act(() => {
155+
fireEvent.keyDown(input, { key: 'Enter' });
156+
});
157+
await waitFor(() => {
158+
expect(handleChange).toHaveBeenLastCalledWith('a');
159+
});
160+
});
161+
});

0 commit comments

Comments
 (0)