Skip to content

Commit 99b79ef

Browse files
committed
fix(a11y): announce file selection, page changes, and multiselect count via live region (forms-17, navigation-10, comboboxes-7)
1 parent ae2d146 commit 99b79ef

7 files changed

Lines changed: 266 additions & 10 deletions

File tree

.changeset/announce-wiring.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@astryxdesign/core': patch
3+
---
4+
5+
[fix] Announce file selection, page changes, and multi-select count via the live-region hook (#3343)
6+
7+
FileInput now announces successful file selection, Pagination announces page changes, and MultiSelector announces selection-count changes, all through the shared visually-hidden polite live region so these previously-silent surfaces are audible to screen-reader users.
8+
@cixzhang

packages/core/src/FileInput/FileInput.test.tsx

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,27 @@
99
* SYNC: When FileInput.tsx changes, update tests to match new behavior
1010
*/
1111

12-
import {describe, it, expect, vi} from 'vitest';
13-
import {render, screen, fireEvent} from '@testing-library/react';
12+
import {describe, it, expect, vi, afterEach} from 'vitest';
13+
import {render, screen, fireEvent, waitFor} from '@testing-library/react';
1414
import userEvent from '@testing-library/user-event';
1515
import {FileInput} from './FileInput';
16+
import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
17+
18+
afterEach(() => {
19+
__resetLiveRegionsForTest();
20+
});
21+
22+
function politeRegion(): HTMLElement | null {
23+
return document.querySelector('[data-astryx-live-region="polite"]');
24+
}
25+
26+
function fileInputEl(): HTMLInputElement {
27+
const el = document.querySelector('input[type="file"]');
28+
if (!(el instanceof HTMLInputElement)) {
29+
throw new Error('file input not found');
30+
}
31+
return el;
32+
}
1633

1734
function createFile(
1835
name: string,
@@ -245,6 +262,55 @@ describe('FileInput', () => {
245262
});
246263
});
247264

265+
describe('announcements', () => {
266+
it('announces a single file selection politely', async () => {
267+
render(<FileInput label="Upload" value={null} onChange={() => {}} />);
268+
fireEvent.change(fileInputEl(), {
269+
target: {files: [createFile('report.pdf', 100)]},
270+
});
271+
await waitFor(() => {
272+
expect(politeRegion()).toHaveTextContent('1 file selected: report.pdf');
273+
});
274+
});
275+
276+
it('announces a multi-file count politely', async () => {
277+
render(
278+
<FileInput
279+
label="Upload"
280+
value={null}
281+
onChange={() => {}}
282+
isMultiple
283+
/>,
284+
);
285+
const files = [
286+
createFile('a.txt', 100),
287+
createFile('b.txt', 200),
288+
createFile('c.txt', 300),
289+
];
290+
fireEvent.change(fileInputEl(), {target: {files}});
291+
await waitFor(() => {
292+
expect(politeRegion()).toHaveTextContent('3 files selected');
293+
});
294+
});
295+
296+
it('does not announce a selection when validation rejects all files', async () => {
297+
render(
298+
<FileInput
299+
label="Upload"
300+
value={null}
301+
onChange={() => {}}
302+
accept=".pdf"
303+
/>,
304+
);
305+
fireEvent.change(fileInputEl(), {
306+
target: {files: [createFile('note.txt', 100)]},
307+
});
308+
// A rejected selection creates no polite region (only the error goes to
309+
// the existing role="status" region).
310+
expect(politeRegion()).toBeNull();
311+
});
312+
});
313+
248314
describe('validation', () => {
249315
it('rejects files exceeding maxSize', () => {
250316
const handleChange = vi.fn();

packages/core/src/FileInput/FileInput.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
} from '../Field';
4545
import {Icon, type IconName} from '../Icon';
4646
import {Spinner} from '../Spinner';
47+
import {useAnnounce} from '../hooks/useAnnounce';
4748

4849
export type {
4950
InputStatus as FileInputStatus,
@@ -410,6 +411,11 @@ export function FileInput({
410411
const [validationError, setValidationError] = useState<string | null>(null);
411412
const [, startTransition] = useTransition();
412413

414+
// Announce successful file selection to screen readers via a persistent
415+
// live region (forms-17). The component's own role="status" region only
416+
// carries validation errors, so a successful attach was previously silent.
417+
const announce = useAnnounce();
418+
413419
const status =
414420
statusProp ??
415421
(validationError
@@ -470,6 +476,17 @@ export function FileInput({
470476
const result = isMultiple ? valid : valid[0];
471477
onChange(result);
472478

479+
// Announce the successful selection politely. Validation errors are
480+
// handled by the role="status" region below, so only announce the
481+
// attach here (do not double-announce errors).
482+
if (errors.length === 0) {
483+
announce(
484+
valid.length === 1
485+
? `1 file selected: ${valid[0].name}`
486+
: `${valid.length} files selected`,
487+
);
488+
}
489+
473490
if (changeAction) {
474491
startTransition(async () => {
475492
await changeAction(result);
@@ -485,6 +502,7 @@ export function FileInput({
485502
onChange,
486503
changeAction,
487504
startTransition,
505+
announce,
488506
],
489507
);
490508

packages/core/src/MultiSelector/MultiSelector.test.tsx

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,19 @@
99
* SYNC: When MultiSelector.tsx API changes, update these tests.
1010
*/
1111

12-
import {describe, it, expect, vi, beforeEach} from 'vitest';
13-
import {render, screen} from '@testing-library/react';
12+
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
13+
import {render, screen, waitFor} from '@testing-library/react';
1414
import userEvent from '@testing-library/user-event';
1515
import {MultiSelector} from './MultiSelector';
16+
import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
17+
18+
// Module-level constants to satisfy @eslint-react/no-unstable-default-props.
19+
const ANNOUNCE_OPTIONS = ['Apple', 'Banana', 'Orange'] as const;
20+
const EMPTY_VALUE: string[] = [];
21+
22+
function politeRegion(): HTMLElement | null {
23+
return document.querySelector('[data-astryx-live-region="polite"]');
24+
}
1625

1726
// Mock showPopover and hidePopover methods since they're not implemented in jsdom
1827
beforeEach(() => {
@@ -40,6 +49,10 @@ beforeEach(() => {
4049
};
4150
});
4251

52+
afterEach(() => {
53+
__resetLiveRegionsForTest();
54+
});
55+
4356
// Helper: jsdom popover content is in the DOM but may not be
4457
// "visible" in the accessibility tree. Use hidden: true to find it.
4558
const h = {hidden: true} as const;
@@ -771,4 +784,59 @@ describe('MultiSelector', () => {
771784
}
772785
});
773786
});
787+
788+
describe('announcements', () => {
789+
it('announces the selection count politely when toggling an option', async () => {
790+
const user = userEvent.setup();
791+
render(
792+
<MultiSelector
793+
label="Fruit"
794+
options={[...ANNOUNCE_OPTIONS]}
795+
value={EMPTY_VALUE}
796+
onChange={() => {}}
797+
/>,
798+
);
799+
await user.click(screen.getByRole('combobox'));
800+
const options = screen.getAllByRole('option', {hidden: true});
801+
await user.click(options[0]);
802+
await waitFor(() => {
803+
expect(politeRegion()).toHaveTextContent('1 of 3 selected');
804+
});
805+
});
806+
807+
it('announces "All selected" when select-all selects everything', async () => {
808+
const user = userEvent.setup();
809+
render(
810+
<MultiSelector
811+
label="Fruit"
812+
options={[...ANNOUNCE_OPTIONS]}
813+
value={EMPTY_VALUE}
814+
onChange={() => {}}
815+
hasSelectAll
816+
/>,
817+
);
818+
await user.click(screen.getByRole('combobox'));
819+
await user.click(screen.getByText('Select all'));
820+
await waitFor(() => {
821+
expect(politeRegion()).toHaveTextContent('All selected');
822+
});
823+
});
824+
825+
it('announces "Selection cleared" when clearing', async () => {
826+
const user = userEvent.setup();
827+
render(
828+
<MultiSelector
829+
label="Fruit"
830+
options={[...ANNOUNCE_OPTIONS]}
831+
value={['Apple', 'Banana']}
832+
onChange={() => {}}
833+
hasClear
834+
/>,
835+
);
836+
await user.click(screen.getByRole('button', {name: 'Clear all Fruit'}));
837+
await waitFor(() => {
838+
expect(politeRegion()).toHaveTextContent('Selection cleared');
839+
});
840+
});
841+
});
774842
});

packages/core/src/MultiSelector/MultiSelector.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import {
6565
} from '../Selector/utils';
6666
import {useMultiCombobox} from './hooks';
6767
import {mergeProps} from '../utils';
68+
import {useAnnounce} from '../hooks/useAnnounce';
6869
import type {BaseProps} from '../BaseProps';
6970
import type {SizeValue} from '../utils/types';
7071
import {useSize} from '../SizeContext/SizeContext';
@@ -607,6 +608,25 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
607608
[options],
608609
);
609610

611+
// Announce selection-count changes politely (comboboxes-7 announce path).
612+
// Toggling options / select-all previously produced no audible feedback.
613+
const announce = useAnnounce();
614+
const announceSelection = useCallback(
615+
(nextValue: string[]) => {
616+
const total = selectableItems.length;
617+
const selectableSet = new Set(selectableItems.map(item => item.value));
618+
const selectedCount = nextValue.filter(v => selectableSet.has(v)).length;
619+
if (selectedCount === 0) {
620+
announce('Selection cleared');
621+
} else if (total > 0 && selectedCount === total) {
622+
announce('All selected');
623+
} else {
624+
announce(`${selectedCount} of ${total} selected`);
625+
}
626+
},
627+
[announce, selectableItems],
628+
);
629+
610630
// Filter items by search query
611631
const filteredItems = useMemo(() => {
612632
if (!searchQuery) {
@@ -715,14 +735,21 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
715735
(e: React.MouseEvent) => {
716736
e.stopPropagation(); // Don't open dropdown
717737
onChange([]);
738+
announceSelection([]);
718739
if (changeAction) {
719740
startTransition(async () => {
720741
setOptimisticValue([]);
721742
await changeAction([]);
722743
});
723744
}
724745
},
725-
[onChange, changeAction, startTransition, setOptimisticValue],
746+
[
747+
onChange,
748+
changeAction,
749+
startTransition,
750+
setOptimisticValue,
751+
announceSelection,
752+
],
726753
);
727754

728755
const handleToggle = useCallback(
@@ -732,6 +759,7 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
732759
: [...optimisticValue, itemValue];
733760

734761
onChange(newValue);
762+
announceSelection(newValue);
735763
if (changeAction) {
736764
startTransition(async () => {
737765
setOptimisticValue(newValue);
@@ -745,6 +773,7 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
745773
changeAction,
746774
startTransition,
747775
setOptimisticValue,
776+
announceSelection,
748777
],
749778
);
750779

@@ -790,6 +819,7 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
790819
}
791820

792821
onChange(newValue);
822+
announceSelection(newValue);
793823
if (changeAction) {
794824
startTransition(async () => {
795825
setOptimisticValue(newValue);
@@ -804,6 +834,7 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
804834
changeAction,
805835
startTransition,
806836
setOptimisticValue,
837+
announceSelection,
807838
]);
808839

809840
// Route toggle: select-all sentinel → handleSelectAll, everything else → handleToggle

0 commit comments

Comments
 (0)