Skip to content

Commit 507df6d

Browse files
committed
feat(react-headless-components): use AriaLiveAnnouncer for Toast component and update tests
1 parent 35720ad commit 507df6d

5 files changed

Lines changed: 205 additions & 77 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "feat: use AriaLiveAnnouncer for Toast component and update tests",
4+
"packageName": "@fluentui/react-headless-components-preview",
5+
"email": "dmytrokirpa@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import * as React from 'react';
2+
import { act, render } from '@testing-library/react';
3+
import { AnnounceProvider } from '@fluentui/react-shared-contexts';
4+
import { AriaLiveAnnouncer } from '@fluentui/react-aria';
5+
import type { ToastAnnounce } from '@fluentui/react-toast';
6+
import { AriaLive } from './AriaLive';
7+
8+
describe('AriaLive', () => {
9+
it('renders nothing visible', () => {
10+
const { container } = render(
11+
<AnnounceProvider value={{ announce: jest.fn() }}>
12+
<AriaLive announceRef={React.createRef()} />
13+
</AnnounceProvider>,
14+
);
15+
16+
expect(container.firstChild).toBeNull();
17+
});
18+
19+
it('populates announceRef with a callable function once mounted', () => {
20+
const announceRef = React.createRef<ToastAnnounce>();
21+
22+
render(
23+
<AnnounceProvider value={{ announce: jest.fn() }}>
24+
<AriaLive announceRef={announceRef} />
25+
</AnnounceProvider>,
26+
);
27+
28+
expect(typeof announceRef.current).toBe('function');
29+
});
30+
31+
it('forwards calls to the surrounding AnnounceProvider', () => {
32+
const announceSpy = jest.fn();
33+
const announceRef = React.createRef<ToastAnnounce>();
34+
35+
render(
36+
<AnnounceProvider value={{ announce: announceSpy }}>
37+
<AriaLive announceRef={announceRef} />
38+
</AnnounceProvider>,
39+
);
40+
41+
act(() => {
42+
announceRef.current?.('hello', { politeness: 'assertive' });
43+
});
44+
45+
expect(announceSpy).toHaveBeenCalledTimes(1);
46+
expect(announceSpy).toHaveBeenCalledWith('hello', { polite: false });
47+
});
48+
49+
it('adapts politeness "polite" → polite: true', () => {
50+
const announceSpy = jest.fn();
51+
const announceRef = React.createRef<ToastAnnounce>();
52+
53+
render(
54+
<AnnounceProvider value={{ announce: announceSpy }}>
55+
<AriaLive announceRef={announceRef} />
56+
</AnnounceProvider>,
57+
);
58+
59+
act(() => {
60+
announceRef.current?.('polite message', { politeness: 'polite' });
61+
});
62+
63+
expect(announceSpy).toHaveBeenLastCalledWith('polite message', { polite: true });
64+
});
65+
66+
it('adapts politeness "assertive" → polite: false', () => {
67+
const announceSpy = jest.fn();
68+
const announceRef = React.createRef<ToastAnnounce>();
69+
70+
render(
71+
<AnnounceProvider value={{ announce: announceSpy }}>
72+
<AriaLive announceRef={announceRef} />
73+
</AnnounceProvider>,
74+
);
75+
76+
act(() => {
77+
announceRef.current?.('assertive message', { politeness: 'assertive' });
78+
});
79+
80+
expect(announceSpy).toHaveBeenLastCalledWith('assertive message', { polite: false });
81+
});
82+
83+
it('updates the bound ref when the context announce changes', () => {
84+
const first = jest.fn();
85+
const second = jest.fn();
86+
const announceRef = React.createRef<ToastAnnounce>();
87+
88+
const { rerender } = render(
89+
<AnnounceProvider value={{ announce: first }}>
90+
<AriaLive announceRef={announceRef} />
91+
</AnnounceProvider>,
92+
);
93+
94+
act(() => {
95+
announceRef.current?.('msg-1', { politeness: 'polite' });
96+
});
97+
expect(first).toHaveBeenCalledWith('msg-1', { polite: true });
98+
99+
rerender(
100+
<AnnounceProvider value={{ announce: second }}>
101+
<AriaLive announceRef={announceRef} />
102+
</AnnounceProvider>,
103+
);
104+
105+
act(() => {
106+
announceRef.current?.('msg-2', { politeness: 'polite' });
107+
});
108+
expect(second).toHaveBeenCalledWith('msg-2', { polite: true });
109+
expect(first).toHaveBeenCalledTimes(1); // no double-fire
110+
});
111+
112+
it('is a no-op when rendered without an AnnounceProvider ancestor', () => {
113+
const announceRef = React.createRef<ToastAnnounce>();
114+
115+
render(<AriaLive announceRef={announceRef} />);
116+
117+
expect(() => {
118+
act(() => {
119+
announceRef.current?.('orphan', { politeness: 'polite' });
120+
});
121+
}).not.toThrow();
122+
});
123+
124+
describe('end-to-end via AriaLiveAnnouncer', () => {
125+
beforeEach(() => {
126+
jest.useFakeTimers();
127+
});
128+
129+
afterEach(() => {
130+
jest.useRealTimers();
131+
});
132+
133+
it('routes assertive announcements into the document.body live region', () => {
134+
const announceRef = React.createRef<ToastAnnounce>();
135+
136+
render(
137+
<AriaLiveAnnouncer>
138+
<AriaLive announceRef={announceRef} />
139+
</AriaLiveAnnouncer>,
140+
);
141+
142+
act(() => {
143+
announceRef.current?.('end-to-end message', { politeness: 'assertive' });
144+
});
145+
// useDomAnnounce schedules a 0ms cycle that commits the message into a
146+
// child span of the live region.
147+
act(() => {
148+
jest.advanceTimersByTime(0);
149+
});
150+
151+
const liveRegion = document.body.querySelector('[aria-live="assertive"]');
152+
// useDomAnnounce sets the message via `.innerText` on a wrapping span
153+
// (a workaround for some SR/browsers); jsdom stores `innerText` but
154+
// doesn't reflect it to `textContent`, so we read it directly.
155+
const wrappingSpan = liveRegion?.querySelector('span');
156+
expect(wrappingSpan?.innerText).toContain('end-to-end message');
157+
});
158+
});
159+
});
Lines changed: 17 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,85 +1,30 @@
11
'use client';
22

33
import * as React from 'react';
4-
import { createPriorityQueue, useEventCallback, useTimeout } from '@fluentui/react-utilities';
5-
import type { ToastAnnounce, ToastAnnounceOptions, ToastLiveMessage } from '@fluentui/react-toast';
6-
7-
/** Duration the message stays in DOM so screen readers register the change. */
8-
const MESSAGE_DURATION = 500;
9-
10-
const visuallyHiddenStyle: React.CSSProperties = {
11-
position: 'absolute',
12-
width: '1px',
13-
height: '1px',
14-
margin: '-1px',
15-
padding: 0,
16-
overflow: 'hidden',
17-
clip: 'rect(0px, 0px, 0px, 0px)',
18-
};
4+
import type { JSXElement } from '@fluentui/react-utilities';
5+
import { useAnnounce } from '@fluentui/react-shared-contexts';
6+
import type { ToastAnnounce } from '@fluentui/react-toast';
197

208
export type AriaLiveProps = {
9+
/**
10+
* Receives the announce function resolved from `AnnounceContext`. Must be
11+
* rendered inside an `<AriaLiveAnnouncer>` ancestor.
12+
*/
2113
announceRef: React.Ref<ToastAnnounce>;
2214
};
2315

24-
/**
25-
* Renders two visually-hidden `aria-live` regions (one polite, one assertive)
26-
* and exposes an imperative `announce(message, { politeness })` API via
27-
* `announceRef`. No Griffel; visually-hidden via inline styles only.
28-
*/
29-
export const AriaLive = ({ announceRef }: AriaLiveProps): React.ReactNode => {
30-
const [currentMessage, setCurrentMessage] = React.useState<ToastLiveMessage | undefined>(undefined);
31-
// Date.now() loses ordering when announce fires multiple times in the same tick.
32-
const order = React.useRef(0);
33-
const [messageQueue] = React.useState(() =>
34-
createPriorityQueue<ToastLiveMessage>((a, b) => {
35-
if (a.politeness === b.politeness) {
36-
return a.createdAt - b.createdAt;
37-
}
38-
return a.politeness === 'assertive' ? -1 : 1;
39-
}),
40-
);
41-
42-
const announce = useEventCallback((message: string, options: ToastAnnounceOptions) => {
43-
const { politeness } = options;
44-
if (message === currentMessage?.message) {
45-
return;
46-
}
47-
const liveMessage: ToastLiveMessage = { message, politeness, createdAt: order.current++ };
48-
if (!currentMessage) {
49-
setCurrentMessage(liveMessage);
50-
} else {
51-
messageQueue.enqueue(liveMessage);
52-
}
53-
});
16+
export const AriaLive = (props: AriaLiveProps): JSXElement | null => {
17+
const { announce } = useAnnounce();
5418

55-
const [setMessageTimeout, clearMessageTimeout] = useTimeout();
56-
57-
React.useEffect(() => {
58-
setMessageTimeout(() => {
59-
if (messageQueue.peek()) {
60-
setCurrentMessage(messageQueue.dequeue());
61-
} else {
62-
setCurrentMessage(undefined);
63-
}
64-
}, MESSAGE_DURATION);
65-
return () => clearMessageTimeout();
66-
}, [currentMessage, messageQueue, setMessageTimeout, clearMessageTimeout]);
67-
68-
React.useImperativeHandle(announceRef, () => announce);
69-
70-
const politeMessage = currentMessage?.politeness === 'polite' ? currentMessage.message : undefined;
71-
const assertiveMessage = currentMessage?.politeness === 'assertive' ? currentMessage.message : undefined;
72-
73-
return (
74-
<>
75-
<div aria-live="assertive" style={visuallyHiddenStyle}>
76-
{assertiveMessage}
77-
</div>
78-
<div aria-live="polite" style={visuallyHiddenStyle}>
79-
{politeMessage}
80-
</div>
81-
</>
19+
React.useImperativeHandle<ToastAnnounce, ToastAnnounce>(
20+
props.announceRef,
21+
() => (message, options) => {
22+
announce(message, { polite: options?.politeness === 'polite' });
23+
},
24+
[announce],
8225
);
26+
27+
return null;
8328
};
8429

8530
AriaLive.displayName = 'AriaLive';

packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.test.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,27 @@ describe('Toaster', () => {
2020
],
2121
});
2222

23-
it('renders aria-live regions by default', () => {
24-
const { container } = render(<Toaster />);
23+
it('mounts an aria-live region on document.body by default', () => {
24+
// AriaLiveAnnouncer attaches its live region directly to document.body
25+
// (not inside the Toaster's React tree) so a single shared region serves
26+
// the whole app. The DOM fallback path only renders an assertive region;
27+
// polite messages on browsers without `ariaNotify` are announced assertively.
28+
render(<Toaster />);
2529

26-
expect(container.querySelector('[aria-live="assertive"]')).not.toBeNull();
27-
expect(container.querySelector('[aria-live="polite"]')).not.toBeNull();
30+
expect(document.body.querySelector('[aria-live="assertive"]')).not.toBeNull();
2831
});
2932

3033
it('does not render position containers when there are no toasts', () => {
3134
const { container } = render(<Toaster />);
3235

3336
expect(container.querySelector('[data-toaster-position]')).toBeNull();
3437
});
38+
39+
it('does not mount its own live region when a custom `announce` prop is supplied', () => {
40+
const before = document.body.querySelectorAll('[aria-live]').length;
41+
42+
render(<Toaster announce={jest.fn()} />);
43+
44+
expect(document.body.querySelectorAll('[aria-live]').length).toBe(before);
45+
});
3546
});

packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/renderToaster.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/** @jsxRuntime automatic */
22
/** @jsxImportSource @fluentui/react-jsx-runtime */
33

4+
import { AriaLiveAnnouncer } from '@fluentui/react-aria';
45
import { assertSlots } from '@fluentui/react-utilities';
56
import type { JSXElement } from '@fluentui/react-utilities';
67
import type { ToasterSlotsInternal, ToasterState } from './Toaster.types';
@@ -21,7 +22,12 @@ export const renderToaster = (state: ToasterState): JSXElement => {
2122
const hasToasts =
2223
!!state.bottomStart || !!state.bottomEnd || !!state.topStart || !!state.topEnd || !!state.top || !!state.bottom;
2324

24-
const ariaLive = renderAriaLive ? <AriaLive announceRef={announceRef} /> : null;
25+
const ariaLive = renderAriaLive ? (
26+
<AriaLiveAnnouncer>
27+
<AriaLive announceRef={announceRef} />
28+
</AriaLiveAnnouncer>
29+
) : null;
30+
2531
const positionSlots = (
2632
<>
2733
{state.bottom ? <state.bottom /> : null}

0 commit comments

Comments
 (0)