Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
Comment thread
dmytrokirpa marked this conversation as resolved.
"type": "patch",
"comment": "feat: use AriaLiveAnnouncer for Toast component and update tests",
"packageName": "@fluentui/react-headless-components-preview",
"email": "dmytrokirpa@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -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(
<AnnounceProvider value={{ announce: jest.fn() }}>
<AriaLive announceRef={React.createRef()} />
</AnnounceProvider>,
);

expect(container.firstChild).toBeNull();
});

it('populates announceRef with a callable function once mounted', () => {
const announceRef = React.createRef<ToastAnnounce>();

render(
<AnnounceProvider value={{ announce: jest.fn() }}>
<AriaLive announceRef={announceRef} />
</AnnounceProvider>,
);

expect(typeof announceRef.current).toBe('function');
});

it('forwards calls to the surrounding AnnounceProvider', () => {
const announceSpy = jest.fn();
const announceRef = React.createRef<ToastAnnounce>();

render(
<AnnounceProvider value={{ announce: announceSpy }}>
<AriaLive announceRef={announceRef} />
</AnnounceProvider>,
);

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<ToastAnnounce>();

render(
<AnnounceProvider value={{ announce: announceSpy }}>
<AriaLive announceRef={announceRef} />
</AnnounceProvider>,
);

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<ToastAnnounce>();

render(
<AnnounceProvider value={{ announce: announceSpy }}>
<AriaLive announceRef={announceRef} />
</AnnounceProvider>,
);

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<ToastAnnounce>();

const { rerender } = render(
<AnnounceProvider value={{ announce: first }}>
<AriaLive announceRef={announceRef} />
</AnnounceProvider>,
);

act(() => {
announceRef.current?.('msg-1', { politeness: 'polite' });
});
expect(first).toHaveBeenCalledWith('msg-1', { polite: true });

rerender(
<AnnounceProvider value={{ announce: second }}>
<AriaLive announceRef={announceRef} />
</AnnounceProvider>,
);

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<ToastAnnounce>();

render(<AriaLive announceRef={announceRef} />);

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<ToastAnnounce>();

render(
<AriaLiveAnnouncer>
<AriaLive announceRef={announceRef} />
</AriaLiveAnnouncer>,
);

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');
});
});
});
Original file line number Diff line number Diff line change
@@ -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. `<AriaLiveAnnouncer>`); otherwise announcements are a no-op.
*/
announceRef: React.Ref<ToastAnnounce>;
Comment thread
dmytrokirpa marked this conversation as resolved.
};

/**
* 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<ToastLiveMessage | undefined>(undefined);
// Date.now() loses ordering when announce fires multiple times in the same tick.
const order = React.useRef(0);
const [messageQueue] = React.useState(() =>
createPriorityQueue<ToastLiveMessage>((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 (
<>
<div aria-live="assertive" style={visuallyHiddenStyle}>
{assertiveMessage}
</div>
<div aria-live="polite" style={visuallyHiddenStyle}>
{politeMessage}
</div>
</>
React.useImperativeHandle<ToastAnnounce, ToastAnnounce>(
props.announceRef,
() => (message, options) => {
announce(message, { polite: options?.politeness === 'polite' });
},
[announce],
);

return null;
};

AriaLive.displayName = 'AriaLive';
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,27 @@ describe('Toaster', () => {
],
});

it('renders aria-live regions by default', () => {
const { container } = render(<Toaster />);
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(<Toaster />);

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', () => {
const { container } = render(<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(<Toaster announce={jest.fn()} />);

expect(document.body.querySelectorAll('[aria-live]').length).toBe(before);
});
});
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 ? <AriaLive announceRef={announceRef} /> : null;
const ariaLive = renderAriaLive ? (
<AriaLiveAnnouncer>
<AriaLive announceRef={announceRef} />
</AriaLiveAnnouncer>
) : null;

const positionSlots = (
<>
{state.bottom ? <state.bottom /> : null}
Expand Down
Loading