Skip to content

Commit da3b7a4

Browse files
dmytrokirpaclaude
andauthored
feat(react-toast): headless toast implementation (#36059)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fa64f01 commit da3b7a4

60 files changed

Lines changed: 3173 additions & 1 deletion

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
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 Toast component",
4+
"packageName": "@fluentui/react-headless-components-preview",
5+
"email": "dmytrokirpa@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import * as TabList from '@fluentui/react-headless-components-preview/tab-list';
3838
import * as Tag from '@fluentui/react-headless-components-preview/tag';
3939
import * as TagGroup from '@fluentui/react-headless-components-preview/tag-group';
4040
import * as Textarea from '@fluentui/react-headless-components-preview/textarea';
41+
import * as Toast from '@fluentui/react-headless-components-preview/toast';
4142
import * as ToggleButton from '@fluentui/react-headless-components-preview/toggle-button';
4243
import * as Toolbar from '@fluentui/react-headless-components-preview/toolbar';
4344
import * as Tooltip from '@fluentui/react-headless-components-preview/tooltip';
@@ -83,6 +84,7 @@ console.log({
8384
Tag,
8485
TagGroup,
8586
Textarea,
87+
Toast,
8688
ToggleButton,
8789
Toolbar,
8890
Tooltip,
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
## API Report File for "@fluentui/react-headless-components-preview"
2+
3+
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
4+
5+
```ts
6+
7+
import type { ComponentProps } from '@fluentui/react-utilities';
8+
import type { ComponentState } from '@fluentui/react-utilities';
9+
import type { ForwardRefComponent } from '@fluentui/react-utilities';
10+
import type { JSXElement } from '@fluentui/react-utilities';
11+
import * as React_2 from 'react';
12+
import { renderToastFooter_unstable as renderToastFooter } from '@fluentui/react-toast';
13+
import type { Slot } from '@fluentui/react-utilities';
14+
import type { ToastAnnounce } from '@fluentui/react-toast';
15+
import type { ToastBaseState } from '@fluentui/react-toast';
16+
import { ToastBodyBaseProps as ToastBodyProps } from '@fluentui/react-toast';
17+
import { ToastBodySlots } from '@fluentui/react-toast';
18+
import { ToastBodyBaseState as ToastBodyState } from '@fluentui/react-toast';
19+
import { ToastChangeData } from '@fluentui/react-toast';
20+
import { ToastChangeHandler } from '@fluentui/react-toast';
21+
import type { ToastContainerContextValue } from '@fluentui/react-toast';
22+
import type { ToastData } from '@fluentui/react-toast';
23+
import { ToasterId } from '@fluentui/react-toast';
24+
import type { ToasterProps as ToasterProps_2 } from '@fluentui/react-toast';
25+
import type { ToasterState as ToasterState_2 } from '@fluentui/react-toast';
26+
import { ToastFooterProps } from '@fluentui/react-toast';
27+
import { ToastFooterSlots } from '@fluentui/react-toast';
28+
import { ToastFooterState } from '@fluentui/react-toast';
29+
import { ToastId } from '@fluentui/react-toast';
30+
import { ToastImperativeRef } from '@fluentui/react-toast';
31+
import { ToastIntent } from '@fluentui/react-toast';
32+
import { ToastPoliteness } from '@fluentui/react-toast';
33+
import { ToastPosition } from '@fluentui/react-toast';
34+
import { ToastBaseProps as ToastProps } from '@fluentui/react-toast';
35+
import { ToastSlots } from '@fluentui/react-toast';
36+
import { ToastStatus } from '@fluentui/react-toast';
37+
import { ToastTitleBaseProps as ToastTitleProps } from '@fluentui/react-toast';
38+
import { ToastTitleSlots } from '@fluentui/react-toast';
39+
import { ToastTitleBaseState as ToastTitleState } from '@fluentui/react-toast';
40+
import { useToastBodyBase_unstable as useToastBody } from '@fluentui/react-toast';
41+
import { useToastContainerContext } from '@fluentui/react-toast';
42+
import { useToastController } from '@fluentui/react-toast';
43+
import { useToastFooter_unstable as useToastFooter } from '@fluentui/react-toast';
44+
import { useToastTitleBase_unstable as useToastTitle } from '@fluentui/react-toast';
45+
46+
// @public
47+
export const renderToast: (state: ToastState) => JSXElement;
48+
49+
// @public
50+
export const renderToastBody: (state: ToastBodyState) => JSXElement;
51+
52+
// @public
53+
export const renderToastContainer: (state: ToastContainerState, contextValues: ToastContainerContextValues) => JSXElement;
54+
55+
// @public
56+
export const renderToaster: (state: ToasterState) => JSXElement;
57+
58+
export { renderToastFooter }
59+
60+
// @public (undocumented)
61+
export const renderToastTitle: (state: ToastTitleState) => JSXElement;
62+
63+
// @public
64+
export const Toast: ForwardRefComponent<ToastProps>;
65+
66+
// @public
67+
export const ToastBody: ForwardRefComponent<ToastBodyProps>;
68+
69+
export { ToastBodyProps }
70+
71+
export { ToastBodySlots }
72+
73+
export { ToastBodyState }
74+
75+
export { ToastChangeData }
76+
77+
export { ToastChangeHandler }
78+
79+
// @public
80+
export const ToastContainer: ForwardRefComponent<ToastContainerProps>;
81+
82+
export { ToastContainerContextValue }
83+
84+
// @public (undocumented)
85+
export type ToastContainerProps = Omit<ComponentProps<Partial<ToastContainerSlots>>, 'content'> & ToastData & {
86+
visible: boolean;
87+
tryRestoreFocus: () => void;
88+
announce?: ToastAnnounce;
89+
};
90+
91+
// @public (undocumented)
92+
export type ToastContainerSlots = {
93+
root: NonNullable<Slot<'div'>>;
94+
};
95+
96+
// @public (undocumented)
97+
export type ToastContainerState = ComponentState<ToastContainerSlots> & Pick<ToastContainerProps, 'remove' | 'close' | 'updateId' | 'visible' | 'intent'> & Pick<ToastContainerContextValue, 'titleId' | 'bodyId'> & {
98+
running: boolean;
99+
nodeRef: React_2.Ref<HTMLDivElement>;
100+
};
101+
102+
// @public
103+
export const Toaster: {
104+
(props: ToasterProps): JSXElement;
105+
displayName: string;
106+
};
107+
108+
export { ToasterId }
109+
110+
// @public
111+
export type ToasterProps = Omit<ToasterProps_2, 'mountNode' | 'inline'>;
112+
113+
// @public
114+
export type ToasterState = Omit<ToasterState_2, 'mountNode' | 'inline'>;
115+
116+
// @public
117+
export const ToastFooter: ForwardRefComponent<ToastFooterProps>;
118+
119+
export { ToastFooterProps }
120+
121+
export { ToastFooterSlots }
122+
123+
export { ToastFooterState }
124+
125+
export { ToastId }
126+
127+
export { ToastImperativeRef }
128+
129+
export { ToastIntent }
130+
131+
export { ToastPoliteness }
132+
133+
export { ToastPosition }
134+
135+
export { ToastProps }
136+
137+
export { ToastSlots }
138+
139+
// @public (undocumented)
140+
export type ToastState = ToastBaseState & {
141+
root: {
142+
'data-intent'?: string;
143+
};
144+
};
145+
146+
export { ToastStatus }
147+
148+
// @public
149+
export const ToastTitle: ForwardRefComponent<ToastTitleProps>;
150+
151+
export { ToastTitleProps }
152+
153+
export { ToastTitleSlots }
154+
155+
export { ToastTitleState }
156+
157+
// @public
158+
export const useToast: (props: ToastProps, ref: React_2.Ref<HTMLElement>) => ToastState;
159+
160+
export { useToastBody }
161+
162+
// @public
163+
export const useToastContainer: (props: ToastContainerProps, ref: React_2.Ref<HTMLElement>) => ToastContainerState;
164+
165+
export { useToastContainerContext }
166+
167+
// @public (undocumented)
168+
export const useToastContainerContextValues: (state: ToastContainerState) => ToastContainerContextValues;
169+
170+
export { useToastController }
171+
172+
// @public
173+
export const useToaster: (props: ToasterProps) => ToasterState;
174+
175+
export { useToastFooter }
176+
177+
export { useToastTitle }
178+
179+
// (No @packageDocumentation comment for this package)
180+
181+
```

packages/react-components/react-headless-components-preview/library/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@
4141
"@fluentui/react-nav": "^9.4.0",
4242
"@fluentui/react-persona": "^9.7.4",
4343
"@fluentui/react-popover": "^9.14.3",
44-
"@fluentui/react-portal": "^9.8.13",
4544
"@fluentui/react-positioning": "^9.22.2",
4645
"@fluentui/react-progress": "^9.5.2",
4746
"@fluentui/react-provider": "^9.22.17",
@@ -61,6 +60,7 @@
6160
"@fluentui/react-tags": "^9.9.1",
6261
"@fluentui/react-textarea": "^9.7.3",
6362
"@fluentui/react-toolbar": "^9.8.1",
63+
"@fluentui/react-toast": "^9.7.18",
6464
"@fluentui/react-tooltip": "^9.10.2",
6565
"@fluentui/react-utilities": "^9.26.4",
6666
"@swc/helpers": "^0.5.1"
@@ -318,6 +318,12 @@
318318
"import": "./lib/textarea.js",
319319
"require": "./lib-commonjs/textarea.js"
320320
},
321+
"./toast": {
322+
"types": "./dist/toast.d.ts",
323+
"node": "./lib-commonjs/toast.js",
324+
"import": "./lib/toast.js",
325+
"require": "./lib-commonjs/toast.js"
326+
},
321327
"./toggle-button": {
322328
"types": "./dist/toggle-button.d.ts",
323329
"node": "./lib-commonjs/toggle-button.js",
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
'use client';
2+
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';
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+
};
19+
20+
export type AriaLiveProps = {
21+
announceRef: React.Ref<ToastAnnounce>;
22+
};
23+
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+
});
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+
);
83+
};
84+
85+
AriaLive.displayName = 'AriaLive';
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { AriaLive } from './AriaLive';
2+
export type { AriaLiveProps } from './AriaLive';

0 commit comments

Comments
 (0)