Skip to content

Commit a6a173f

Browse files
fix(Select): announce result count with the active option for improvedA11y combobox
The improvedA11y combobox echoed the active option into the result-count live region and overwrote the count, so screen readers stopped hearing it. Announce the count together with the highlighted option in one polite region (option only on arrow navigation), debounced on open/filter so per-keystroke updates coalesce and do not race the combobox input's own typing echo. On open/filter the seeded option's group label is included too, since VoiceOver does not read it natively there; during arrow navigation the group is left to the native reading. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0127ZbvzW5a888TLW3jTJXWJ
1 parent fe124ee commit a6a173f

3 files changed

Lines changed: 89 additions & 5 deletions

File tree

src/components/Select/Select.test.tsx

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1514,7 +1514,7 @@ describe('Select improvedA11y', () => {
15141514
});
15151515
});
15161516

1517-
test('announces the highlighted option in the live region as focus moves', async () => {
1517+
test('announces the result count with the highlighted option, and only the option on arrow keys', async () => {
15181518
const { getByPlaceholderText, getByRole, getAllByRole } = render(
15191519
<Select
15201520
improvedA11y
@@ -1528,14 +1528,44 @@ describe('Select improvedA11y', () => {
15281528
fireEvent.click(input);
15291529
await waitFor(() => getAllByRole('option'));
15301530

1531-
// Opening highlights the first option: announce its name and position.
15321531
await waitFor(() => {
1533-
expect(getByRole('status')).toHaveTextContent('Apple, 1 of 3');
1532+
const status: string = getByRole('status').textContent;
1533+
expect(status).toContain('matches found');
1534+
expect(status).toContain('Apple, 1 of 3');
15341535
});
15351536

15361537
fireEvent.keyDown(input, { key: 'ArrowDown' });
15371538
await waitFor(() => {
15381539
expect(getByRole('status')).toHaveTextContent('Banana, 2 of 3');
15391540
});
1541+
expect(getByRole('status').textContent).not.toContain('matches found');
1542+
});
1543+
1544+
test('announces the seeded option group on open, but not while arrowing', async () => {
1545+
const grouped = [
1546+
{ text: 'With HRIS', value: 'hris', type: MenuItemType.subHeader },
1547+
{ text: 'Apple', value: 'apple' },
1548+
{ text: 'Others', value: 'others', type: MenuItemType.subHeader },
1549+
{ text: 'Banana', value: 'banana' },
1550+
];
1551+
const { getByPlaceholderText, getByRole, getAllByRole } = render(
1552+
<Select improvedA11y filterable options={grouped} placeholder="Fruit" />
1553+
);
1554+
const input = getByPlaceholderText('Fruit') as HTMLInputElement;
1555+
input.focus();
1556+
fireEvent.click(input);
1557+
await waitFor(() => getAllByRole('option'));
1558+
1559+
// Open: the seeded option carries its group (VoiceOver won't read it natively).
1560+
await waitFor(() => {
1561+
expect(getByRole('status')).toHaveTextContent('Apple, With HRIS');
1562+
});
1563+
1564+
// Arrow: group is left to VoiceOver's native reading, so the echo omits it.
1565+
fireEvent.keyDown(input, { key: 'ArrowDown' });
1566+
await waitFor(() => {
1567+
expect(getByRole('status')).toHaveTextContent('Banana, 2 of 2');
1568+
});
1569+
expect(getByRole('status').textContent).not.toContain('Others');
15401570
});
15411571
});

src/components/Select/Select.tsx

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
import { Pill, PillSize, PillType } from '../Pills';
3535
import { IconName } from '../Icon';
3636
import {
37+
ANNOUNCE_DEBOUNCE_DURATION,
3738
SelectOption,
3839
SelectProps,
3940
SelectShape,
@@ -340,8 +341,30 @@ export const Select: FC<SelectProps> = React.forwardRef(
340341
}
341342
}, [filterable, initialFocus]);
342343

344+
// Option id -> its group label (nearest preceding sub header).
345+
const optionGroupById = useMemo<Record<string, string>>(() => {
346+
if (!improvedA11y) {
347+
return {};
348+
}
349+
const groups: Record<string, string> = {};
350+
let group = '';
351+
for (const opt of options || []) {
352+
if (opt.hideOption) {
353+
continue;
354+
}
355+
if (opt.type === MenuItemType.subHeader) {
356+
group = opt.text ?? '';
357+
} else if (opt.id) {
358+
groups[opt.id] = group;
359+
}
360+
}
361+
return groups;
362+
}, [options, improvedA11y]);
363+
343364
// Derive the live region message, memoized to prevent spurious
344365
const lastLiveRegionMessageRef = useRef<string>('');
366+
// Mode: 'list' (open/filter) includes the count; 'nav' (arrows) omits it.
367+
const announceModeRef = useRef<'list' | 'nav'>('list');
345368
const liveRegionMessage = useMemo<string>(() => {
346369
if (!dropdownVisible) {
347370
return lastLiveRegionMessageRef.current;
@@ -382,9 +405,24 @@ export const Select: FC<SelectProps> = React.forwardRef(
382405
if (activeOption) {
383406
const positionSeparator: string =
384407
selectLocale?.lang?.optionPositionSeparatorText ?? 'of';
385-
message = `${activeOption.text}, ${
408+
const position: string = `${
386409
activeIndex + 1
387410
} ${positionSeparator} ${visibleOptionsCount}`;
411+
// Group only on open/filter; the seeded option isn't announced natively.
412+
const activeText: string =
413+
announceModeRef.current === 'nav'
414+
? `${activeOption.text}, ${position}`
415+
: [
416+
activeOption.text,
417+
optionGroupById[activeDescendantId] ?? '',
418+
position,
419+
]
420+
.filter(Boolean)
421+
.join(', ');
422+
message =
423+
announceModeRef.current === 'nav'
424+
? activeText
425+
: `${message} ${activeText}`;
388426
}
389427
}
390428

@@ -399,6 +437,18 @@ export const Select: FC<SelectProps> = React.forwardRef(
399437
activeDescendantId,
400438
]);
401439

440+
// Debounce open/filter so keystroke updates coalesce; arrow nav is immediate.
441+
const [announcedMessage, setAnnouncedMessage] = useState<string>('');
442+
useEffect(() => {
443+
const delay: number =
444+
announceModeRef.current === 'nav' ? 0 : ANNOUNCE_DEBOUNCE_DURATION;
445+
const timer: ReturnType<typeof setTimeout> = setTimeout(
446+
() => setAnnouncedMessage(liveRegionMessage),
447+
delay
448+
);
449+
return () => clearTimeout(timer);
450+
}, [liveRegionMessage]);
451+
402452
const toggleOption = (option: SelectOption): void => {
403453
setSearchQuery('');
404454

@@ -934,6 +984,7 @@ export const Select: FC<SelectProps> = React.forwardRef(
934984
if (!nextId) {
935985
return;
936986
}
987+
announceModeRef.current = 'nav';
937988
setActiveDescendantId(nextId);
938989
if (canUseDocElement()) {
939990
requestAnimationFrame(() => {
@@ -1149,6 +1200,7 @@ export const Select: FC<SelectProps> = React.forwardRef(
11491200
if (!improvedA11y) {
11501201
return;
11511202
}
1203+
announceModeRef.current = 'list';
11521204
if (!dropdownVisible) {
11531205
setActiveDescendantId(null);
11541206
return;
@@ -1192,7 +1244,7 @@ export const Select: FC<SelectProps> = React.forwardRef(
11921244
className={styles.selectLiveRegion}
11931245
role="status"
11941246
>
1195-
{liveRegionMessage}
1247+
{improvedA11y ? announcedMessage : liveRegionMessage}
11961248
</div>
11971249
{/* When Dropdown is hidden, place Pills outside the reference element */}
11981250
{!dropdownVisible && showPills() ? getPills() : null}

src/components/Select/Select.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { PillProps } from '../Pills';
1313
import { MenuItemButtonProps } from '../Menu/MenuItem/MenuItem.types';
1414
import { InputStatus } from '../../shared/utilities';
1515

16+
export const ANNOUNCE_DEBOUNCE_DURATION: number = 400;
17+
1618
type SelectLang = {
1719
/**
1820
* The Select locale.

0 commit comments

Comments
 (0)