diff --git a/change/@fluentui-react-headless-components-preview-0c06bf3c-fc53-48f0-8c19-5d507ad82db8.json b/change/@fluentui-react-headless-components-preview-0c06bf3c-fc53-48f0-8c19-5d507ad82db8.json
new file mode 100644
index 00000000000000..83651a809e821b
--- /dev/null
+++ b/change/@fluentui-react-headless-components-preview-0c06bf3c-fc53-48f0-8c19-5d507ad82db8.json
@@ -0,0 +1,7 @@
+{
+ "type": "patch",
+ "comment": "feat: use AriaLiveAnnouncer for Toast component and update tests",
+ "packageName": "@fluentui/react-headless-components-preview",
+ "email": "dmytrokirpa@microsoft.com",
+ "dependentChangeType": "patch"
+}
diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/AriaLive/AriaLive.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/AriaLive/AriaLive.test.tsx
new file mode 100644
index 00000000000000..ff94f7238904ff
--- /dev/null
+++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/AriaLive/AriaLive.test.tsx
@@ -0,0 +1,159 @@
+import * as React from 'react';
+import { act, render } from '@testing-library/react';
+import { AnnounceProvider } from '@fluentui/react-shared-contexts';
+import { AriaLiveAnnouncer } from '@fluentui/react-aria';
+import type { ToastAnnounce } from '@fluentui/react-toast';
+import { AriaLive } from './AriaLive';
+
+describe('AriaLive', () => {
+ it('renders nothing visible', () => {
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('populates announceRef with a callable function once mounted', () => {
+ const announceRef = React.createRef();
+
+ render(
+
+
+ ,
+ );
+
+ expect(typeof announceRef.current).toBe('function');
+ });
+
+ it('forwards calls to the surrounding AnnounceProvider', () => {
+ const announceSpy = jest.fn();
+ const announceRef = React.createRef();
+
+ render(
+
+
+ ,
+ );
+
+ act(() => {
+ announceRef.current?.('hello', { politeness: 'assertive' });
+ });
+
+ expect(announceSpy).toHaveBeenCalledTimes(1);
+ expect(announceSpy).toHaveBeenCalledWith('hello', { polite: false });
+ });
+
+ it('adapts politeness "polite" → polite: true', () => {
+ const announceSpy = jest.fn();
+ const announceRef = React.createRef();
+
+ render(
+
+
+ ,
+ );
+
+ act(() => {
+ announceRef.current?.('polite message', { politeness: 'polite' });
+ });
+
+ expect(announceSpy).toHaveBeenLastCalledWith('polite message', { polite: true });
+ });
+
+ it('adapts politeness "assertive" → polite: false', () => {
+ const announceSpy = jest.fn();
+ const announceRef = React.createRef();
+
+ render(
+
+
+ ,
+ );
+
+ act(() => {
+ announceRef.current?.('assertive message', { politeness: 'assertive' });
+ });
+
+ expect(announceSpy).toHaveBeenLastCalledWith('assertive message', { polite: false });
+ });
+
+ it('updates the bound ref when the context announce changes', () => {
+ const first = jest.fn();
+ const second = jest.fn();
+ const announceRef = React.createRef();
+
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ act(() => {
+ announceRef.current?.('msg-1', { politeness: 'polite' });
+ });
+ expect(first).toHaveBeenCalledWith('msg-1', { polite: true });
+
+ rerender(
+
+
+ ,
+ );
+
+ act(() => {
+ announceRef.current?.('msg-2', { politeness: 'polite' });
+ });
+ expect(second).toHaveBeenCalledWith('msg-2', { polite: true });
+ expect(first).toHaveBeenCalledTimes(1); // no double-fire
+ });
+
+ it('is a no-op when rendered without an AnnounceProvider ancestor', () => {
+ const announceRef = React.createRef();
+
+ render();
+
+ expect(() => {
+ act(() => {
+ announceRef.current?.('orphan', { politeness: 'polite' });
+ });
+ }).not.toThrow();
+ });
+
+ describe('end-to-end via AriaLiveAnnouncer', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('routes assertive announcements into the document.body live region', () => {
+ const announceRef = React.createRef();
+
+ render(
+
+
+ ,
+ );
+
+ act(() => {
+ announceRef.current?.('end-to-end message', { politeness: 'assertive' });
+ });
+ // useDomAnnounce schedules a 0ms cycle that commits the message into a
+ // child span of the live region.
+ act(() => {
+ jest.advanceTimersByTime(0);
+ });
+
+ const liveRegion = document.body.querySelector('[aria-live="assertive"]');
+ // useDomAnnounce sets the message via `.innerText` on a wrapping span
+ // (a workaround for some SR/browsers); jsdom stores `innerText` but
+ // doesn't reflect it to `textContent`, so we read it directly.
+ const wrappingSpan = liveRegion?.querySelector('span');
+ expect(wrappingSpan?.innerText).toContain('end-to-end message');
+ });
+ });
+});
diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/AriaLive/AriaLive.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/AriaLive/AriaLive.tsx
index 97051edcb289f4..410ee2ec6f616d 100644
--- a/packages/react-components/react-headless-components-preview/library/src/components/Toast/AriaLive/AriaLive.tsx
+++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/AriaLive/AriaLive.tsx
@@ -1,85 +1,30 @@
'use client';
import * as React from 'react';
-import { createPriorityQueue, useEventCallback, useTimeout } from '@fluentui/react-utilities';
-import type { ToastAnnounce, ToastAnnounceOptions, ToastLiveMessage } from '@fluentui/react-toast';
-
-/** Duration the message stays in DOM so screen readers register the change. */
-const MESSAGE_DURATION = 500;
-
-const visuallyHiddenStyle: React.CSSProperties = {
- position: 'absolute',
- width: '1px',
- height: '1px',
- margin: '-1px',
- padding: 0,
- overflow: 'hidden',
- clip: 'rect(0px, 0px, 0px, 0px)',
-};
+import type { JSXElement } from '@fluentui/react-utilities';
+import { useAnnounce } from '@fluentui/react-shared-contexts';
+import type { ToastAnnounce } from '@fluentui/react-toast';
export type AriaLiveProps = {
+ /**
+ * Receives the announce function resolved from `AnnounceContext`.
+ * Requires an `AnnounceProvider` ancestor (e.g. ``); otherwise announcements are a no-op.
+ */
announceRef: React.Ref;
};
-/**
- * Renders two visually-hidden `aria-live` regions (one polite, one assertive)
- * and exposes an imperative `announce(message, { politeness })` API via
- * `announceRef`. No Griffel; visually-hidden via inline styles only.
- */
-export const AriaLive = ({ announceRef }: AriaLiveProps): React.ReactNode => {
- const [currentMessage, setCurrentMessage] = React.useState(undefined);
- // Date.now() loses ordering when announce fires multiple times in the same tick.
- const order = React.useRef(0);
- const [messageQueue] = React.useState(() =>
- createPriorityQueue((a, b) => {
- if (a.politeness === b.politeness) {
- return a.createdAt - b.createdAt;
- }
- return a.politeness === 'assertive' ? -1 : 1;
- }),
- );
-
- const announce = useEventCallback((message: string, options: ToastAnnounceOptions) => {
- const { politeness } = options;
- if (message === currentMessage?.message) {
- return;
- }
- const liveMessage: ToastLiveMessage = { message, politeness, createdAt: order.current++ };
- if (!currentMessage) {
- setCurrentMessage(liveMessage);
- } else {
- messageQueue.enqueue(liveMessage);
- }
- });
+export const AriaLive = (props: AriaLiveProps): JSXElement | null => {
+ const { announce } = useAnnounce();
- const [setMessageTimeout, clearMessageTimeout] = useTimeout();
-
- React.useEffect(() => {
- setMessageTimeout(() => {
- if (messageQueue.peek()) {
- setCurrentMessage(messageQueue.dequeue());
- } else {
- setCurrentMessage(undefined);
- }
- }, MESSAGE_DURATION);
- return () => clearMessageTimeout();
- }, [currentMessage, messageQueue, setMessageTimeout, clearMessageTimeout]);
-
- React.useImperativeHandle(announceRef, () => announce);
-
- const politeMessage = currentMessage?.politeness === 'polite' ? currentMessage.message : undefined;
- const assertiveMessage = currentMessage?.politeness === 'assertive' ? currentMessage.message : undefined;
-
- return (
- <>
-
- {assertiveMessage}
-
-
- {politeMessage}
-
- >
+ React.useImperativeHandle(
+ props.announceRef,
+ () => (message, options) => {
+ announce(message, { polite: options?.politeness === 'polite' });
+ },
+ [announce],
);
+
+ return null;
};
AriaLive.displayName = 'AriaLive';
diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.test.tsx
index 111180ba384e84..998489831689d0 100644
--- a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.test.tsx
+++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.test.tsx
@@ -20,11 +20,14 @@ describe('Toaster', () => {
],
});
- it('renders aria-live regions by default', () => {
- const { container } = render();
+ it('mounts an aria-live region on document.body by default', () => {
+ // AriaLiveAnnouncer attaches its live region directly to document.body
+ // (not inside the Toaster's React tree) so a single shared region serves
+ // the whole app. The DOM fallback path only renders an assertive region;
+ // polite messages on browsers without `ariaNotify` are announced assertively.
+ render();
- expect(container.querySelector('[aria-live="assertive"]')).not.toBeNull();
- expect(container.querySelector('[aria-live="polite"]')).not.toBeNull();
+ expect(document.body.querySelector('[aria-live="assertive"]')).not.toBeNull();
});
it('does not render position containers when there are no toasts', () => {
@@ -32,4 +35,12 @@ describe('Toaster', () => {
expect(container.querySelector('[data-toaster-position]')).toBeNull();
});
+
+ it('does not mount its own live region when a custom `announce` prop is supplied', () => {
+ const before = document.body.querySelectorAll('[aria-live]').length;
+
+ render();
+
+ expect(document.body.querySelectorAll('[aria-live]').length).toBe(before);
+ });
});
diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/renderToaster.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/renderToaster.tsx
index 6973fc5f057352..3a2441f4d7cfc8 100644
--- a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/renderToaster.tsx
+++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/renderToaster.tsx
@@ -1,6 +1,7 @@
/** @jsxRuntime automatic */
/** @jsxImportSource @fluentui/react-jsx-runtime */
+import { AriaLiveAnnouncer } from '@fluentui/react-aria';
import { assertSlots } from '@fluentui/react-utilities';
import type { JSXElement } from '@fluentui/react-utilities';
import type { ToasterSlotsInternal, ToasterState } from './Toaster.types';
@@ -21,7 +22,12 @@ export const renderToaster = (state: ToasterState): JSXElement => {
const hasToasts =
!!state.bottomStart || !!state.bottomEnd || !!state.topStart || !!state.topEnd || !!state.top || !!state.bottom;
- const ariaLive = renderAriaLive ? : null;
+ const ariaLive = renderAriaLive ? (
+
+
+
+ ) : null;
+
const positionSlots = (
<>
{state.bottom ? : null}