From 507df6da1b8bda181ad4f32874d6a31c4e9199fd Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Thu, 28 May 2026 20:00:25 +0200 Subject: [PATCH 1/2] feat(react-headless-components): use AriaLiveAnnouncer for Toast component and update tests --- ...-0c06bf3c-fc53-48f0-8c19-5d507ad82db8.json | 7 + .../Toast/AriaLive/AriaLive.test.tsx | 159 ++++++++++++++++++ .../components/Toast/AriaLive/AriaLive.tsx | 89 ++-------- .../components/Toast/Toaster/Toaster.test.tsx | 19 ++- .../Toast/Toaster/renderToaster.tsx | 8 +- 5 files changed, 205 insertions(+), 77 deletions(-) create mode 100644 change/@fluentui-react-headless-components-preview-0c06bf3c-fc53-48f0-8c19-5d507ad82db8.json create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/AriaLive/AriaLive.test.tsx 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..7df6139097daf6 --- /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..213ef771f56f5d 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`. Must be + * rendered inside an `` ancestor. + */ 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} From 756f04bd1cba3d34af3891f814569452d23ce831 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Thu, 28 May 2026 20:23:53 +0200 Subject: [PATCH 2/2] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- ...mponents-preview-0c06bf3c-fc53-48f0-8c19-5d507ad82db8.json | 2 +- .../library/src/components/Toast/AriaLive/AriaLive.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 index 7df6139097daf6..83651a809e821b 100644 --- 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 @@ -1,6 +1,6 @@ { "type": "patch", - "comment": "feat: use AriaLiveAnnouncer for Toast component and update tests", + "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.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/AriaLive/AriaLive.tsx index 213ef771f56f5d..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 @@ -7,8 +7,8 @@ import type { ToastAnnounce } from '@fluentui/react-toast'; export type AriaLiveProps = { /** - * Receives the announce function resolved from `AnnounceContext`. Must be - * rendered inside an `` ancestor. + * Receives the announce function resolved from `AnnounceContext`. + * Requires an `AnnounceProvider` ancestor (e.g. ``); otherwise announcements are a no-op. */ announceRef: React.Ref; };