Skip to content

Commit 8376d53

Browse files
committed
fix(emojis): improve picker keyboard accessibility
- Skin-tone selector: expose it as a proper WAI-ARIA radiogroup — focus moves onto the checked tone when it opens, a roving tabindex keeps one tone tab-reachable, arrow/Home/End keys move the selection (selection follows focus), and Escape collapses back to the toggle without closing the picker. - Mark the emoji toggle button with aria-haspopup="dialog".
1 parent 98ba7ff commit 8376d53

4 files changed

Lines changed: 139 additions & 6 deletions

File tree

src/plugins/Emojis/EmojiPicker.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ export const EmojiPicker = (props: EmojiPickerProps) => {
184184
<Button
185185
appearance='ghost'
186186
aria-expanded={displayPicker}
187+
aria-haspopup='dialog'
187188
aria-label={t('aria/Emoji picker')}
188189
circular
189190
className={props.buttonClassName ?? defaultButtonClassName}

src/plugins/Emojis/__tests__/EmojiPicker.test.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { MouseEventHandler, ReactNode } from 'react';
1+
import type { AriaAttributes, MouseEventHandler, ReactNode } from 'react';
22
import { fireEvent, render, screen } from '@testing-library/react';
33

44
// The panel is mounted only while the picker is open, so mock it to a controllable
@@ -51,6 +51,7 @@ vi.mock('../../../components', async () => {
5151
// Forward only the DOM-valid props the test needs; styling props are dropped.
5252
return (
5353
<button
54+
aria-haspopup={props['aria-haspopup'] as AriaAttributes['aria-haspopup']}
5455
aria-label={props['aria-label'] as string | undefined}
5556
onClick={props.onClick as MouseEventHandler<HTMLButtonElement> | undefined}
5657
ref={ref}
@@ -121,6 +122,14 @@ describe('EmojiPicker session state', () => {
121122
fireEvent.click(screen.getByText('select-rocket'));
122123
expect(screen.getByTestId('frequently-used').textContent).toBe('');
123124
});
125+
126+
it('marks the toggle button as opening a dialog popup', () => {
127+
render(<EmojiPicker />);
128+
expect(screen.getByLabelText('aria/Emoji picker')).toHaveAttribute(
129+
'aria-haspopup',
130+
'dialog',
131+
);
132+
});
124133
});
125134

126135
describe('EmojiPicker pickerProps', () => {

src/plugins/Emojis/components/SkinToneSelector.tsx

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,96 @@
1-
import { useState } from 'react';
1+
import { type KeyboardEvent, useEffect, useRef, useState } from 'react';
22
import clsx from 'clsx';
3-
import { SKIN_TONES } from './skinTones';
3+
import { MAX_SKIN_TONE_INDEX, SKIN_TONES } from './skinTones';
44
import { useTranslationContext } from '../../../context';
55

66
export type SkinToneSelectorProps = {
77
onSelect: (skinToneIndex: number) => void;
88
skinToneIndex: number;
99
};
1010

11+
const ARROW_KEYS = ['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Home', 'End'];
12+
1113
/**
12-
* Skin-tone picker rendered in the footer. Collapsed to the active tone; expands to
13-
* a radiogroup of all tones on activation.
14+
* Skin-tone picker rendered in the footer. Collapsed to the active tone; expands to a
15+
* WAI-ARIA radiogroup: focus moves onto the checked tone on open, a roving tabindex keeps
16+
* one tone tab-reachable, arrow/Home/End keys move the selection (selection follows
17+
* focus), and Escape collapses back to the toggle without closing the whole picker.
1418
*/
1519
export const SkinToneSelector = ({ onSelect, skinToneIndex }: SkinToneSelectorProps) => {
1620
const { t } = useTranslationContext('EmojiPickerSkinTone');
1721
const [expanded, setExpanded] = useState(false);
22+
const toggleRef = useRef<HTMLButtonElement>(null);
23+
const groupRef = useRef<HTMLDivElement>(null);
24+
const returnFocusToToggle = useRef(false);
1825
const activeTone = SKIN_TONES[skinToneIndex] ?? SKIN_TONES[0];
1926

27+
const radios = () =>
28+
Array.from(
29+
groupRef.current?.querySelectorAll<HTMLButtonElement>('[role="radio"]') ?? [],
30+
);
31+
32+
useEffect(() => {
33+
if (expanded) {
34+
// Move focus into the group, onto the checked tone, when it opens.
35+
radios()[skinToneIndex]?.focus();
36+
} else if (returnFocusToToggle.current) {
37+
returnFocusToToggle.current = false;
38+
toggleRef.current?.focus();
39+
}
40+
// Focus is managed on open/close only; arrow navigation focuses the target directly.
41+
// eslint-disable-next-line react-hooks/exhaustive-deps
42+
}, [expanded]);
43+
2044
if (!expanded) {
2145
return (
2246
<button
2347
aria-label={t('aria/Choose default skin tone')}
2448
className='str-chat__emoji-picker__skin-tone-toggle'
2549
onClick={() => setExpanded(true)}
50+
ref={toggleRef}
2651
type='button'
2752
>
2853
<span aria-hidden='true'>{activeTone.glyph}</span>
2954
</button>
3055
);
3156
}
3257

58+
const collapse = (returnFocus: boolean) => {
59+
returnFocusToToggle.current = returnFocus;
60+
setExpanded(false);
61+
};
62+
63+
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
64+
if (event.key === 'Escape') {
65+
event.stopPropagation(); // collapse the group, not the whole picker
66+
collapse(true);
67+
return;
68+
}
69+
if (!ARROW_KEYS.includes(event.key)) return;
70+
event.preventDefault();
71+
72+
let next = skinToneIndex;
73+
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
74+
next = skinToneIndex >= MAX_SKIN_TONE_INDEX ? 0 : skinToneIndex + 1;
75+
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
76+
next = skinToneIndex <= 0 ? MAX_SKIN_TONE_INDEX : skinToneIndex - 1;
77+
} else if (event.key === 'Home') {
78+
next = 0;
79+
} else if (event.key === 'End') {
80+
next = MAX_SKIN_TONE_INDEX;
81+
}
82+
83+
// All tones are mounted, so focus the target now; selection follows focus.
84+
radios()[next]?.focus();
85+
onSelect(next);
86+
};
87+
3388
return (
3489
<div
3590
aria-label={t('aria/Choose default skin tone')}
3691
className='str-chat__emoji-picker__skin-tones'
92+
onKeyDown={onKeyDown}
93+
ref={groupRef}
3794
role='radiogroup'
3895
>
3996
{SKIN_TONES.map((tone, index) => (
@@ -46,9 +103,10 @@ export const SkinToneSelector = ({ onSelect, skinToneIndex }: SkinToneSelectorPr
46103
key={tone.labelKey}
47104
onClick={() => {
48105
onSelect(index);
49-
setExpanded(false);
106+
collapse(true);
50107
}}
51108
role='radio'
109+
tabIndex={index === skinToneIndex ? 0 : -1}
52110
type='button'
53111
>
54112
<span aria-hidden='true'>{tone.glyph}</span>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { fireEvent, render, screen } from '@testing-library/react';
2+
3+
vi.mock('../../../../context', () => ({
4+
useTranslationContext: () => ({ t: (key: string) => key }),
5+
}));
6+
7+
import { SkinToneSelector } from '../SkinToneSelector';
8+
9+
const openGroup = () =>
10+
fireEvent.click(screen.getByRole('button', { name: 'aria/Choose default skin tone' }));
11+
12+
describe('SkinToneSelector radiogroup keyboard navigation', () => {
13+
it('moves focus to the checked tone when the group opens, with roving tabindex', () => {
14+
render(<SkinToneSelector onSelect={vi.fn()} skinToneIndex={2} />);
15+
openGroup();
16+
17+
const radios = screen.getAllByRole('radio');
18+
expect(radios[2]).toHaveFocus();
19+
expect(radios[2]).toHaveAttribute('tabindex', '0');
20+
expect(radios[0]).toHaveAttribute('tabindex', '-1');
21+
});
22+
23+
it('selects the next/previous tone with arrow keys (selection follows focus)', () => {
24+
const onSelect = vi.fn();
25+
render(<SkinToneSelector onSelect={onSelect} skinToneIndex={0} />);
26+
openGroup();
27+
28+
const group = screen.getByRole('radiogroup');
29+
fireEvent.keyDown(group, { key: 'ArrowRight' });
30+
expect(onSelect).toHaveBeenLastCalledWith(1);
31+
fireEvent.keyDown(group, { key: 'ArrowLeft' });
32+
expect(onSelect).toHaveBeenLastCalledWith(5); // wraps from 0 to the last tone
33+
});
34+
35+
it('jumps to the first/last tone with Home/End', () => {
36+
const onSelect = vi.fn();
37+
render(<SkinToneSelector onSelect={onSelect} skinToneIndex={2} />);
38+
openGroup();
39+
40+
const group = screen.getByRole('radiogroup');
41+
fireEvent.keyDown(group, { key: 'End' });
42+
expect(onSelect).toHaveBeenLastCalledWith(5);
43+
fireEvent.keyDown(group, { key: 'Home' });
44+
expect(onSelect).toHaveBeenLastCalledWith(0);
45+
});
46+
47+
it('collapses on Escape without bubbling to close the whole picker', () => {
48+
const onOuterKeyDown = vi.fn();
49+
render(
50+
<div onKeyDown={onOuterKeyDown}>
51+
<SkinToneSelector onSelect={vi.fn()} skinToneIndex={0} />
52+
</div>,
53+
);
54+
openGroup();
55+
56+
fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'Escape' });
57+
58+
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument();
59+
expect(
60+
screen.getByRole('button', { name: 'aria/Choose default skin tone' }),
61+
).toBeInTheDocument();
62+
// Escape stayed inside the selector — it must not reach the picker's Escape handler.
63+
expect(onOuterKeyDown).not.toHaveBeenCalled();
64+
});
65+
});

0 commit comments

Comments
 (0)