Skip to content

Commit 0150356

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 and its group label 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 typing echo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0127ZbvzW5a888TLW3jTJXWJ
1 parent fe124ee commit 0150356

3 files changed

Lines changed: 85 additions & 9 deletions

File tree

src/components/Select/Select.test.tsx

Lines changed: 30 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,41 @@ 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('includes the option group in the improvedA11y announcement', 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+
await waitFor(() => {
1560+
expect(getByRole('status')).toHaveTextContent('Apple, With HRIS');
1561+
});
1562+
1563+
fireEvent.keyDown(input, { key: 'ArrowDown' });
1564+
await waitFor(() => {
1565+
expect(getByRole('status')).toHaveTextContent('Banana, Others');
1566+
});
15401567
});
15411568
});

src/components/Select/Select.tsx

Lines changed: 53 additions & 6 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;
@@ -368,8 +391,8 @@ export const Select: FC<SelectProps> = React.forwardRef(
368391
message = searchQuery ? `${noResultsText} ${searchQuery}` : '';
369392
}
370393

371-
// VoiceOver ignores aria-activedescendant, so announce the active option
372-
// here. Tradeoff: NVDA hears it twice (native + this echo).
394+
// VoiceOver ignores aria-activedescendant, so echo the active option here.
395+
// Tradeoff: NVDA hears it twice (native + this echo).
373396
if (improvedA11y && activeDescendantId) {
374397
const visibleOptions: SelectOption[] = (options || []).filter(
375398
(opt: SelectOption) =>
@@ -382,9 +405,18 @@ export const Select: FC<SelectProps> = React.forwardRef(
382405
if (activeOption) {
383406
const positionSeparator: string =
384407
selectLocale?.lang?.optionPositionSeparatorText ?? 'of';
385-
message = `${activeOption.text}, ${
386-
activeIndex + 1
387-
} ${positionSeparator} ${visibleOptionsCount}`;
408+
const group: string = optionGroupById[activeDescendantId] ?? '';
409+
const activeText: string = [
410+
activeOption.text,
411+
group,
412+
`${activeIndex + 1} ${positionSeparator} ${visibleOptionsCount}`,
413+
]
414+
.filter(Boolean)
415+
.join(', ');
416+
message =
417+
announceModeRef.current === 'nav'
418+
? activeText
419+
: `${message} ${activeText}`;
388420
}
389421
}
390422

@@ -399,6 +431,19 @@ export const Select: FC<SelectProps> = React.forwardRef(
399431
activeDescendantId,
400432
]);
401433

434+
// Announce arrow nav immediately, but debounce open/filter so per-keystroke
435+
// updates coalesce and don't race the input's own typing echo.
436+
const [announcedMessage, setAnnouncedMessage] = useState<string>('');
437+
useEffect(() => {
438+
const delay: number =
439+
announceModeRef.current === 'nav' ? 0 : ANNOUNCE_DEBOUNCE_DURATION;
440+
const timer: ReturnType<typeof setTimeout> = setTimeout(
441+
() => setAnnouncedMessage(liveRegionMessage),
442+
delay
443+
);
444+
return () => clearTimeout(timer);
445+
}, [liveRegionMessage]);
446+
402447
const toggleOption = (option: SelectOption): void => {
403448
setSearchQuery('');
404449

@@ -934,6 +979,7 @@ export const Select: FC<SelectProps> = React.forwardRef(
934979
if (!nextId) {
935980
return;
936981
}
982+
announceModeRef.current = 'nav';
937983
setActiveDescendantId(nextId);
938984
if (canUseDocElement()) {
939985
requestAnimationFrame(() => {
@@ -1149,6 +1195,7 @@ export const Select: FC<SelectProps> = React.forwardRef(
11491195
if (!improvedA11y) {
11501196
return;
11511197
}
1198+
announceModeRef.current = 'list';
11521199
if (!dropdownVisible) {
11531200
setActiveDescendantId(null);
11541201
return;
@@ -1192,7 +1239,7 @@ export const Select: FC<SelectProps> = React.forwardRef(
11921239
className={styles.selectLiveRegion}
11931240
role="status"
11941241
>
1195-
{liveRegionMessage}
1242+
{improvedA11y ? announcedMessage : liveRegionMessage}
11961243
</div>
11971244
{/* When Dropdown is hidden, place Pills outside the reference element */}
11981245
{!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)