diff --git a/change/@fluentui-react-headless-components-preview-86a2a1c9-dfd8-4e9e-8286-6567597a6749.json b/change/@fluentui-react-headless-components-preview-86a2a1c9-dfd8-4e9e-8286-6567597a6749.json new file mode 100644 index 00000000000000..e29a1d2a83ef5f --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-86a2a1c9-dfd8-4e9e-8286-6567597a6749.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add headless Toast, ToastTitle, ToastBody, ToastFooter, ToastContainer, and Toaster components using the Popover API", + "packageName": "@fluentui/react-headless-components-preview", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-toast-base-hooks.json b/change/@fluentui-react-toast-base-hooks.json new file mode 100644 index 00000000000000..9504d22cb2bb45 --- /dev/null +++ b/change/@fluentui-react-toast-base-hooks.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add useToastBase_unstable, useToastTitleBase_unstable, and useToastBodyBase_unstable hooks; export useToaster, ToastData, ToasterId, ToastImperativeRef, ToastChangeData, ToastChangeHandler, DispatchToastOptions, UpdateToastOptions from public API", + "packageName": "@fluentui/react-toast", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js b/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js index 14b87aef5c243b..1cd4d7ddc584d9 100644 --- a/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js +++ b/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js @@ -29,6 +29,7 @@ import * as Spinner from '@fluentui/react-headless-components-preview/spinner'; import * as Switch from '@fluentui/react-headless-components-preview/switch'; import * as TabList from '@fluentui/react-headless-components-preview/tab-list'; import * as Textarea from '@fluentui/react-headless-components-preview/textarea'; +import * as Toast from '@fluentui/react-headless-components-preview/toast'; import * as ToggleButton from '@fluentui/react-headless-components-preview/toggle-button'; import * as Toolbar from '@fluentui/react-headless-components-preview/toolbar'; import * as Tooltip from '@fluentui/react-headless-components-preview/tooltip'; @@ -65,6 +66,7 @@ console.log({ Switch, TabList, Textarea, + Toast, ToggleButton, Toolbar, Tooltip, diff --git a/packages/react-components/react-headless-components-preview/library/etc/toast.api.md b/packages/react-components/react-headless-components-preview/library/etc/toast.api.md new file mode 100644 index 00000000000000..673cbd8305a48e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/toast.api.md @@ -0,0 +1,180 @@ +## API Report File for "@fluentui/react-headless-components-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { Announce } from '@fluentui/react-toast'; +import type { ComponentProps } from '@fluentui/react-utilities'; +import type { ComponentState } from '@fluentui/react-utilities'; +import { DispatchToastOptions } from '@fluentui/react-toast'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import * as React_2 from 'react'; +import { renderToastFooter_unstable as renderToastFooter } from '@fluentui/react-toast'; +import type { Slot } from '@fluentui/react-utilities'; +import { ToastBodyBaseProps as ToastBodyProps } from '@fluentui/react-toast'; +import { ToastBodySlots } from '@fluentui/react-toast'; +import { ToastBodyBaseState as ToastBodyState } from '@fluentui/react-toast'; +import { ToastChangeData } from '@fluentui/react-toast'; +import { ToastChangeHandler } from '@fluentui/react-toast'; +import type { ToastContainerContextValue } from '@fluentui/react-toast'; +import type { ToastData } from '@fluentui/react-toast'; +import { ToasterId } from '@fluentui/react-toast'; +import { ToasterProps } from '@fluentui/react-toast'; +import { ToasterState } from '@fluentui/react-toast'; +import { ToastFooterProps } from '@fluentui/react-toast'; +import { ToastFooterSlots } from '@fluentui/react-toast'; +import { ToastFooterState } from '@fluentui/react-toast'; +import { ToastId } from '@fluentui/react-toast'; +import { ToastImperativeRef } from '@fluentui/react-toast'; +import { ToastIntent } from '@fluentui/react-toast'; +import { ToastPoliteness } from '@fluentui/react-toast'; +import { ToastPosition } from '@fluentui/react-toast'; +import { ToastBaseProps as ToastProps } from '@fluentui/react-toast'; +import { ToastSlots } from '@fluentui/react-toast'; +import { ToastBaseState as ToastState } from '@fluentui/react-toast'; +import { ToastStatus } from '@fluentui/react-toast'; +import { ToastTitleBaseProps as ToastTitleProps } from '@fluentui/react-toast'; +import { ToastTitleSlots } from '@fluentui/react-toast'; +import { ToastTitleBaseState as ToastTitleState } from '@fluentui/react-toast'; +import { UpdateToastOptions } from '@fluentui/react-toast'; +import { useToastBodyBase_unstable as useToastBody } from '@fluentui/react-toast'; +import { useToastContainerContext } from '@fluentui/react-toast'; +import { useToastController } from '@fluentui/react-toast'; +import { useToastFooter_unstable as useToastFooter } from '@fluentui/react-toast'; +import { useToastTitleBase_unstable as useToastTitle } from '@fluentui/react-toast'; + +export { DispatchToastOptions } + +// @public +export const renderToast: (state: ToastState) => JSXElement; + +// @public (undocumented) +export const renderToastBody: (state: ToastBodyState) => JSXElement; + +// @public (undocumented) +export const renderToastContainer: (state: ToastContainerState, contextValues: ToastContainerContextValues) => JSXElement; + +// @public +export const renderToaster: (state: ToasterState) => JSXElement; + +export { renderToastFooter } + +// @public (undocumented) +export const renderToastTitle: (state: ToastTitleState) => JSXElement; + +// @public (undocumented) +export const Toast: ForwardRefComponent; + +// @public (undocumented) +export const ToastBody: ForwardRefComponent; + +export { ToastBodyProps } + +export { ToastBodySlots } + +export { ToastBodyState } + +export { ToastChangeData } + +export { ToastChangeHandler } + +// @public (undocumented) +export const ToastContainer: ForwardRefComponent; + +export { ToastContainerContextValue } + +// @public (undocumented) +export type ToastContainerProps = Omit>, 'content'> & ToastData & { + visible: boolean; + tryRestoreFocus: () => void; + announce?: Announce; +}; + +// @public (undocumented) +export type ToastContainerSlots = { + root: NonNullable>; +}; + +// @public (undocumented) +export type ToastContainerState = ComponentState & Pick & Pick & { + running: boolean; + nodeRef: React_2.Ref; +}; + +// @public +export const Toaster: { + (props: ToasterProps): JSXElement; + displayName: string; +}; + +export { ToasterId } + +export { ToasterProps } + +export { ToasterState } + +// @public (undocumented) +export const ToastFooter: ForwardRefComponent; + +export { ToastFooterProps } + +export { ToastFooterSlots } + +export { ToastFooterState } + +export { ToastId } + +export { ToastImperativeRef } + +export { ToastIntent } + +export { ToastPoliteness } + +export { ToastPosition } + +export { ToastProps } + +export { ToastSlots } + +export { ToastState } + +export { ToastStatus } + +// @public (undocumented) +export const ToastTitle: ForwardRefComponent; + +export { ToastTitleProps } + +export { ToastTitleSlots } + +export { ToastTitleState } + +export { UpdateToastOptions } + +// @public (undocumented) +export const useToast: (props: ToastProps, ref: React_2.Ref) => ToastState; + +export { useToastBody } + +// @public (undocumented) +export const useToastContainer: (props: ToastContainerProps, ref: React_2.Ref) => ToastContainerState; + +export { useToastContainerContext } + +// @public (undocumented) +export const useToastContainerContextValues: (state: ToastContainerState) => ToastContainerContextValues; + +export { useToastController } + +// @public +export const useToaster: (props: ToasterProps) => ToasterState; + +export { useToastFooter } + +export { useToastTitle } + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-headless-components-preview/library/package.json b/packages/react-components/react-headless-components-preview/library/package.json index 2f6af185720d55..1f4a2b59cddfe6 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -58,6 +58,7 @@ "@fluentui/react-tags": "^9.8.1", "@fluentui/react-textarea": "^9.7.2", "@fluentui/react-toolbar": "^9.8.0", + "@fluentui/react-toast": "^9.7.17", "@fluentui/react-tooltip": "^9.10.1", "@fluentui/react-utilities": "^9.26.3", "@swc/helpers": "^0.5.1" @@ -261,6 +262,12 @@ "import": "./lib/textarea.js", "require": "./lib-commonjs/textarea.js" }, + "./toast": { + "types": "./dist/toast.d.ts", + "node": "./lib-commonjs/toast.js", + "import": "./lib/toast.js", + "require": "./lib-commonjs/toast.js" + }, "./toggle-button": { "types": "./dist/toggle-button.d.ts", "node": "./lib-commonjs/toggle-button.js", 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 new file mode 100644 index 00000000000000..d630a5592d9e55 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/AriaLive/AriaLive.tsx @@ -0,0 +1,87 @@ +'use client'; + +import * as React from 'react'; +import { createPriorityQueue, useEventCallback, useTimeout } from '@fluentui/react-utilities'; +import type { Announce, AnnounceOptions, LiveMessage } 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)', +}; + +export type AriaLiveProps = { + announceRef: React.Ref; +}; + +/** + * Headless aria-live announcer. + * + * 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: AnnounceOptions) => { + const { politeness } = options; + if (message === currentMessage?.message) { + return; + } + const liveMessage: LiveMessage = { message, politeness, createdAt: order.current++ }; + if (!currentMessage) { + setCurrentMessage(liveMessage); + } else { + messageQueue.enqueue(liveMessage); + } + }); + + 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} +
+ + ); +}; + +AriaLive.displayName = 'AriaLive'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/AriaLive/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/AriaLive/index.ts new file mode 100644 index 00000000000000..d5d42dd25568db --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/AriaLive/index.ts @@ -0,0 +1,2 @@ +export { AriaLive } from './AriaLive'; +export type { AriaLiveProps } from './AriaLive'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.cy.tsx new file mode 100644 index 00000000000000..ee2d152189eae3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.cy.tsx @@ -0,0 +1,371 @@ +import * as React from 'react'; +import { mount as mountBase } from '@fluentui/scripts-cypress'; +import type { JSXElement } from '@fluentui/react-utilities'; + +import { Toaster, Toast, ToastTitle, useToastController } from '.'; +import { Provider } from '../Provider'; + +/** + * Selectors used by the headless tests. Unlike the styled v9 layer, the headless + * Toast does not emit Griffel class names — we target structural roles and the + * `data-intent` attribute the headless `Toast` adds to its root. + */ +const TOAST_CONTAINER = '[role="listitem"]'; +const TOAST = '[data-intent]'; + +const mount = (element: JSXElement) => + mountBase( + +
{element}
+
, + { + strict: false, + }, + ); + +describe('Toast (headless)', () => { + it('should dispatch toast', () => { + const Example = () => { + const { dispatchToast } = useToastController(); + const onClick = () => + dispatchToast( + + This is a toast + , + ); + + return ( + <> + + + + ); + }; + + mount(); + cy.get('button').click().get(TOAST).should('exist'); + }); + + it('should dismiss toast', () => { + const Example = () => { + const toastId = 'foo'; + const { dispatchToast, dismissToast } = useToastController(); + const makeToast = () => + dispatchToast( + + This is a toast + , + { toastId, timeout: -1 }, + ); + const removeToast = () => dismissToast(toastId); + + return ( + <> + + + + + ); + }; + + mount(); + cy.get('#make').click().get(TOAST).should('exist'); + cy.get('#dismiss').click().get(TOAST).should('not.exist'); + }); + + it('should dismiss all toasts', () => { + const Example = () => { + const { dispatchToast, dismissAllToasts } = useToastController(); + const makeToast = () => { + for (let i = 0; i < 5; i++) { + dispatchToast( + + This is a toast + , + { timeout: -1 }, + ); + } + }; + + return ( + <> + + + + + ); + }; + + mount(); + cy.get('#make').click().get(TOAST).should('have.length', 5); + cy.get('#dismiss').click().get(TOAST).should('not.exist'); + }); + + it('should play and pause toast', () => { + const Example = () => { + const toastId = 'foo'; + const { dispatchToast, playToast, pauseToast } = useToastController(); + const makeToast = () => + dispatchToast( + + This is a toast + , + { toastId, timeout: 3000 }, + ); + + return ( + <> + + + + + + ); + }; + + mount(); + cy.get('#make').click().get(TOAST).should('exist'); + cy.get('#pause').click().wait(1000).get(TOAST).should('exist'); + cy.get('#play').click().get(TOAST).should('not.exist'); + }); + + it('should update toast content', () => { + const Example = () => { + const toastId = 'foo'; + const { dispatchToast, updateToast } = useToastController(); + const makeToast = () => + dispatchToast( + + This is a toast + , + { timeout: -1, toastId }, + ); + const update = () => + updateToast({ + content: ( + + Foo + + ), + toastId, + }); + + return ( + <> + + + + + ); + }; + + mount(); + cy.get('#make').click().get(TOAST).should('exist'); + cy.get('#update').click().get('body').contains('Foo'); + }); + + it('should pause auto-dismiss on hover', () => { + const Example = () => { + const { dispatchToast } = useToastController(); + const makeToast = () => + dispatchToast( + + This is a toast + , + { timeout: 3000, pauseOnHover: true }, + ); + + return ( + <> + + + + ); + }; + + mount(); + cy.get('#make').click().get(TOAST).trigger('mouseenter').wait(700).get(TOAST).should('exist'); + }); + + it('should follow lifecycle', () => { + const Example = () => { + const { dispatchToast } = useToastController(); + const [log, setLog] = React.useState([]); + const makeToast = () => + dispatchToast( + + This is a toast + , + { timeout: 500, onStatusChange: (_, data) => setLog(s => [...s, data.status]) }, + ); + + return ( + <> + +
    + {log.map((msg, i) => ( +
  • {msg}
  • + ))} +
+ + + ); + }; + + mount(); + cy.get('#make').realClick(); + cy.get('li').should('have.length.at.least', 3); + cy.get('li').eq(0).should('have.text', 'queued'); + cy.get('li').eq(1).should('have.text', 'visible'); + cy.get('li').last().should('have.text', 'unmounted'); + }); + + it('should focus most recent toast with shortcut', () => { + const Example = () => { + const { dispatchToast } = useToastController(); + const makeToast = () => { + dispatchToast( + + This is a toast + , + { timeout: -1 }, + ); + dispatchToast( + + This is a toast + , + { timeout: -1, root: { id: 'most-recent' } }, + ); + }; + + return ( + <> + + e.ctrlKey && e.key === 'm' }} /> + + ); + }; + + mount(); + cy.get('#make').click().get('#most-recent').should('exist'); + cy.get('body').type('{ctrl+m}'); + cy.get('#most-recent').should('be.focused'); + }); + + it('should dismiss toast with Delete and restore focus to the next visible toast', () => { + const Example = () => { + const { dispatchToast } = useToastController(); + const makeToast = () => { + dispatchToast( + + This is a toast + , + { timeout: -1 }, + ); + }; + + return ( + <> + + e.ctrlKey && e.key === 'm' }} /> + + ); + }; + + mount(); + cy.get('#make').click().click().click(); + cy.get(TOAST_CONTAINER).should('have.length', 3); + cy.get('body').type('{ctrl+m}'); + cy.focused().should('have.attr', 'role', 'listitem').realPress('Delete'); + cy.get(TOAST_CONTAINER).should('have.length', 2); + cy.focused().should('have.attr', 'role', 'listitem').realPress('Delete'); + cy.get(TOAST_CONTAINER).should('have.length', 1); + cy.focused().should('have.attr', 'role', 'listitem').realPress('Delete'); + cy.get(TOAST_CONTAINER).should('not.exist'); + cy.get('#make').should('be.focused'); + }); + + it('should dismiss all toasts with Escape and restore focus', () => { + const Example = () => { + const { dispatchToast } = useToastController(); + const makeToast = () => { + dispatchToast( + + This is a toast + , + { timeout: -1 }, + ); + }; + + return ( + <> + + e.ctrlKey && e.key === 'm' }} /> + + ); + }; + + mount(); + cy.get('#make').click().click().click(); + cy.get(TOAST_CONTAINER).should('have.length', 3); + cy.get('body').type('{ctrl+m}'); + cy.focused().should('have.attr', 'role', 'listitem').realPress('Escape'); + cy.get(TOAST_CONTAINER).should('not.exist'); + cy.get('#make').should('be.focused'); + }); + + it('should render toasts inline (no Portal) when inline=true', () => { + const Example = () => { + const { dispatchToast } = useToastController(); + const onClick = () => + dispatchToast( + + This is a toast + , + { timeout: 100000 }, + ); + + return ( + <> + +
+ +
+ + ); + }; + + mount(); + cy.get('button').click(); + cy.get(`#container ${TOAST}`).should('exist'); + cy.get(`[data-portal-node] ${TOAST}`).should('not.exist'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.test.tsx new file mode 100644 index 00000000000000..da620c438fca11 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.test.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { isConformant } from '../../testing/isConformant'; +import { Toast } from './Toast'; +import { ToastContainer } from './ToastContainer'; +import type { ToastContainerProps, ToastImperativeRef } from './'; + +const createToastContainerWrapper = + (props: Partial) => + ({ children }: React.PropsWithChildren) => { + const imperativeRef = React.useRef({ + focus: jest.fn(), + play: jest.fn(), + pause: jest.fn(), + }); + + return ( + + {children} + + ); + }; + +describe('Toast', () => { + isConformant({ + Component: Toast, + displayName: 'Toast', + }); + + it('renders children', () => { + const { getByTestId } = render(Default Toast, { + wrapper: createToastContainerWrapper({}), + }); + + const toast = getByTestId('toast'); + + expect(toast).toHaveTextContent('Default Toast'); + expect(toast).toHaveAttribute('data-intent', 'info'); + }); + + it('renders children with error intent', () => { + const { getByTestId } = render(Error Toast, { + wrapper: createToastContainerWrapper({ intent: 'error' }), + }); + + const toast = getByTestId('toast'); + + expect(toast).toHaveTextContent('Error Toast'); + expect(toast).toHaveAttribute('data-intent', 'error'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.tsx new file mode 100644 index 00000000000000..91de01ae6c90aa --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.tsx @@ -0,0 +1,13 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ToastProps } from './Toast.types'; +import { useToast } from './useToast'; +import { renderToast } from './renderToast'; + +export const Toast: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useToast(props, ref); + return renderToast(state); +}); +Toast.displayName = 'Toast'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.types.ts new file mode 100644 index 00000000000000..d02a4161d2d042 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.types.ts @@ -0,0 +1 @@ +export type { ToastSlots, ToastBaseProps as ToastProps, ToastBaseState as ToastState } from '@fluentui/react-toast'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/ToastBody.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/ToastBody.tsx new file mode 100644 index 00000000000000..a0c5327e0b5175 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/ToastBody.tsx @@ -0,0 +1,13 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ToastBodyProps } from './ToastBody.types'; +import { useToastBody } from './useToastBody'; +import { renderToastBody } from './renderToastBody'; + +export const ToastBody: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useToastBody(props, ref); + return renderToastBody(state); +}); +ToastBody.displayName = 'ToastBody'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/ToastBody.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/ToastBody.types.ts new file mode 100644 index 00000000000000..13ba2c9c2702c4 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/ToastBody.types.ts @@ -0,0 +1,5 @@ +export type { + ToastBodyBaseProps as ToastBodyProps, + ToastBodyBaseState as ToastBodyState, + ToastBodySlots, +} from '@fluentui/react-toast'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/index.ts new file mode 100644 index 00000000000000..5e0c19a1304098 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/index.ts @@ -0,0 +1,4 @@ +export { ToastBody } from './ToastBody'; +export { renderToastBody } from './renderToastBody'; +export { useToastBody } from './useToastBody'; +export type { ToastBodyProps, ToastBodyState, ToastBodySlots } from './ToastBody.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/renderToastBody.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/renderToastBody.ts new file mode 100644 index 00000000000000..1adecafed2812d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/renderToastBody.ts @@ -0,0 +1,6 @@ +import { renderToastBody_unstable } from '@fluentui/react-toast'; +import type { JSXElement } from '@fluentui/react-utilities'; + +import type { ToastBodyState } from './ToastBody.types'; + +export const renderToastBody = renderToastBody_unstable as (state: ToastBodyState) => JSXElement; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/useToastBody.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/useToastBody.ts new file mode 100644 index 00000000000000..1c3a72ede7823f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/useToastBody.ts @@ -0,0 +1 @@ +export { useToastBodyBase_unstable as useToastBody } from '@fluentui/react-toast'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/ToastContainer.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/ToastContainer.test.tsx new file mode 100644 index 00000000000000..8c7b3e36b594f3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/ToastContainer.test.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { resetIdsForTests } from '@fluentui/react-utilities'; +import { isConformant } from '../../../testing/isConformant'; +import type { ToastContainerProps } from './ToastContainer.types'; +import { ToastContainer } from './ToastContainer'; + +const defaultToastContainerProps: ToastContainerProps = { + announce: () => null, + close: () => null, + data: {}, + pauseOnHover: false, + pauseOnWindowBlur: false, + politeness: 'polite', + remove: () => null, + timeout: -1, + intent: undefined, + updateId: 0, + visible: true, + imperativeRef: { current: null }, + tryRestoreFocus: () => null, + order: 0, + content: '', + onStatusChange: () => null, + position: 'bottom-end', + toastId: 'toast-id', + priority: 0, + toasterId: 'toaster-id', +}; + +describe('ToastContainer', () => { + beforeEach(() => { + jest.useRealTimers(); + resetIdsForTests(); + }); + + isConformant({ + Component: ToastContainer, + displayName: 'ToastContainer', + requiredProps: defaultToastContainerProps, + disabledTests: [ + // Callback argument signature includes toast metadata from ToastData. + 'consistent-callback-args', + // Headless ToastContainer has no static classnames object. + 'component-has-static-classnames-object', + 'make-styles-overrides-win', + // ToastContainer is exported from `toast.ts` rather than top-level `toast-container.ts`. + 'has-top-level-file-extra', + 'export-map-entry-exists', + ], + }); + + it('renders listitem semantics and generated accessible ids', () => { + const { getByRole } = render( + Default ToastContainer, + ); + + const toast = getByRole('listitem'); + + expect(toast).toHaveTextContent('Default ToastContainer'); + expect(toast).toHaveAttribute('aria-labelledby'); + expect(toast).toHaveAttribute('aria-describedby'); + }); + + it('announces on mount with default politeness', () => { + const announce = jest.fn(); + + render( + + ToastContainer + , + ); + + expect(announce).toHaveBeenCalledTimes(1); + expect(announce).toHaveBeenCalledWith('ToastContainer', { politeness: 'polite' }); + }); + + it('respects user root props from data.root', () => { + const className = 'custom-toast-root'; + const { container } = render( + + ToastContainer + , + ); + + expect(container.querySelector(`.${className}`)).not.toBeNull(); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/ToastContainer.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/ToastContainer.tsx new file mode 100644 index 00000000000000..dfde74c045ca00 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/ToastContainer.tsx @@ -0,0 +1,16 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ToastContainerProps } from './ToastContainer.types'; +import { useToastContainer } from './useToastContainer'; +import { useToastContainerContextValues } from './useToastContainerContextValues'; +import { renderToastContainer } from './renderToastContainer'; + +export const ToastContainer: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useToastContainer(props, ref); + const contextValues = useToastContainerContextValues(state); + return renderToastContainer(state, contextValues); +}); + +ToastContainer.displayName = 'ToastContainer'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/ToastContainer.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/ToastContainer.types.ts new file mode 100644 index 00000000000000..0b6a6982e0542c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/ToastContainer.types.ts @@ -0,0 +1,32 @@ +import type * as React from 'react'; +import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import type { Announce, ToastData } from '@fluentui/react-toast'; +import type { ToastContainerContextValue } from '@fluentui/react-toast'; + +export type { ToastContainerContextValue }; + +export type ToastContainerContextValues = { + toast: ToastContainerContextValue; +}; + +export type ToastContainerSlots = { + root: NonNullable>; +}; + +export type ToastContainerProps = Omit>, 'content'> & + ToastData & { + visible: boolean; + tryRestoreFocus: () => void; + /** + * Announcer used to narrate this toast's text content to screen readers. + * Supplied by the parent `Toaster`; consumers do not need to pass this directly. + */ + announce?: Announce; + }; + +export type ToastContainerState = ComponentState & + Pick & + Pick & { + running: boolean; + nodeRef: React.Ref; + }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/index.ts new file mode 100644 index 00000000000000..98cc812b136408 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/index.ts @@ -0,0 +1,11 @@ +export { ToastContainer } from './ToastContainer'; +export { renderToastContainer } from './renderToastContainer'; +export { useToastContainer } from './useToastContainer'; +export { useToastContainerContextValues } from './useToastContainerContextValues'; +export type { + ToastContainerContextValues, + ToastContainerProps, + ToastContainerSlots, + ToastContainerState, + ToastContainerContextValue, +} from './ToastContainer.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/renderToastContainer.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/renderToastContainer.tsx new file mode 100644 index 00000000000000..02428bd5ce4bcb --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/renderToastContainer.tsx @@ -0,0 +1,20 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ + +import { assertSlots } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import { ToastContainerContextProvider } from '@fluentui/react-toast'; +import type { ToastContainerContextValues, ToastContainerSlots, ToastContainerState } from './ToastContainer.types'; + +export const renderToastContainer = ( + state: ToastContainerState, + contextValues: ToastContainerContextValues, +): JSXElement => { + assertSlots(state); + + return ( + + + + ); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/useToastContainer.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/useToastContainer.ts new file mode 100644 index 00000000000000..1c7834391c45e1 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/useToastContainer.ts @@ -0,0 +1,202 @@ +'use client'; + +import * as React from 'react'; +import { getIntrinsicElementProps, slot, useEventCallback, useId, useMergedRefs } from '@fluentui/react-utilities'; +import { useFluent_unstable } from '@fluentui/react-shared-contexts'; +import { Delete } from '@fluentui/keyboard-keys'; +import type { ToastPoliteness, ToastStatus } from '@fluentui/react-toast'; +import type { ToastContainerProps, ToastContainerState } from './ToastContainer.types'; + +const intentPolitenessMap: Record, ToastPoliteness> = { + success: 'assertive', + warning: 'assertive', + error: 'assertive', + info: 'polite', +}; + +export const useToastContainer = (props: ToastContainerProps, ref: React.Ref): ToastContainerState => { + const { + visible, + children, + close: closeProp, + remove, + updateId, + onStatusChange, + data, + timeout: timerTimeout = -1, + intent = 'info', + politeness, + pauseOnHover, + pauseOnWindowBlur, + imperativeRef, + tryRestoreFocus, + announce, + content: _content, + ...rest + } = props; + + const titleId = useId('toast-title'); + const bodyId = useId('toast-body'); + const toastRef = React.useRef(null); + const { targetDocument } = useFluent_unstable(); + const [running, setRunning] = React.useState(false); + const imperativePauseRef = React.useRef(false); + const focusedToastBeforeClose = React.useRef(false); + + const close = useEventCallback(() => { + const activeElement = targetDocument?.activeElement; + if (activeElement && toastRef.current?.contains(activeElement)) { + focusedToastBeforeClose.current = true; + } + + closeProp(); + }); + + const reportStatus = useEventCallback((status: ToastStatus) => onStatusChange?.(null, { status, ...props })); + const pause = useEventCallback(() => setRunning(false)); + const play = useEventCallback(() => { + if (imperativePauseRef.current) { + return; + } + + if (timerTimeout < 0) { + setRunning(true); + return; + } + + const activeElement = targetDocument?.activeElement; + const containsActive = !!(activeElement && toastRef.current?.contains(activeElement)); + if (!containsActive) { + setRunning(true); + } + }); + + React.useImperativeHandle(imperativeRef, () => ({ + focus: () => { + toastRef.current?.focus(); + }, + play: () => { + imperativePauseRef.current = false; + play(); + }, + pause: () => { + imperativePauseRef.current = true; + pause(); + }, + })); + + React.useEffect(() => { + return () => reportStatus('unmounted'); + }, [reportStatus]); + + React.useEffect(() => { + if (!targetDocument || !pauseOnWindowBlur) { + return; + } + + targetDocument.defaultView?.addEventListener('focus', play); + targetDocument.defaultView?.addEventListener('blur', pause); + return () => { + targetDocument.defaultView?.removeEventListener('focus', play); + targetDocument.defaultView?.removeEventListener('blur', pause); + }; + }, [targetDocument, pause, play, pauseOnWindowBlur]); + + React.useEffect(() => { + if (!visible) { + return; + } + + play(); + reportStatus('visible'); + }, [visible, play, reportStatus, updateId]); + + React.useEffect(() => { + if (!running || timerTimeout < 0 || !targetDocument?.defaultView) { + return; + } + + const timeoutId = targetDocument.defaultView.setTimeout(close, timerTimeout); + return () => targetDocument.defaultView?.clearTimeout(timeoutId); + }, [running, timerTimeout, targetDocument, close]); + + React.useEffect(() => { + if (!visible) { + reportStatus('dismissed'); + remove(); + } + }, [visible, remove, reportStatus]); + + React.useEffect(() => { + return () => { + if (focusedToastBeforeClose.current) { + focusedToastBeforeClose.current = false; + tryRestoreFocus(); + } + }; + }, [tryRestoreFocus]); + + React.useEffect(() => { + if (!visible || !announce) { + return; + } + const resolvedPoliteness = politeness ?? intentPolitenessMap[intent]; + announce(toastRef.current?.textContent ?? '', { politeness: resolvedPoliteness }); + }, [announce, politeness, intent, visible, updateId]); + + const userRootSlot = (data as { root?: React.HTMLAttributes } | undefined)?.root; + + const onMouseEnter = useEventCallback((e: React.MouseEvent) => { + if (pauseOnHover) { + pause(); + } + userRootSlot?.onMouseEnter?.(e); + }); + + const onMouseLeave = useEventCallback((e: React.MouseEvent) => { + if (pauseOnHover) { + play(); + } + userRootSlot?.onMouseLeave?.(e); + }); + + const onKeyDown = useEventCallback((e: React.KeyboardEvent) => { + if (e.key === Delete) { + e.preventDefault(); + close(); + } + + userRootSlot?.onKeyDown?.(e); + }); + + return { + components: { + root: 'div', + }, + root: slot.always( + getIntrinsicElementProps('div', { + ref: useMergedRefs(ref, toastRef) as React.Ref, + children, + tabIndex: 0, + role: 'listitem', + 'aria-labelledby': titleId, + 'aria-describedby': bodyId, + ...rest, + ...userRootSlot, + onMouseEnter, + onMouseLeave, + onKeyDown, + }), + { elementType: 'div' }, + ), + running, + visible, + remove, + close, + updateId, + nodeRef: toastRef, + intent, + titleId, + bodyId, + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/useToastContainerContextValues.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/useToastContainerContextValues.ts new file mode 100644 index 00000000000000..20d8b83ee81e88 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/useToastContainerContextValues.ts @@ -0,0 +1,20 @@ +'use client'; + +import * as React from 'react'; +import type { ToastContainerContextValues, ToastContainerState } from './ToastContainer.types'; + +export const useToastContainerContextValues = (state: ToastContainerState): ToastContainerContextValues => { + const { close, intent, titleId, bodyId } = state; + + const toast = React.useMemo( + () => ({ + close, + intent, + titleId, + bodyId, + }), + [close, intent, titleId, bodyId], + ); + + return { toast }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/ToastFooter.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/ToastFooter.tsx new file mode 100644 index 00000000000000..0fd0c8fc72d99a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/ToastFooter.tsx @@ -0,0 +1,13 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ToastFooterProps } from './ToastFooter.types'; +import { useToastFooter } from './useToastFooter'; +import { renderToastFooter } from './renderToastFooter'; + +export const ToastFooter: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useToastFooter(props, ref); + return renderToastFooter(state); +}); +ToastFooter.displayName = 'ToastFooter'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/ToastFooter.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/ToastFooter.types.ts new file mode 100644 index 00000000000000..2f37d5f8ffdc12 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/ToastFooter.types.ts @@ -0,0 +1 @@ +export type { ToastFooterProps, ToastFooterSlots, ToastFooterState } from '@fluentui/react-toast'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/index.ts new file mode 100644 index 00000000000000..8fd334f9f96449 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/index.ts @@ -0,0 +1,4 @@ +export { ToastFooter } from './ToastFooter'; +export { renderToastFooter } from './renderToastFooter'; +export { useToastFooter } from './useToastFooter'; +export type { ToastFooterProps, ToastFooterSlots, ToastFooterState } from './ToastFooter.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/renderToastFooter.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/renderToastFooter.ts new file mode 100644 index 00000000000000..e85df81c6a19b2 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/renderToastFooter.ts @@ -0,0 +1 @@ +export { renderToastFooter_unstable as renderToastFooter } from '@fluentui/react-toast'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/useToastFooter.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/useToastFooter.ts new file mode 100644 index 00000000000000..c00656749180cd --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/useToastFooter.ts @@ -0,0 +1 @@ +export { useToastFooter_unstable as useToastFooter } from '@fluentui/react-toast'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/ToastTitle.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/ToastTitle.tsx new file mode 100644 index 00000000000000..e322f008912448 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/ToastTitle.tsx @@ -0,0 +1,13 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ToastTitleProps } from './ToastTitle.types'; +import { useToastTitle } from './useToastTitle'; +import { renderToastTitle } from './renderToastTitle'; + +export const ToastTitle: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useToastTitle(props, ref); + return renderToastTitle(state); +}); +ToastTitle.displayName = 'ToastTitle'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/ToastTitle.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/ToastTitle.types.ts new file mode 100644 index 00000000000000..61d0ac9d9f39f1 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/ToastTitle.types.ts @@ -0,0 +1,5 @@ +export type { + ToastTitleBaseProps as ToastTitleProps, + ToastTitleBaseState as ToastTitleState, + ToastTitleSlots, +} from '@fluentui/react-toast'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/index.ts new file mode 100644 index 00000000000000..222539f40b00fc --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/index.ts @@ -0,0 +1,4 @@ +export { ToastTitle } from './ToastTitle'; +export { renderToastTitle } from './renderToastTitle'; +export { useToastTitle } from './useToastTitle'; +export type { ToastTitleProps, ToastTitleState, ToastTitleSlots } from './ToastTitle.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/renderToastTitle.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/renderToastTitle.ts new file mode 100644 index 00000000000000..cf8ed3720650c0 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/renderToastTitle.ts @@ -0,0 +1,6 @@ +import { renderToastTitle_unstable } from '@fluentui/react-toast'; +import type { JSXElement } from '@fluentui/react-utilities'; + +import type { ToastTitleState } from './ToastTitle.types'; + +export const renderToastTitle = renderToastTitle_unstable as (state: ToastTitleState) => JSXElement; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/useToastTitle.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/useToastTitle.ts new file mode 100644 index 00000000000000..28da64324363cd --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/useToastTitle.ts @@ -0,0 +1 @@ +export { useToastTitleBase_unstable as useToastTitle } from '@fluentui/react-toast'; 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 new file mode 100644 index 00000000000000..111180ba384e84 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.test.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { isConformant } from '../../../testing/isConformant'; +import { Toaster } from './Toaster'; + +describe('Toaster', () => { + isConformant({ + Component: Toaster, + displayName: 'Toaster', + disabledTests: [ + // Toaster is a wrapper that does not expose a single root element. + 'component-has-root-ref', + 'component-handles-ref', + 'component-handles-classname', + 'component-has-static-classnames-object', + 'make-styles-overrides-win', + // Toaster is exported from `toast.ts` rather than top-level `toaster.ts`. + 'has-top-level-file-extra', + 'export-map-entry-exists', + ], + }); + + it('renders aria-live regions by default', () => { + const { container } = render(); + + expect(container.querySelector('[aria-live="assertive"]')).not.toBeNull(); + expect(container.querySelector('[aria-live="polite"]')).not.toBeNull(); + }); + + it('does not render position containers when there are no toasts', () => { + const { container } = render(); + + expect(container.querySelector('[data-toaster-position]')).toBeNull(); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.tsx new file mode 100644 index 00000000000000..bf3f6029a6dee6 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.tsx @@ -0,0 +1,19 @@ +'use client'; + +import type { JSXElement } from '@fluentui/react-utilities'; +import type { ToasterProps } from './Toaster.types'; +import { useToaster } from './useToaster'; +import { renderToaster } from './renderToaster'; + +/** + * Toaster — subscribes to the event-driven toast state machine and + * renders toasts in a Portal with position-based slot containers. + * + * Pair with useToastController from @fluentui/react-toast to dispatch and dismiss toasts imperatively. + */ +export const Toaster = (props: ToasterProps): JSXElement => { + const state = useToaster(props); + return renderToaster(state); +}; + +Toaster.displayName = 'Toaster'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.types.ts new file mode 100644 index 00000000000000..6de05d98f5611b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.types.ts @@ -0,0 +1,13 @@ +import type { Slot } from '@fluentui/react-utilities'; + +export type { ToasterSlots, ToasterProps, ToasterState } from '@fluentui/react-toast'; + +export type ToasterSlotsInternal = { + root: Slot<'div'>; + bottomEnd?: Slot<'div'>; + bottomStart?: Slot<'div'>; + topEnd?: Slot<'div'>; + topStart?: Slot<'div'>; + top?: Slot<'div'>; + bottom?: Slot<'div'>; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/index.ts new file mode 100644 index 00000000000000..bd6856eb7f4603 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/index.ts @@ -0,0 +1,4 @@ +export { Toaster } from './Toaster'; +export { renderToaster } from './renderToaster'; +export { useToaster } from './useToaster'; +export type { ToasterSlots, ToasterProps, ToasterState } from './Toaster.types'; 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 new file mode 100644 index 00000000000000..7d03785068067d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/renderToaster.tsx @@ -0,0 +1,51 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ + +import { assertSlots } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import { Portal } from '@fluentui/react-portal'; +import type { ToasterSlotsInternal, ToasterState } from './Toaster.types'; +import { AriaLive } from '../AriaLive'; + +/** + * Render the position-based containers for the headless Toaster. + * + * Each container is a `
` that + * consumers can target with CSS to apply positioning/styling. When `inline` is + * true the slots render in-place; otherwise they render inside a Portal. + */ +export const renderToaster = (state: ToasterState): JSXElement => { + const { announceRef, renderAriaLive, inline, mountNode } = state; + assertSlots(state); + + const hasToasts = + !!state.bottomStart || !!state.bottomEnd || !!state.topStart || !!state.topEnd || !!state.top || !!state.bottom; + + const ariaLive = renderAriaLive ? : null; + const positionSlots = ( + <> + {state.bottom ? : null} + {state.bottomStart ? : null} + {state.bottomEnd ? : null} + {state.topStart ? : null} + {state.topEnd ? : null} + {state.top ? : null} + + ); + + if (inline) { + return ( + <> + {ariaLive} + {hasToasts ? positionSlots : null} + + ); + } + + return ( + <> + {ariaLive} + {hasToasts ? {positionSlots} : null} + + ); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/useToaster.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/useToaster.tsx new file mode 100644 index 00000000000000..ea75da1f2b07e3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/useToaster.tsx @@ -0,0 +1,103 @@ +'use client'; + +import * as React from 'react'; +import { useToaster as useToasterState, useToastAnnounce } from '@fluentui/react-toast'; +import type { Announce, ToastPosition } from '@fluentui/react-toast'; +import type { ToasterProps, ToasterState } from './Toaster.types'; +import type { ExtractSlotProps, Slot } from '@fluentui/react-utilities'; +import { getIntrinsicElementProps, slot, useEventCallback, useMergedRefs } from '@fluentui/react-utilities'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; +import { Escape } from '@fluentui/keyboard-keys'; +import { ToastContainer } from '../ToastContainer'; + +/** + * Create the state required to render the Toaster. + */ +export const useToaster = (props: ToasterProps): ToasterState => { + 'use no memo'; + + const { mountNode, inline = false, toasterId, offset, shortcuts, announce: announceProp, ...rest } = props; + + const { toastsToRender, isToastVisible, tryRestoreFocus, closeAllToasts } = useToasterState({ + toasterId, + offset, + shortcuts, + }); + + const announceRef = React.useRef(() => null); + const announce = React.useCallback((message, options) => announceRef.current(message, options), []); + const { dir } = useFluent(); + + const { onKeyDown: onKeyDownProp, ...rootProps } = slot.always( + getIntrinsicElementProps>>('div', rest), + { + elementType: 'div', + }, + ); + const onKeyDown = useEventCallback((e: React.KeyboardEvent) => { + if (e.key === Escape) { + e.preventDefault(); + closeAllToasts(); + } + onKeyDownProp?.(e); + }); + + const usePositionSlot = (toastPosition: ToastPosition) => { + const { announceToast, toasterRef } = useToastAnnounce(announceProp ?? announce); + + return slot.optional>>(toastsToRender.has(toastPosition) ? rootProps : null, { + defaultProps: { + ref: useMergedRefs(toasterRef), + children: toastsToRender.get(toastPosition)?.map(toast => ( + + {toast.content as React.ReactNode} + + )), + onKeyDown, + 'data-toaster-position': toastPosition, + role: 'list', + } as ExtractSlotProps>, + elementType: 'div', + }); + }; + + const bottomStart = usePositionSlot('bottom-start'); + const bottomEnd = usePositionSlot('bottom-end'); + const topStart = usePositionSlot('top-start'); + const topEnd = usePositionSlot('top-end'); + const top = usePositionSlot('top'); + const bottom = usePositionSlot('bottom'); + + return { + dir, + mountNode, + components: { + root: 'div', + bottomStart: 'div', + bottomEnd: 'div', + topStart: 'div', + topEnd: 'div', + top: 'div', + bottom: 'div', + }, + root: slot.always(rootProps, { elementType: 'div' }), + bottomStart, + bottomEnd, + topStart, + topEnd, + top, + bottom, + announceRef, + offset, + announce: announceProp ?? announce, + renderAriaLive: !announceProp, + inline, + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/index.ts new file mode 100644 index 00000000000000..6993862db1159e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/index.ts @@ -0,0 +1,43 @@ +// ─── Compound Toast content ────────────────────────────────────────────────── +export { Toast } from './Toast'; +export { renderToast } from './renderToast'; +export { useToast } from './useToast'; +export type { ToastProps, ToastState, ToastSlots } from './Toast.types'; + +// ─── Sub-components ─────────────────────────────────────────────────────────── +export { ToastTitle, renderToastTitle, useToastTitle } from './ToastTitle'; +export type { ToastTitleProps, ToastTitleState, ToastTitleSlots } from './ToastTitle'; + +export { ToastBody, renderToastBody, useToastBody } from './ToastBody'; +export type { ToastBodyProps, ToastBodyState, ToastBodySlots } from './ToastBody'; + +export { ToastFooter, renderToastFooter, useToastFooter } from './ToastFooter'; +export type { ToastFooterProps, ToastFooterSlots, ToastFooterState } from './ToastFooter'; + +// ─── Toaster DX (state-machine-driven) ─────────────────────────────────────── +export { Toaster, renderToaster, useToaster } from './Toaster'; +export type { ToasterProps, ToasterState } from './Toaster'; + +export { + ToastContainer, + renderToastContainer, + useToastContainer, + useToastContainerContextValues, +} from './ToastContainer'; +export type { ToastContainerProps, ToastContainerSlots, ToastContainerState } from './ToastContainer'; + +// ─── Re-exported from @fluentui/react-toast for import convenience ──────────── +export { useToastController } from '@fluentui/react-toast'; +export type { + ToastId, + ToasterId, + ToastIntent, + ToastStatus, + ToastPosition, + ToastPoliteness, + DispatchToastOptions, + UpdateToastOptions, + ToastImperativeRef, + ToastChangeHandler, + ToastChangeData, +} from '@fluentui/react-toast'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/renderToast.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/renderToast.tsx new file mode 100644 index 00000000000000..ad045ee391772b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/renderToast.tsx @@ -0,0 +1,15 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ + +import { assertSlots } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { ToastState, ToastSlots } from './Toast.types'; + +/** + * Render the final JSX of Toast + */ +export const renderToast = (state: ToastState): JSXElement => { + assertSlots(state); + + return ; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/useToast.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/useToast.ts new file mode 100644 index 00000000000000..369c1b02b09c95 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/useToast.ts @@ -0,0 +1,14 @@ +'use client'; + +import type * as React from 'react'; +import { useToastBase_unstable } from '@fluentui/react-toast'; + +import type { ToastProps, ToastState } from './Toast.types'; + +export const useToast = (props: ToastProps, ref: React.Ref): ToastState => { + const state = useToastBase_unstable(props, ref); + + Object.assign(state.root, { 'data-intent': state.intent }); + + return state; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/toast.ts b/packages/react-components/react-headless-components-preview/library/src/toast.ts new file mode 100644 index 00000000000000..5144f6a2b9f3ca --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/toast.ts @@ -0,0 +1,42 @@ +export { Toast, renderToast, useToast } from './components/Toast'; +export type { ToastProps, ToastState, ToastSlots, ToastIntent } from './components/Toast'; + +export { ToastTitle, renderToastTitle, useToastTitle } from './components/Toast/ToastTitle'; +export type { ToastTitleProps, ToastTitleState, ToastTitleSlots } from './components/Toast/ToastTitle'; + +export { ToastBody, renderToastBody, useToastBody } from './components/Toast/ToastBody'; +export type { ToastBodyProps, ToastBodyState, ToastBodySlots } from './components/Toast/ToastBody'; + +export { ToastFooter, renderToastFooter, useToastFooter } from './components/Toast/ToastFooter'; +export type { ToastFooterProps, ToastFooterSlots, ToastFooterState } from './components/Toast/ToastFooter'; + +export { Toaster, renderToaster, useToaster } from './components/Toast/Toaster'; +export type { ToasterProps, ToasterState } from './components/Toast/Toaster'; + +export { + ToastContainer, + renderToastContainer, + useToastContainer, + useToastContainerContextValues, +} from './components/Toast/ToastContainer'; +export type { + ToastContainerProps, + ToastContainerSlots, + ToastContainerState, + ToastContainerContextValue, +} from './components/Toast/ToastContainer'; + +// ─── Re-exported from @fluentui/react-toast ────────────────────────────────── +export { useToastController, useToastContainerContext } from '@fluentui/react-toast'; +export type { + ToastId, + ToasterId, + ToastStatus, + ToastPosition, + ToastPoliteness, + DispatchToastOptions, + UpdateToastOptions, + ToastImperativeRef, + ToastChangeHandler, + ToastChangeData, +} from '@fluentui/react-toast'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastCustomTimeout.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastCustomTimeout.stories.tsx new file mode 100644 index 00000000000000..acc43ce879f857 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastCustomTimeout.stories.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { + Toast, + Toaster, + ToastTitle, + useToastController, + useToastContainerContext, +} from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +const DismissButton = ({ children }: { children: React.ReactNode }) => { + const { close } = useToastContainerContext(); + return ( + + ); +}; + +export const CustomTimeout = (): React.ReactNode => { + const toasterId = React.useId(); + const { dispatchToast } = useToastController(toasterId); + const [timeout, setDismissTimeout] = React.useState(1000); + + const notify = () => + dispatchToast( + + Dismiss}> + {timeout >= 0 ? `Custom timeout ${timeout} ms` : 'Dismiss manually'} + + , + { timeout, intent: 'info' }, + ); + + return ( + <> + +
+ + +
+ + ); +}; + +CustomTimeout.parameters = { + docs: { + description: { + story: [ + 'Pass `timeout` (ms) to `dispatchToast` to control how long a toast stays visible.', + 'A negative value disables auto-dismiss — the user must close the toast manually.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDefault.stories.tsx new file mode 100644 index 00000000000000..4cd1e71f4e8ae6 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDefault.stories.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { + Toaster, + ToastTitle, + ToastBody, + ToastFooter, + useToastController, + Toast, +} from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +export const Default = (): React.ReactNode => { + const toasterId = React.useId(); + const { dispatchToast } = useToastController(toasterId); + + const notify = () => + dispatchToast( + + + Undo + + } + > + Email sent + + + This is a toast body + + + + + + , + { intent: 'success', timeout: 10_000 }, + ); + + return ( + <> + + + + ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDescription.md new file mode 100644 index 00000000000000..4ec4bfcbdad9b0 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDescription.md @@ -0,0 +1,35 @@ +A Toasts displays temporary content to the user. Toasts are rendered as a separate surface that can be dismissed by +user action or a application timeout. Toasts are typically used in the following situations: + +- Update the user on the status of a task +- Display the progress of a task +- Notify the user to take an action +- Notify the user of an application update +- Warn the user of an error + +The Fluent UI Toast component uses an **imperative** API. Once a Toaster has been rendered, you can use the +`useToastController` hook to get access to imperative methods to dispatch a Toast. The Toast component itself +is simply a layout component. + +> ⚠️ In order for notifications that use toast to be fully accessible, developers should make the notifications +> available on a permanent surface too. One of the ways to do this in an application is to implement a notification +> centre. + +For live region debugging help, check our [Debugging Notifications](./?path=/docs/concepts-developer-accessibility-notification-debugging--docs) docs page. + +## Best practices + +### Do + +- Configure defaults on the Toaster +- Use the toast for non-critical messages +- Let the user view the toast content in the application after the toast dismissed +- Create a keyboard shortcut to move focus to actionable toasts +- Use `politeness` setting to differentiate urgent and non-urgent messages + +### Don't + +- Render too many toasts at once +- Use different positions for toasts +- Use more than one Toaster in an application +- Make every toast have `assertive` politeness diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDismissAll.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDismissAll.stories.tsx new file mode 100644 index 00000000000000..727e08276e9cf9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDismissAll.stories.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { Toast, Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +export const DismissAll = (): React.ReactNode => { + const toasterId = React.useId(); + const { dispatchToast, dismissAllToasts } = useToastController(toasterId); + + const notify = () => + dispatchToast( + + This is a toast + , + { intent: 'info' }, + ); + + return ( + <> + +
+ + +
+ + ); +}; + +DismissAll.parameters = { + docs: { + description: { + story: 'The `dismissAllToasts` imperative API dismisses all rendered toasts at once.', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDismissToast.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDismissToast.stories.tsx new file mode 100644 index 00000000000000..f7f3d37d60406b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDismissToast.stories.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { Toast, Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +export const DismissToast = (): React.ReactNode => { + const toasterId = React.useId(); + const toastId = `dismiss-example-${toasterId}`; + const [unmounted, setUnmounted] = React.useState(true); + const { dispatchToast, dismissToast } = useToastController(toasterId); + + const notify = () => { + dispatchToast( + + This is a toast + , + { + toastId, + intent: 'success', + onStatusChange: (_, { status }) => setUnmounted(status === 'unmounted'), + }, + ); + setUnmounted(false); + }; + + return ( + <> + + + + ); +}; + +DismissToast.parameters = { + docs: { + description: { + story: [ + 'Toasts can be dismissed imperatively with `dismissToast`. Provide a `toastId` when dispatching', + 'so you can reference the same toast later. Use `onStatusChange` to track when the toast is', + 'fully removed (`status === "unmounted"`).', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDismissToastWithAction.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDismissToastWithAction.stories.tsx new file mode 100644 index 00000000000000..4824bac62d2778 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDismissToastWithAction.stories.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { + Toast, + Toaster, + ToastTitle, + useToastController, + useToastContainerContext, +} from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +/** + * A dismiss button that reads `close` from `ToastContainerContext`. + * This is the headless equivalent of the styled layer's `ToastTrigger`. + */ +const DismissButton = ({ children }: { children: React.ReactNode }) => { + const { close } = useToastContainerContext(); + return ( + + ); +}; + +export const DismissToastWithAction = (): React.ReactNode => { + const toasterId = React.useId(); + const { dispatchToast } = useToastController(toasterId); + + const notify = () => + dispatchToast( + + Dismiss}> + Dismiss me + + , + { intent: 'success' }, + ); + + return ( + <> + + + + ); +}; + +DismissToastWithAction.parameters = { + docs: { + description: { + story: [ + 'Use `useToastContainerContext()` to access `close` inside the dispatched content.', + 'Calling it closes the toast — this is the headless equivalent of the styled', + "layer's `ToastTrigger` component.", + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastIntent.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastIntent.stories.tsx new file mode 100644 index 00000000000000..a02ea045d40634 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastIntent.stories.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; +import { Toast, Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import type { ToastIntent } from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +const intentIcon: Record = { + success: '✓', + info: 'i', + warning: '⚠', + error: '✕', +}; + +const getIconBadgeClass = (intent: ToastIntent): string => { + switch (intent) { + case 'success': + return `${styles.iconBadge} ${styles.iconBadgeSuccess}`; + case 'info': + return `${styles.iconBadge} ${styles.iconBadgeInfo}`; + case 'warning': + return `${styles.iconBadge} ${styles.iconBadgeWarning}`; + case 'error': + return `${styles.iconBadge} ${styles.iconBadgeError}`; + default: + return styles.iconBadge; + } +}; + +const getIntentClass = (intent: ToastIntent): string => { + switch (intent) { + case 'success': + return `${styles.toast} ${styles.intentSuccess}`; + case 'info': + return `${styles.toast} ${styles.intentInfo}`; + case 'warning': + return `${styles.toast} ${styles.intentWarning}`; + case 'error': + return `${styles.toast} ${styles.intentError}`; + default: + return styles.toast; + } +}; + +export const Intent = (): React.ReactNode => { + const toasterId = React.useId(); + const { dispatchToast } = useToastController(toasterId); + const [intent, setIntent] = React.useState('success'); + + const notify = () => + dispatchToast( + + + Toast intent: {intent} + + , + { intent }, + ); + + return ( + <> + +
+
+ Intent +
+ {(['success', 'info', 'warning', 'error'] as ToastIntent[]).map(i => ( + + ))} +
+
+ +
+ + ); +}; + +Intent.parameters = { + docs: { + description: { + story: [ + 'The four standard intents — `success`, `info`, `warning`, `error` — are passed as a', + '`dispatchToast` option. The `intent` value is forwarded to `ToastContext` so that', + '`ToastTitle` can conditionally render the `media` slot. Fill that slot with any icon', + 'component you like; here we use a plain coloured ``.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastLifecycle.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastLifecycle.stories.tsx new file mode 100644 index 00000000000000..8ff0f8bd8aed05 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastLifecycle.stories.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import { + Toast, + Toaster, + ToastTitle, + ToastBody, + ToastFooter, + useToastController, +} from '@fluentui/react-headless-components-preview/toast'; +import type { ToastStatus } from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +export const ToastLifecycle = (): React.ReactNode => { + const toasterId = React.useId(); + const { dispatchToast } = useToastController(toasterId); + const [statusLog, setStatusLog] = React.useState<[number, ToastStatus][]>([]); + const [dismissed, setDismissed] = React.useState(true); + + const notify = () => { + dispatchToast( + + + Undo + + } + > + Email sent + + Subtitle} className={styles.bodyText}> + This is a toast body + + + + + + , + { + timeout: 1000, + intent: 'success', + onStatusChange: (_, { status: toastStatus }) => { + setDismissed(toastStatus === 'unmounted'); + setStatusLog(prev => [[Date.now(), toastStatus], ...prev]); + }, + }, + ); + }; + + return ( + <> + +
+
+ + +
+
+
Status log
+
+ {statusLog.map(([time, status], i) => { + const date = new Date(time); + return ( +
+ {date.toLocaleTimeString()} {status} +
+ ); + })} +
+
+
+ + ); +}; + +ToastLifecycle.parameters = { + docs: { + description: { + story: [ + 'The `onStatusChange` callback reports each lifecycle transition of a toast.', + 'Possible statuses: `queued`, `visible`, `hidden`, `unmounted`.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastMultipleToasters.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastMultipleToasters.stories.tsx new file mode 100644 index 00000000000000..f5ff3bc2ea3433 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastMultipleToasters.stories.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { Toast, Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +export const MultipleToasters = (): React.ReactNode => { + const firstId = React.useId(); + const secondId = React.useId(); + const [toaster, setToaster] = React.useState<'first' | 'second'>('first'); + const { dispatchToast: dispatchFirst } = useToastController(firstId); + const { dispatchToast: dispatchSecond } = useToastController(secondId); + + const notify = () => { + if (toaster === 'first') { + dispatchFirst( + + First toaster + , + { intent: 'info' }, + ); + } else { + dispatchSecond( + + Second toaster + , + { intent: 'info' }, + ); + } + }; + + return ( + <> + + +
+
+ Choose toaster +
+ + +
+
+ +
+ + ); +}; + +MultipleToasters.parameters = { + docs: { + description: { + story: [ + '> ⚠️ This use case is **not recommended** for most applications.', + '', + 'Pass a `toasterId` to each `Toaster` and to `useToastController` to support multiple', + 'independent Toasters on the same page.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastPauseAndPlay.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastPauseAndPlay.stories.tsx new file mode 100644 index 00000000000000..58f6bd33022507 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastPauseAndPlay.stories.tsx @@ -0,0 +1,63 @@ +import * as React from 'react'; +import { Toast, Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +export const PauseAndPlay = (): React.ReactNode => { + const toasterId = React.useId(); + const toastId = `pause-play-${toasterId}`; + const [unmounted, setUnmounted] = React.useState(true); + const [paused, setPaused] = React.useState(false); + const { pauseToast, playToast, dispatchToast } = useToastController(toasterId); + + const notify = () => { + dispatchToast( + + This is a toast + , + { + toastId, + intent: 'success', + onStatusChange: (_, { status }) => { + setUnmounted(status === 'unmounted'); + setPaused(false); + }, + }, + ); + setUnmounted(false); + }; + + const toggle = () => { + if (paused) { + playToast(toastId); + setPaused(false); + } else { + pauseToast(toastId); + setPaused(true); + } + }; + + return ( + <> + +
+ + +
+ + ); +}; + +PauseAndPlay.parameters = { + docs: { + description: { + story: [ + 'Use `pauseToast` and `playToast` from `useToastController` to imperatively pause and', + 'resume the dismiss timer. Both require the `toastId` used when dispatching.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastPauseOnHover.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastPauseOnHover.stories.tsx new file mode 100644 index 00000000000000..ecaa4fe9b8d6ca --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastPauseOnHover.stories.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { Toast, Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +export const PauseOnHover = (): React.ReactNode => { + const toasterId = React.useId(); + const { dispatchToast } = useToastController(toasterId); + + const notify = () => + dispatchToast( + + Hover me! + , + { pauseOnHover: true, intent: 'info' }, + ); + + return ( + <> + + + + ); +}; + +PauseOnHover.parameters = { + docs: { + description: { + story: [ + 'Pass `pauseOnHover: true` to `dispatchToast` to pause the dismiss timer while the', + 'mouse cursor is inside the toast. The timer resumes when the cursor leaves.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastPauseOnWindowBlur.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastPauseOnWindowBlur.stories.tsx new file mode 100644 index 00000000000000..014ae1a9170cd9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastPauseOnWindowBlur.stories.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { Toast, Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +export const PauseOnWindowBlur = (): React.ReactNode => { + const toasterId = React.useId(); + const { dispatchToast } = useToastController(toasterId); + + const notify = () => + dispatchToast( + + Click on another window! + , + { pauseOnWindowBlur: true, intent: 'info' }, + ); + + return ( + <> + + + + ); +}; + +PauseOnWindowBlur.parameters = { + docs: { + description: { + story: [ + 'Pass `pauseOnWindowBlur: true` to `dispatchToast` to pause the dismiss timer when', + 'the user switches to another window. The timer resumes when the window regains focus.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastProgressToast.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastProgressToast.stories.tsx new file mode 100644 index 00000000000000..22a5d92fcb7cd3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastProgressToast.stories.tsx @@ -0,0 +1,96 @@ +import * as React from 'react'; +import { + Toast, + Toaster, + ToastTitle, + ToastBody, + ToastFooter, + useToastController, + useToastContainerContext, +} from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +const intervalDelay = 100; +const intervalIncrement = 5; + +const DismissButton = ({ children }: { children: React.ReactNode }) => { + const { close } = useToastContainerContext(); + return ( + + ); +}; + +const DownloadProgressBar: React.FC<{ onDownloadEnd: () => void }> = ({ onDownloadEnd }) => { + const [value, setValue] = React.useState(100); + + React.useEffect(() => { + if (value > 0) { + const id = setTimeout(() => setValue(v => Math.max(v - intervalIncrement, 0)), intervalDelay); + return () => clearTimeout(id); + } + if (value === 0) { + onDownloadEnd(); + } + }, [value, onDownloadEnd]); + + return ; +}; + +export const ProgressToast = (): React.ReactNode => { + const toasterId = React.useId(); + const toastId = `progress-${toasterId}`; + const [unmounted, setUnmounted] = React.useState(true); + const { dispatchToast, dismissToast } = useToastController(toasterId); + + const dismiss = React.useCallback(() => dismissToast(toastId), [dismissToast, toastId]); + + const notify = () => + dispatchToast( + + Dismiss}> + Downloading file + + +

This may take a while

+ +
+ + + + +
, + { + intent: 'success', + timeout: -1, + toastId, + onStatusChange: (_, { status }) => setUnmounted(status === 'unmounted'), + }, + ); + + return ( + <> + + + + ); +}; + +ProgressToast.parameters = { + docs: { + description: { + story: [ + 'Toasts can host arbitrary content — here a CSS progress bar is rendered inside a toast.', + 'The toast uses `timeout: -1` so it never auto-dismisses; the progress bar calls', + '`dismissToast` imperatively once it completes.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastUpdateToast.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastUpdateToast.stories.tsx new file mode 100644 index 00000000000000..1f601557284bff --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastUpdateToast.stories.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { Toast, Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +export const UpdateToast = (): React.ReactNode => { + const toasterId = React.useId(); + const toastId = `update-example-${toasterId}`; + const [unmounted, setUnmounted] = React.useState(true); + const { dispatchToast, updateToast } = useToastController(toasterId); + + const notify = () => { + dispatchToast( + + This toast never closes + , + { + toastId, + intent: 'warning', + timeout: -1, + onStatusChange: (_, { status }) => setUnmounted(status === 'unmounted'), + }, + ); + setUnmounted(false); + }; + + const update = () => + updateToast({ + content: ( + + This toast will close soon + + ), + intent: 'success', + toastId, + timeout: 2000, + }); + + return ( + <> + + + + ); +}; + +UpdateToast.parameters = { + docs: { + description: { + story: [ + 'Use the `updateToast` imperative API to change a visible toast. You **must** provide a', + '`toastId` when dispatching. Almost all options — content, intent, timeout — can be updated.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toast/index.stories.tsx new file mode 100644 index 00000000000000..121c6f885cf4a6 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/index.stories.tsx @@ -0,0 +1,35 @@ +import { Toast, ToastTitle, ToastBody, ToastFooter, Toaster } from '@fluentui/react-headless-components-preview/toast'; + +import descriptionMd from './ToastDescription.md'; + +export { Default } from './ToastDefault.stories'; +export { Intent } from './ToastIntent.stories'; +export { DismissToast } from './ToastDismissToast.stories'; +export { DismissToastWithAction } from './ToastDismissToastWithAction.stories'; +export { UpdateToast } from './ToastUpdateToast.stories'; +export { DismissAll } from './ToastDismissAll.stories'; +export { CustomTimeout } from './ToastCustomTimeout.stories'; +export { PauseOnHover } from './ToastPauseOnHover.stories'; +export { PauseOnWindowBlur } from './ToastPauseOnWindowBlur.stories'; +export { PauseAndPlay } from './ToastPauseAndPlay.stories'; +export { ToastLifecycle } from './ToastLifecycle.stories'; +export { MultipleToasters } from './ToastMultipleToasters.stories'; +export { ProgressToast } from './ToastProgressToast.stories'; + +export default { + title: 'Components/Toast', + component: Toast, + subcomponents: { + ToastTitle, + ToastBody, + ToastFooter, + Toaster, + }, + parameters: { + docs: { + description: { + component: descriptionMd, + }, + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/toast.module.css b/packages/react-components/react-headless-components-preview/stories/src/Toast/toast.module.css new file mode 100644 index 00000000000000..a387c01cf8686c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/toast.module.css @@ -0,0 +1,533 @@ +.toaster { + position: fixed; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.toaster[data-toaster-position='bottom-end'] { + /* flex-direction: column-reverse; */ + bottom: 16px; + right: 16px; +} + +/* Toast card wrapper — white elevated surface with subtle border and shadow */ +.toast { + display: grid; + grid-template-columns: auto 1fr auto; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-4); + margin: unset; + margin-block-start: auto; + margin-inline-start: auto; + padding: 12px; + min-width: 280px; + max-width: 400px; + transition: box-shadow var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard); + animation: slideIn var(--duration-medium) var(--ease-emphasized); +} + +.toast:hover { + box-shadow: var(--shadow-5); +} + +/* Intent accent — left border color per intent */ +.intentSuccess { + border-left: 4px solid var(--success); +} + +.intentInfo { + border-left: 4px solid var(--info); +} + +.intentWarning { + border-left: 4px solid var(--warning); +} + +.intentError { + border-left: 4px solid var(--danger); +} + +/* Toast title — heading with optional action slot */ +.title { + display: flex; + grid-column-end: 3; + font-size: 14px; + font-weight: 600; + line-height: 20px; + color: var(--text); + word-break: break-word; +} + +/* Toast subtitle — smaller, muted text */ +.subtitle { + display: block; + grid-column-start: 2; + grid-column-end: 3; + padding-top: 4px; + font-size: 12px; + color: var(--text-soft); + line-height: 1.5; +} + +/* Toast body — main content area */ +.body, +.bodyText { + grid-column-start: 2; + grid-column-end: 3; + padding-top: 6px; + font-size: 14px; + line-height: 20px; + font-weight: 400; + color: var(--text-muted); + word-break: break-word; +} + +.body { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +/* Toast body text — regular body content */ +.bodyText { + margin: 0; +} + +/* Toast footer — action button container */ +.footer { + grid-column-start: 2; + grid-column-end: 3; + display: flex; + align-items: center; + gap: var(--space-3); + padding-top: 16px; + flex-wrap: wrap; +} + +/* Icon badge — circular badge for intent icon */ +.iconBadge { + display: flex; + align-items: center; + justify-content: center; + grid-column-end: 2; + margin-inline-end: var(--space-2); + width: 24px; + height: 24px; + border-radius: var(--radius-pill); + font-size: 12px; + font-weight: 700; + color: var(--text-on-accent); + flex-shrink: 0; + transition: transform var(--duration-fast) var(--ease-standard); +} + +.iconBadge:hover { + transform: scale(1.08); +} + +.iconBadgeSuccess { + background: var(--success); +} + +.iconBadgeInfo { + background: var(--info); +} + +.iconBadgeWarning { + background: var(--warning); +} + +.iconBadgeError { + background: var(--danger); +} + +/* Action button — link-style button for toast actions */ +.actionBtn { + display: flex; + align-items: flex-start; + grid-column-end: -1; + appearance: none; + background: transparent; + border: 0; + padding: var(--space-1) var(--space-2); + margin: 0 calc(var(--space-1) * -1); + font-size: 12px; + font-weight: 600; + color: var(--accent); + cursor: pointer; + text-decoration: none; + transition: all var(--duration-fast) var(--ease-standard); + font-family: inherit; + border-radius: var(--radius-xs); +} + +.actionBtn:hover { + color: var(--accent-strong); + text-decoration: underline; + background: var(--accent-soft); +} + +.actionBtn:active { + color: var(--accent-strong); + transform: scale(0.98); +} + +.actionBtn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.actionBtn:disabled { + opacity: 0.5; + cursor: not-allowed; + text-decoration: none; +} + +/* Dismiss button — text link for dismissing toasts */ +.dismissBtn { + appearance: none; + background: transparent; + border: 0; + padding: var(--space-1) 0; + margin: 0 calc(var(--space-1) * -1); + font-size: 12px; + font-weight: 600; + color: var(--accent); + cursor: pointer; + text-decoration: none; + transition: all var(--duration-fast) var(--ease-standard); + font-family: inherit; + border-radius: var(--radius-xs); +} + +.dismissBtn:hover { + color: var(--accent-strong); + text-decoration: underline; + background: var(--accent-soft); +} + +.dismissBtn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Primary button — solid color action button */ +.btnPrimary { + display: inline-flex; + align-items: center; + justify-content: center; + appearance: none; + background: var(--accent); + color: var(--accent-contrast); + border: 1px solid transparent; + border-radius: var(--radius-pill); + padding: 0 var(--space-3); + height: 32px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all var(--duration-fast) var(--ease-standard); + font-family: inherit; +} + +.btnPrimary:hover { + background: var(--accent-strong); + transform: translateY(-1px); +} + +.btnPrimary:active { + transform: translateY(0); +} + +.btnPrimary:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); +} + +.btnPrimary:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +/* Secondary button — light background */ +.btnSecondary { + display: inline-flex; + align-items: center; + justify-content: center; + appearance: none; + background: var(--surface-muted); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-pill); + padding: 0 var(--space-3); + height: 32px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all var(--duration-fast) var(--ease-standard); + font-family: inherit; +} + +.btnSecondary:hover { + background: var(--surface-sunken); + border-color: var(--border-strong); + transform: translateY(-1px); +} + +.btnSecondary:active { + transform: translateY(0); +} + +.btnSecondary:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); +} + +.btnSecondary:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +/* Trigger button — for triggering toasts */ +.triggerBtn { + display: inline-flex; + align-items: center; + justify-content: center; + appearance: none; + background: var(--bg-elev); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0 var(--space-3); + height: 32px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all var(--duration-fast) var(--ease-standard); + font-family: inherit; +} + +.triggerBtn:hover { + background: var(--surface-muted); + border-color: var(--border-strong); + transform: translateY(-1px); +} + +.triggerBtn:active { + transform: translateY(0); +} + +.triggerBtn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); +} + +.triggerBtn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +/* Controls wrapper — vertical flex for controls in demo */ +.controls { + display: flex; + flex-direction: column; + gap: var(--space-3); + width: 100%; +} + +/* Fieldset — for grouping related controls */ +.fieldset { + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--space-3); + font-size: 12px; +} + +/* Legend — label for fieldset */ +.legend { + padding-inline: var(--space-2); + color: var(--text-muted); + font-size: 12px; + font-weight: 500; +} + +/* Radio/checkbox options container */ +.options { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +/* Option label */ +.optionLabel { + display: flex; + align-items: center; + gap: var(--space-2); + cursor: pointer; + font-size: 12px; + color: var(--text); +} + +.optionLabel input { + cursor: pointer; +} + +/* Progress bar styling */ +.progressBar { + width: 100%; + height: 4px; + margin-top: var(--space-1); +} + +.progressBar::-webkit-progress-bar { + background: var(--surface-muted); + border-radius: var(--radius-pill); + height: 4px; +} + +.progressBar::-webkit-progress-value { + background: var(--accent); + border-radius: var(--radius-pill); + transition: width var(--duration-medium) linear; +} + +.progressBar::-moz-progress-bar { + background: var(--accent); + border-radius: var(--radius-pill); + transition: width var(--duration-medium) linear; +} + +/* Toast slide-in animation */ +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(24px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +/* Toast fade-out animation */ +@keyframes fadeOut { + to { + opacity: 0; + transform: translateX(24px); + } +} + +/* Demo container — wrapper for demo content */ +.demo { + display: flex; + flex-direction: column; + gap: var(--space-4); + width: 100%; + max-width: 100%; +} + +/* Demo row — horizontal flex for related items */ +.demoRow { + display: flex; + flex-direction: row; + gap: var(--space-3); + align-items: center; + flex-wrap: wrap; +} + +/* Toaster popover positioning — fixed in bottom-right corner with optimal spacing */ +.toasterPopover { + position: fixed; + inset: auto calc(var(--space-4) + var(--space-1)) calc(var(--space-4) + var(--space-1)) auto; + max-width: 420px; + padding: 0; + margin: 0; + border: none; + background: transparent; + pointer-events: none; + z-index: 9999; + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +/* Allow pointer events on toast children */ +.toasterPopover > * { + pointer-events: auto; +} + +/* Support for LTR/RTL — adjust inset-inline for RTL */ +@supports (inset-inline-start: 0) { + .toasterPopover { + inset-inline-end: calc(var(--space-4) + var(--space-1)); + inset-inline-start: auto; + inset-block-end: calc(var(--space-4) + var(--space-1)); + inset-block-start: auto; + } +} + +/* Timeout input wrapper — vertical flex for label and input */ +.timeoutInput { + display: flex; + flex-direction: column; + gap: var(--space-1); + font-size: 12px; + color: var(--text); +} + +.timeoutInput input { + width: 128px; + border-radius: var(--radius-md); + border: 1px solid var(--border); + padding: 0 var(--space-2); + font-size: 12px; +} + +.timeoutInputHint { + font-size: 11px; + color: var(--text-faint); +} + +/* Log container — flex wrapper for lifecycle status log */ +.logContainer { + display: flex; + gap: var(--space-4); +} + +.logSection { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.logViewer { + display: flex; + flex-direction: column; + gap: var(--space-1); + min-width: 200px; +} + +.logHeader { + background: var(--text); + color: var(--bg); + font-size: 10px; + font-weight: bold; + padding: var(--space-2); + width: fit-content; + border-radius: var(--radius-xs); +} + +.logContent { + overflow-y: auto; + border: 2px solid var(--text); + padding: var(--space-3); + height: 200px; + font-size: 11px; + font-family: var(--font-mono); +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/tooltip.module.css b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/tooltip.module.css index 0ebd94d9eafc6a..e3fa3b7ee42890 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/tooltip.module.css +++ b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/tooltip.module.css @@ -1,5 +1,6 @@ /* Tooltip content bubble — inverse surface (dark on light, light on dark) */ .content { + position: fixed; background: var(--bg-elev); color: var(--text); font-size: 12px; diff --git a/packages/react-components/react-toast/library/etc/react-toast.api.md b/packages/react-components/react-toast/library/etc/react-toast.api.md index f791dbd4d1e2e7..39c96c6f878382 100644 --- a/packages/react-components/react-toast/library/etc/react-toast.api.md +++ b/packages/react-components/react-toast/library/etc/react-toast.api.md @@ -17,12 +17,39 @@ import type { Slot } from '@fluentui/react-utilities'; import type { SlotClassNames } from '@fluentui/react-utilities'; import type { TriggerProps } from '@fluentui/react-utilities'; +// @public (undocumented) +export type Announce = (message: string, options: AnnounceOptions) => void; + +// @public (undocumented) +export type AnnounceOptions = { + politeness: AriaLivePoliteness; +}; + +// @public (undocumented) +export type AriaLivePoliteness = 'polite' | 'assertive'; + +// @public (undocumented) +export interface DispatchToastOptions extends Partial> { + // (undocumented) + root?: RootSlot; +} + +// @public (undocumented) +export type LiveMessage = { + message: string; + createdAt: number; + politeness: AriaLivePoliteness; +}; + // @public export const renderToast_unstable: (state: ToastState, contextValues: ToastContextValues) => JSXElement; // @public export const renderToastBody_unstable: (state: ToastBodyState) => JSXElement; +// @public +export const renderToastContainer_unstable: (state: ToastContainerState, contextValues: ToastContainerContextValues) => JSXElement; + // @public export const renderToaster_unstable: (state: ToasterState) => JSXElement; @@ -38,9 +65,21 @@ export const renderToastTrigger_unstable: (state: ToastTriggerState) => JSXEleme // @public export const Toast: ForwardRefComponent; +// @public +export type ToastBaseProps = Omit; + +// @public +export type ToastBaseState = Omit; + // @public export const ToastBody: ForwardRefComponent; +// @public +export type ToastBodyBaseProps = ToastBodyProps; + +// @public +export type ToastBodyBaseState = Omit; + // @public (undocumented) export const toastBodyClassNames: SlotClassNames; @@ -58,12 +97,46 @@ export type ToastBodyState = ComponentState & { backgroundAppearance: BackgroundAppearanceContextValue; }; +// @public (undocumented) +export interface ToastChangeData extends ToastOptions, Pick { + // (undocumented) + status: ToastStatus; +} + +// @public (undocumented) +export type ToastChangeHandler = (event: null, data: ToastChangeData) => void; + // @public (undocumented) export const toastClassNames: SlotClassNames; // @public (undocumented) export const toastContainerClassNames: SlotClassNames; +// @public (undocumented) +export const ToastContainerContextProvider: React_2.Provider; + +// @public (undocumented) +export type ToastContainerContextValue = { + close: () => void; + intent: ToastIntent | undefined; + bodyId: string; + titleId: string; +}; + +// @public +export type ToastContainerProps = Omit>, 'content'> & ToastData & { + visible: boolean; + announce: Announce; + intent: ToastIntent | undefined; + tryRestoreFocus: () => void; +}; + +// @public (undocumented) +export type ToastContainerSlots = { + root: NonNullable>; + timer: NonNullable>; +}; + // @public export type ToastContainerState = ComponentState & Pick & Pick & { transitionTimeout: number; @@ -76,18 +149,49 @@ export type ToastContainerState = ComponentState & Pick void; }; +// @public (undocumented) +export interface ToastData extends ToastOptions { + close: () => void; + // (undocumented) + imperativeRef: React_2.RefObject; + order: number; + remove: () => void; + updateId: number; +} + // @public export const Toaster: React_2.FC; // @public (undocumented) export const toasterClassNames: SlotClassNames; +// @public (undocumented) +export type ToasterId = string; + +// @public (undocumented) +export interface ToasterOptions extends Pick { + // (undocumented) + limit?: number; + // (undocumented) + offset?: ToastOffset; + // (undocumented) + shortcuts?: ToasterShortcuts; + // (undocumented) + toasterId?: ToasterId; +} + // @public export type ToasterProps = Omit, 'children'> & Partial & Pick & { announce?: Announce; inline?: boolean; }; +// @public (undocumented) +export interface ToasterShortcuts { + // (undocumented) + focus: (e: KeyboardEvent) => boolean; +} + // @public (undocumented) export type ToasterSlots = { root: Slot<'div'>; @@ -120,6 +224,13 @@ export type ToastFooterState = ComponentState; // @public (undocumented) export type ToastId = string; +// @public (undocumented) +export type ToastImperativeRef = { + focus: () => void; + play: () => void; + pause: () => void; +}; + // @public (undocumented) export type ToastIntent = 'info' | 'success' | 'error' | 'warning'; @@ -154,6 +265,12 @@ export type ToastStatus = 'queued' | 'visible' | 'dismissed' | 'unmounted'; // @public export const ToastTitle: ForwardRefComponent; +// @public +export type ToastTitleBaseProps = ToastTitleProps; + +// @public +export type ToastTitleBaseState = Omit; + // @public (undocumented) export const toastTitleClassNames: SlotClassNames; @@ -188,15 +305,42 @@ export type ToastTriggerState = { children: React_2.ReactElement | null; }; +// @public (undocumented) +export interface UpdateToastOptions extends UpdateToastEventDetail { + // (undocumented) + root?: RootSlot; +} + // @public export const useToast_unstable: (props: ToastProps, ref: React_2.Ref) => ToastState; +// @public +export function useToastAnnounce(announce: Announce): { + announceToast: Announce; + toasterRef: React_2.RefCallback; +}; + +// @public +export const useToastBase_unstable: (props: ToastBaseProps, ref: React_2.Ref) => ToastBaseState; + // @public export const useToastBody_unstable: (props: ToastBodyProps, ref: React_2.Ref) => ToastBodyState; +// @public +export const useToastBodyBase_unstable: (props: ToastBodyBaseProps, ref: React_2.Ref) => ToastBodyBaseState; + // @public export const useToastBodyStyles_unstable: (state: ToastBodyState) => ToastBodyState; +// @public +export const useToastContainer_unstable: (props: ToastContainerProps, ref: React_2.Ref) => ToastContainerState; + +// @public (undocumented) +export const useToastContainerContext: () => ToastContainerContextValue; + +// @public (undocumented) +export function useToastContainerContextValues_unstable(state: ToastContainerState): ToastContainerContextValues; + // @public (undocumented) export function useToastController(toasterId?: ToasterId): { dispatchToast: (content: React_2.ReactNode, options?: DispatchToastOptions) => void; @@ -207,6 +351,16 @@ export function useToastController(toasterId?: ToasterId): { playToast: (toastId: ToastId) => void; }; +// @public (undocumented) +export function useToaster(options?: Partial): { + isToastVisible: (toastId: ToastId) => boolean; + toastsToRender: Map; + pauseAllToasts: () => void; + playAllToasts: () => void; + tryRestoreFocus: () => void; + closeAllToasts: () => void; +}; + // @public export const useToaster_unstable: (props: ToasterProps) => ToasterState; @@ -225,6 +379,9 @@ export const useToastStyles_unstable: (state: ToastState) => ToastState; // @public export const useToastTitle_unstable: (props: ToastTitleProps, ref: React_2.Ref) => ToastTitleState; +// @public +export const useToastTitleBase_unstable: (props: ToastTitleBaseProps, ref: React_2.Ref) => ToastTitleBaseState; + // @public export const useToastTitleStyles_unstable: (state: ToastTitleState) => ToastTitleState; diff --git a/packages/react-components/react-toast/library/src/Toast.ts b/packages/react-components/react-toast/library/src/Toast.ts index 13002c4a3b42e1..f4bbcc0f1991e4 100644 --- a/packages/react-components/react-toast/library/src/Toast.ts +++ b/packages/react-components/react-toast/library/src/Toast.ts @@ -1,8 +1,16 @@ -export type { ToastContextValues, ToastProps, ToastSlots, ToastState } from './components/Toast/index'; +export type { + ToastBaseProps, + ToastBaseState, + ToastContextValues, + ToastProps, + ToastSlots, + ToastState, +} from './components/Toast/index'; export { Toast, renderToast_unstable, toastClassNames, useToastStyles_unstable, + useToastBase_unstable, useToast_unstable, } from './components/Toast/index'; diff --git a/packages/react-components/react-toast/library/src/ToastBody.ts b/packages/react-components/react-toast/library/src/ToastBody.ts index 5ca80a167f3426..7e8fd78fecfe02 100644 --- a/packages/react-components/react-toast/library/src/ToastBody.ts +++ b/packages/react-components/react-toast/library/src/ToastBody.ts @@ -1,8 +1,15 @@ -export type { ToastBodyProps, ToastBodySlots, ToastBodyState } from './components/ToastBody/index'; +export type { + ToastBodyBaseProps, + ToastBodyBaseState, + ToastBodyProps, + ToastBodySlots, + ToastBodyState, +} from './components/ToastBody/index'; export { ToastBody, renderToastBody_unstable, toastBodyClassNames, useToastBodyStyles_unstable, + useToastBodyBase_unstable, useToastBody_unstable, } from './components/ToastBody/index'; diff --git a/packages/react-components/react-toast/library/src/ToastTitle.ts b/packages/react-components/react-toast/library/src/ToastTitle.ts index 6765d6a8bc3801..2fade006d768ef 100644 --- a/packages/react-components/react-toast/library/src/ToastTitle.ts +++ b/packages/react-components/react-toast/library/src/ToastTitle.ts @@ -1,8 +1,15 @@ -export type { ToastTitleProps, ToastTitleSlots, ToastTitleState } from './components/ToastTitle/index'; +export type { + ToastTitleBaseProps, + ToastTitleBaseState, + ToastTitleProps, + ToastTitleSlots, + ToastTitleState, +} from './components/ToastTitle/index'; export { ToastTitle, renderToastTitle_unstable, toastTitleClassNames, useToastTitleStyles_unstable, + useToastTitleBase_unstable, useToastTitle_unstable, } from './components/ToastTitle/index'; diff --git a/packages/react-components/react-toast/library/src/Toaster.ts b/packages/react-components/react-toast/library/src/Toaster.ts index 340bda3b7a4aa0..10baaa4b483039 100644 --- a/packages/react-components/react-toast/library/src/Toaster.ts +++ b/packages/react-components/react-toast/library/src/Toaster.ts @@ -5,4 +5,5 @@ export { toasterClassNames, useToasterStyles_unstable, useToaster_unstable, + useToastAnnounce, } from './components/Toaster/index'; diff --git a/packages/react-components/react-toast/library/src/components/Toast/Toast.types.ts b/packages/react-components/react-toast/library/src/components/Toast/Toast.types.ts index a68ab5e0b230b8..ab39f712ebf376 100644 --- a/packages/react-components/react-toast/library/src/components/Toast/Toast.types.ts +++ b/packages/react-components/react-toast/library/src/components/Toast/Toast.types.ts @@ -17,6 +17,11 @@ export type ToastProps = ComponentProps & { appearance?: BackgroundAppearanceContextValue; }; +/** + * Toast Props without design-only props. + */ +export type ToastBaseProps = Omit; + /** * State used in rendering Toast */ @@ -24,3 +29,8 @@ export type ToastState = ComponentState & { backgroundAppearance: BackgroundAppearanceContextValue; intent?: ToastIntent | undefined; }; + +/** + * State used in rendering Toast, without design-only state. + */ +export type ToastBaseState = Omit; diff --git a/packages/react-components/react-toast/library/src/components/Toast/index.ts b/packages/react-components/react-toast/library/src/components/Toast/index.ts index 1e9d2f2c992310..d1d5f897e33d06 100644 --- a/packages/react-components/react-toast/library/src/components/Toast/index.ts +++ b/packages/react-components/react-toast/library/src/components/Toast/index.ts @@ -1,5 +1,12 @@ export { Toast } from './Toast'; -export type { ToastContextValues, ToastProps, ToastSlots, ToastState } from './Toast.types'; +export type { + ToastBaseProps, + ToastBaseState, + ToastContextValues, + ToastProps, + ToastSlots, + ToastState, +} from './Toast.types'; export { renderToast_unstable } from './renderToast'; -export { useToast_unstable } from './useToast'; +export { useToastBase_unstable, useToast_unstable } from './useToast'; export { toastClassNames, useToastStyles_unstable } from './useToastStyles.styles'; diff --git a/packages/react-components/react-toast/library/src/components/Toast/useToast.ts b/packages/react-components/react-toast/library/src/components/Toast/useToast.ts index d65fafb1808cfa..4966606bd96cba 100644 --- a/packages/react-components/react-toast/library/src/components/Toast/useToast.ts +++ b/packages/react-components/react-toast/library/src/components/Toast/useToast.ts @@ -2,19 +2,16 @@ import type * as React from 'react'; import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; -import type { ToastProps, ToastState } from './Toast.types'; +import type { ToastBaseProps, ToastBaseState, ToastProps, ToastState } from './Toast.types'; import { useToastContainerContext } from '../../contexts/toastContainerContext'; /** - * Create the state required to render Toast. - * - * The returned state can be modified with hooks such as useToastStyles_unstable, - * before being passed to renderToast_unstable. + * Create the base state required to render Toast, without design-only props. * - * @param props - props from this instance of Toast + * @param props - props from this instance of Toast (without appearance) * @param ref - reference to root HTMLElement of Toast */ -export const useToast_unstable = (props: ToastProps, ref: React.Ref): ToastState => { +export const useToastBase_unstable = (props: ToastBaseProps, ref: React.Ref): ToastBaseState => { const { intent } = useToastContainerContext(); return { @@ -31,7 +28,23 @@ export const useToast_unstable = (props: ToastProps, ref: React.Ref }), { elementType: 'div' }, ), - backgroundAppearance: props.appearance, intent, }; }; + +/** + * Create the state required to render Toast. + * + * The returned state can be modified with hooks such as useToastStyles_unstable, + * before being passed to renderToast_unstable. + * + * @param props - props from this instance of Toast + * @param ref - reference to root HTMLElement of Toast + */ +export const useToast_unstable = (props: ToastProps, ref: React.Ref): ToastState => { + const state = useToastBase_unstable(props, ref); + return { + ...state, + backgroundAppearance: props.appearance, + }; +}; diff --git a/packages/react-components/react-toast/library/src/components/ToastBody/ToastBody.types.ts b/packages/react-components/react-toast/library/src/components/ToastBody/ToastBody.types.ts index ab7d81036c115d..2db4b7c2ae03d8 100644 --- a/packages/react-components/react-toast/library/src/components/ToastBody/ToastBody.types.ts +++ b/packages/react-components/react-toast/library/src/components/ToastBody/ToastBody.types.ts @@ -11,9 +11,19 @@ export type ToastBodySlots = { */ export type ToastBodyProps = ComponentProps & {}; +/** + * ToastBody Props without design-only props. + */ +export type ToastBodyBaseProps = ToastBodyProps; + /** * State used in rendering ToastBody */ export type ToastBodyState = ComponentState & { backgroundAppearance: BackgroundAppearanceContextValue; }; + +/** + * State used in rendering ToastBody, without design-only state. + */ +export type ToastBodyBaseState = Omit; diff --git a/packages/react-components/react-toast/library/src/components/ToastBody/index.ts b/packages/react-components/react-toast/library/src/components/ToastBody/index.ts index 2903dea7132ad7..9de88b2dcff833 100644 --- a/packages/react-components/react-toast/library/src/components/ToastBody/index.ts +++ b/packages/react-components/react-toast/library/src/components/ToastBody/index.ts @@ -1,5 +1,11 @@ export { ToastBody } from './ToastBody'; -export type { ToastBodyProps, ToastBodySlots, ToastBodyState } from './ToastBody.types'; +export type { + ToastBodyBaseProps, + ToastBodyBaseState, + ToastBodyProps, + ToastBodySlots, + ToastBodyState, +} from './ToastBody.types'; export { renderToastBody_unstable } from './renderToastBody'; -export { useToastBody_unstable } from './useToastBody'; +export { useToastBodyBase_unstable, useToastBody_unstable } from './useToastBody'; export { toastBodyClassNames, useToastBodyStyles_unstable } from './useToastBodyStyles.styles'; diff --git a/packages/react-components/react-toast/library/src/components/ToastBody/useToastBody.ts b/packages/react-components/react-toast/library/src/components/ToastBody/useToastBody.ts index bc53de89a00fff..c347ba2a729a47 100644 --- a/packages/react-components/react-toast/library/src/components/ToastBody/useToastBody.ts +++ b/packages/react-components/react-toast/library/src/components/ToastBody/useToastBody.ts @@ -2,21 +2,20 @@ import type * as React from 'react'; import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; -import type { ToastBodyProps, ToastBodyState } from './ToastBody.types'; +import type { ToastBodyBaseProps, ToastBodyBaseState, ToastBodyProps, ToastBodyState } from './ToastBody.types'; import { useToastContainerContext } from '../../contexts/toastContainerContext'; import { useBackgroundAppearance } from '@fluentui/react-shared-contexts'; /** - * Create the state required to render ToastBody. - * - * The returned state can be modified with hooks such as useToastBodyStyles_unstable, - * before being passed to renderToastBody_unstable. + * Create the base state required to render ToastBody, without design-only props. * * @param props - props from this instance of ToastBody * @param ref - reference to root HTMLElement of ToastBody */ -export const useToastBody_unstable = (props: ToastBodyProps, ref: React.Ref): ToastBodyState => { - const backgroundAppearance = useBackgroundAppearance(); +export const useToastBodyBase_unstable = ( + props: ToastBodyBaseProps, + ref: React.Ref, +): ToastBodyBaseState => { const { bodyId } = useToastContainerContext(); return { components: { @@ -35,6 +34,22 @@ export const useToastBody_unstable = (props: ToastBodyProps, ref: React.Ref): ToastBodyState => { + const backgroundAppearance = useBackgroundAppearance(); + return { + ...useToastBodyBase_unstable(props, ref), backgroundAppearance, }; }; diff --git a/packages/react-components/react-toast/library/src/components/ToastTitle/ToastTitle.types.ts b/packages/react-components/react-toast/library/src/components/ToastTitle/ToastTitle.types.ts index 04f8f030350f0a..e05ace8c23ab08 100644 --- a/packages/react-components/react-toast/library/src/components/ToastTitle/ToastTitle.types.ts +++ b/packages/react-components/react-toast/library/src/components/ToastTitle/ToastTitle.types.ts @@ -13,6 +13,11 @@ export type ToastTitleSlots = { */ export type ToastTitleProps = ComponentProps & {}; +/** + * ToastTitle Props without design-only props. + */ +export type ToastTitleBaseProps = ToastTitleProps; + /** * State used in rendering ToastTitle */ @@ -20,3 +25,8 @@ export type ToastTitleState = ComponentState & Pick & { backgroundAppearance: BackgroundAppearanceContextValue; }; + +/** + * State used in rendering ToastTitle, without design-only state. + */ +export type ToastTitleBaseState = Omit; diff --git a/packages/react-components/react-toast/library/src/components/ToastTitle/index.ts b/packages/react-components/react-toast/library/src/components/ToastTitle/index.ts index 6d187294dcfd22..5ed85bfa1be398 100644 --- a/packages/react-components/react-toast/library/src/components/ToastTitle/index.ts +++ b/packages/react-components/react-toast/library/src/components/ToastTitle/index.ts @@ -1,5 +1,11 @@ export { ToastTitle } from './ToastTitle'; -export type { ToastTitleProps, ToastTitleSlots, ToastTitleState } from './ToastTitle.types'; +export type { + ToastTitleBaseProps, + ToastTitleBaseState, + ToastTitleProps, + ToastTitleSlots, + ToastTitleState, +} from './ToastTitle.types'; export { renderToastTitle_unstable } from './renderToastTitle'; -export { useToastTitle_unstable } from './useToastTitle'; +export { useToastTitleBase_unstable, useToastTitle_unstable } from './useToastTitle'; export { toastTitleClassNames, useToastTitleStyles_unstable } from './useToastTitleStyles.styles'; diff --git a/packages/react-components/react-toast/library/src/components/ToastTitle/useToastTitle.tsx b/packages/react-components/react-toast/library/src/components/ToastTitle/useToastTitle.tsx index a0416ad8dc53fe..04775ba73ab64f 100644 --- a/packages/react-components/react-toast/library/src/components/ToastTitle/useToastTitle.tsx +++ b/packages/react-components/react-toast/library/src/components/ToastTitle/useToastTitle.tsx @@ -6,9 +6,44 @@ import { CheckmarkCircleFilled, DiamondDismissFilled, InfoFilled, WarningFilled import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; import { useBackgroundAppearance } from '@fluentui/react-shared-contexts'; -import type { ToastTitleProps, ToastTitleState } from './ToastTitle.types'; +import type { ToastTitleBaseProps, ToastTitleBaseState, ToastTitleProps, ToastTitleState } from './ToastTitle.types'; import { useToastContainerContext } from '../../contexts/toastContainerContext'; +/** + * Create the base state required to render ToastTitle, without design-only props. + * + * @param props - props from this instance of ToastTitle + * @param ref - reference to root HTMLElement of ToastTitle + */ +export const useToastTitleBase_unstable = ( + props: ToastTitleBaseProps, + ref: React.Ref, +): ToastTitleBaseState => { + const { intent, titleId } = useToastContainerContext(); + + return { + action: slot.optional(props.action, { elementType: 'div' }), + components: { root: 'div', media: 'div', action: 'div' }, + media: slot.optional(props.media, { + renderByDefault: !!intent, + elementType: 'div', + }), + root: slot.always( + getIntrinsicElementProps('div', { + // FIXME: + // `ref` is wrongly assigned to be `HTMLElement` instead of `HTMLDivElement` + // but since it would be a breaking change to fix it, we are casting ref to it's proper type + ref: ref as React.Ref, + children: props.children, + id: titleId, + ...props, + }), + { elementType: 'div' }, + ), + intent, + }; +}; + /** * Create the state required to render ToastTitle. * @@ -19,12 +54,12 @@ import { useToastContainerContext } from '../../contexts/toastContainerContext'; * @param ref - reference to root HTMLElement of ToastTitle */ export const useToastTitle_unstable = (props: ToastTitleProps, ref: React.Ref): ToastTitleState => { - const { intent, titleId } = useToastContainerContext(); const backgroundAppearance = useBackgroundAppearance(); + const baseState = useToastTitleBase_unstable(props, ref); /** Determine the role and media to render based on the intent */ let defaultIcon; - switch (intent) { + switch (baseState.intent) { case 'success': defaultIcon = ; break; @@ -40,26 +75,10 @@ export const useToastTitle_unstable = (props: ToastTitleProps, ref: React.Ref, - children: props.children, - id: titleId, - ...props, - }), - { elementType: 'div' }, - ), - intent, + ...baseState, backgroundAppearance, + media: baseState.media + ? { ...baseState.media, children: baseState.media.children ?? defaultIcon } + : baseState.media, }; }; diff --git a/packages/react-components/react-toast/library/src/components/Toaster/index.ts b/packages/react-components/react-toast/library/src/components/Toaster/index.ts index 17a0deb7f8eb8a..10c4dc591ec1d4 100644 --- a/packages/react-components/react-toast/library/src/components/Toaster/index.ts +++ b/packages/react-components/react-toast/library/src/components/Toaster/index.ts @@ -3,3 +3,4 @@ export type { ToasterProps, ToasterSlots, ToasterSlotsInternal, ToasterState } f export { renderToaster_unstable } from './renderToaster'; export { useToaster_unstable } from './useToaster'; export { toasterClassNames, useToasterStyles_unstable } from './useToasterStyles.styles'; +export { useToastAnnounce } from './useToastAnnounce'; diff --git a/packages/react-components/react-toast/library/src/index.ts b/packages/react-components/react-toast/library/src/index.ts index 3e57242a6cefe6..6e8c3b90df94aa 100644 --- a/packages/react-components/react-toast/library/src/index.ts +++ b/packages/react-components/react-toast/library/src/index.ts @@ -1,5 +1,22 @@ -export { useToastController } from './state'; -export type { ToastPosition, ToastId, ToastOffset, ToastPoliteness, ToastStatus, ToastIntent } from './state'; +export { useToastController, useToaster } from './state'; +export type { Announce, AnnounceOptions, AriaLivePoliteness, LiveMessage } from './AriaLive'; +export type { + ToastPosition, + ToastId, + ToastOffset, + ToastPoliteness, + ToastStatus, + ToastIntent, + ToasterId, + ToasterOptions, + ToasterShortcuts, + ToastImperativeRef, + Toast as ToastData, + ToastChangeData, + ToastChangeHandler, + DispatchToastOptions, + UpdateToastOptions, +} from './state'; export { ToastTrigger, useToastTrigger_unstable, renderToastTrigger_unstable } from './ToastTrigger'; export type { ToastTriggerChildProps, ToastTriggerProps, ToastTriggerState } from './ToastTrigger'; @@ -9,28 +26,50 @@ export { useToasterStyles_unstable, renderToaster_unstable, toasterClassNames, + useToastAnnounce, } from './Toaster'; export type { ToasterProps, ToasterState, ToasterSlots } from './Toaster'; -export { Toast, useToastStyles_unstable, useToast_unstable, renderToast_unstable, toastClassNames } from './Toast'; -export type { ToastProps, ToastState, ToastSlots } from './Toast'; +export { + Toast, + useToastStyles_unstable, + useToastBase_unstable, + useToast_unstable, + renderToast_unstable, + toastClassNames, +} from './Toast'; +export type { ToastBaseProps, ToastBaseState, ToastProps, ToastState, ToastSlots } from './Toast'; export { ToastTitle, useToastTitleStyles_unstable, + useToastTitleBase_unstable, useToastTitle_unstable, renderToastTitle_unstable, toastTitleClassNames, } from './ToastTitle'; -export type { ToastTitleProps, ToastTitleState, ToastTitleSlots } from './ToastTitle'; +export type { + ToastTitleBaseProps, + ToastTitleBaseState, + ToastTitleProps, + ToastTitleState, + ToastTitleSlots, +} from './ToastTitle'; export { ToastBody, useToastBodyStyles_unstable, + useToastBodyBase_unstable, useToastBody_unstable, renderToastBody_unstable, toastBodyClassNames, } from './ToastBody'; -export type { ToastBodyProps, ToastBodyState, ToastBodySlots } from './ToastBody'; +export type { + ToastBodyBaseProps, + ToastBodyBaseState, + ToastBodyProps, + ToastBodyState, + ToastBodySlots, +} from './ToastBody'; export { ToastFooter, @@ -41,5 +80,12 @@ export { } from './ToastFooter'; export type { ToastFooterProps, ToastFooterState, ToastFooterSlots } from './ToastFooter'; +export type { ToastContainerContextValue } from './contexts/toastContainerContext'; +export { ToastContainerContextProvider, useToastContainerContext } from './contexts/toastContainerContext'; export { toastContainerClassNames } from './ToastContainer'; -export type { ToastContainerState } from './ToastContainer'; +export type { ToastContainerSlots, ToastContainerProps, ToastContainerState } from './ToastContainer'; +export { + useToastContainer_unstable, + renderToastContainer_unstable, + useToastContainerContextValues_unstable, +} from './ToastContainer';