Skip to content

Commit 0a25aa9

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. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0127ZbvzW5a888TLW3jTJXWJ
1 parent fe124ee commit 0a25aa9

3 files changed

Lines changed: 30 additions & 5 deletions

File tree

src/components/Select/Select.test.tsx

Lines changed: 5 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,16 @@ 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');
15401542
});
15411543
});

src/components/Select/Select.tsx

Lines changed: 23 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,
@@ -342,6 +343,8 @@ export const Select: FC<SelectProps> = React.forwardRef(
342343

343344
// Derive the live region message, memoized to prevent spurious
344345
const lastLiveRegionMessageRef = useRef<string>('');
346+
// Mode: 'list' (open/filter) includes the count; 'nav' (arrows) omits it.
347+
const announceModeRef = useRef<'list' | 'nav'>('list');
345348
const liveRegionMessage = useMemo<string>(() => {
346349
if (!dropdownVisible) {
347350
return lastLiveRegionMessageRef.current;
@@ -382,9 +385,13 @@ export const Select: FC<SelectProps> = React.forwardRef(
382385
if (activeOption) {
383386
const positionSeparator: string =
384387
selectLocale?.lang?.optionPositionSeparatorText ?? 'of';
385-
message = `${activeOption.text}, ${
388+
const activeText: string = `${activeOption.text}, ${
386389
activeIndex + 1
387390
} ${positionSeparator} ${visibleOptionsCount}`;
391+
message =
392+
announceModeRef.current === 'nav'
393+
? activeText
394+
: `${message} ${activeText}`;
388395
}
389396
}
390397

@@ -399,6 +406,18 @@ export const Select: FC<SelectProps> = React.forwardRef(
399406
activeDescendantId,
400407
]);
401408

409+
// Debounce open/filter so keystroke updates coalesce; arrow nav is immediate.
410+
const [announcedMessage, setAnnouncedMessage] = useState<string>('');
411+
useEffect(() => {
412+
const delay: number =
413+
announceModeRef.current === 'nav' ? 0 : ANNOUNCE_DEBOUNCE_DURATION;
414+
const timer: ReturnType<typeof setTimeout> = setTimeout(
415+
() => setAnnouncedMessage(liveRegionMessage),
416+
delay
417+
);
418+
return () => clearTimeout(timer);
419+
}, [liveRegionMessage]);
420+
402421
const toggleOption = (option: SelectOption): void => {
403422
setSearchQuery('');
404423

@@ -934,6 +953,7 @@ export const Select: FC<SelectProps> = React.forwardRef(
934953
if (!nextId) {
935954
return;
936955
}
956+
announceModeRef.current = 'nav';
937957
setActiveDescendantId(nextId);
938958
if (canUseDocElement()) {
939959
requestAnimationFrame(() => {
@@ -1149,6 +1169,7 @@ export const Select: FC<SelectProps> = React.forwardRef(
11491169
if (!improvedA11y) {
11501170
return;
11511171
}
1172+
announceModeRef.current = 'list';
11521173
if (!dropdownVisible) {
11531174
setActiveDescendantId(null);
11541175
return;
@@ -1192,7 +1213,7 @@ export const Select: FC<SelectProps> = React.forwardRef(
11921213
className={styles.selectLiveRegion}
11931214
role="status"
11941215
>
1195-
{liveRegionMessage}
1216+
{improvedA11y ? announcedMessage : liveRegionMessage}
11961217
</div>
11971218
{/* When Dropdown is hidden, place Pills outside the reference element */}
11981219
{!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)