Skip to content

Commit b712ff3

Browse files
committed
feat(react-toast): export AriaLive component and re-use it for headless Toast
1 parent da3b7a4 commit b712ff3

9 files changed

Lines changed: 90 additions & 75 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: re-use Toast's AriaLive component from react-toast",
4+
"packageName": "@fluentui/react-headless-components-preview",
5+
"email": "dmytrokirpa@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}
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: add export for AriaLive component",
4+
"packageName": "@fluentui/react-toast",
5+
"email": "dmytrokirpa@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}
Lines changed: 7 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,85 +1,19 @@
11
'use client';
22

3-
import * as React from 'react';
4-
import { createPriorityQueue, useEventCallback, useTimeout } from '@fluentui/react-utilities';
5-
import type { ToastAnnounce, ToastAnnounceOptions, ToastLiveMessage } from '@fluentui/react-toast';
3+
import type { JSXElement } from '@fluentui/react-utilities';
64

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-
};
19-
20-
export type AriaLiveProps = {
21-
announceRef: React.Ref<ToastAnnounce>;
22-
};
5+
import type { AriaLiveProps } from './AriaLive.types';
6+
import { useAriaLive } from './useAriaLive';
7+
import { renderAriaLive } from './renderAriaLive';
238

249
/**
2510
* Renders two visually-hidden `aria-live` regions (one polite, one assertive)
2611
* and exposes an imperative `announce(message, { politeness })` API via
2712
* `announceRef`. No Griffel; visually-hidden via inline styles only.
2813
*/
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-
});
54-
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-
</>
82-
);
14+
export const AriaLive = (props: AriaLiveProps): JSXElement => {
15+
const state = useAriaLive(props);
16+
return renderAriaLive(state);
8317
};
8418

8519
AriaLive.displayName = 'AriaLive';
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export type {
2+
ToastAriaLiveSlots as AriaLiveSlots,
3+
ToastAriaLiveProps as AriaLiveProps,
4+
ToastAriaLiveState as AriaLiveState,
5+
} from '@fluentui/react-toast';
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
export { AriaLive } from './AriaLive';
2-
export type { AriaLiveProps } from './AriaLive';
2+
export type { AriaLiveSlots, AriaLiveProps, AriaLiveState } from './AriaLive.types';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { renderToastAriaLive_unstable as renderAriaLive } from '@fluentui/react-toast';
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
'use client';
2+
3+
import type * as React from 'react';
4+
import { useToastAriaLive_unstable } from '@fluentui/react-toast';
5+
import type { AriaLiveProps, AriaLiveState } from './AriaLive.types';
6+
7+
const visuallyHiddenStyle: React.CSSProperties = {
8+
position: 'absolute',
9+
width: '1px',
10+
height: '1px',
11+
margin: '-1px',
12+
padding: 0,
13+
overflow: 'hidden',
14+
clip: 'rect(0px, 0px, 0px, 0px)',
15+
};
16+
17+
/**
18+
* Manages aria live announcements imperatively via `announceRef`.
19+
*/
20+
export const useAriaLive = (props: AriaLiveProps): AriaLiveState => {
21+
const state = useToastAriaLive_unstable(props);
22+
23+
// eslint-disable-next-line react-hooks/immutability
24+
state.assertive.style = visuallyHiddenStyle;
25+
// eslint-disable-next-line react-hooks/immutability
26+
state.polite.style = visuallyHiddenStyle;
27+
28+
return state;
29+
};

packages/react-components/react-toast/library/etc/react-toast.api.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import type { TriggerProps } from '@fluentui/react-utilities';
2020
// @public
2121
export const renderToast_unstable: (state: ToastState, contextValues: ToastContextValues) => JSXElement;
2222

23+
// @public
24+
export const renderToastAriaLive_unstable: (state: ToastAriaLiveState) => JSXElement;
25+
2326
// @public
2427
export const renderToastBody_unstable: (state: ToastBodyState) => JSXElement;
2528

@@ -49,6 +52,24 @@ export type ToastAnnounceOptions = {
4952
politeness: AriaLivePoliteness;
5053
};
5154

55+
// @public
56+
export const ToastAriaLive: React_2.FC<ToastAriaLiveProps>;
57+
58+
// @public
59+
export type ToastAriaLiveProps = ComponentProps<Partial<ToastAriaLiveSlots>> & {
60+
announceRef: React_2.Ref<ToastAnnounce>;
61+
children?: React_2.ReactNode;
62+
};
63+
64+
// @public (undocumented)
65+
export type ToastAriaLiveSlots = {
66+
assertive: NonNullable<Slot<'div'>>;
67+
polite: NonNullable<Slot<'div'>>;
68+
};
69+
70+
// @public
71+
export type ToastAriaLiveState = ComponentState<ToastAriaLiveSlots>;
72+
5273
// @public
5374
export type ToastBaseProps = Omit<ToastProps, 'appearance'>;
5475

@@ -188,7 +209,7 @@ export type ToasterSlots = {
188209
};
189210

190211
// @public
191-
export type ToasterState = ComponentState<ToasterSlotsInternal> & Pick<AriaLiveProps, 'announceRef'> & Pick<PortalProps, 'mountNode'> & Pick<Required<ToasterProps>, 'announce' | 'inline'> & {
212+
export type ToasterState = ComponentState<ToasterSlotsInternal> & Pick<ToastAriaLiveProps, 'announceRef'> & Pick<PortalProps, 'mountNode'> & Pick<Required<ToasterProps>, 'announce' | 'inline'> & {
192213
offset: ToasterOptions['offset'] | undefined;
193214
renderAriaLive: boolean;
194215
dir: 'rtl' | 'ltr';
@@ -339,6 +360,9 @@ export function useToastAnnounce(announce: ToastAnnounce): {
339360
toasterRef: React_2.RefCallback<HTMLDivElement>;
340361
};
341362

363+
// @public
364+
export const useToastAriaLive_unstable: (props: ToastAriaLiveProps) => ToastAriaLiveState;
365+
342366
// @public
343367
export const useToastBase_unstable: (props: ToastBaseProps, ref: React_2.Ref<HTMLElement>) => ToastBaseState;
344368

packages/react-components/react-toast/library/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
export { useToastController, useToaster } from './state';
22
export type {
3+
AriaLiveSlots as ToastAriaLiveSlots,
4+
AriaLiveProps as ToastAriaLiveProps,
5+
AriaLiveState as ToastAriaLiveState,
36
Announce as ToastAnnounce,
47
AnnounceOptions as ToastAnnounceOptions,
58
LiveMessage as ToastLiveMessage,
69
} from './AriaLive';
10+
export {
11+
AriaLive as ToastAriaLive,
12+
useAriaLive_unstable as useToastAriaLive_unstable,
13+
renderAriaLive_unstable as renderToastAriaLive_unstable,
14+
} from './AriaLive';
715
export type {
816
ToastPosition,
917
ToastId,

0 commit comments

Comments
 (0)