From cb4597460d91dda4f661a5af6d7124746ecb4b88 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Sun, 3 May 2026 09:18:49 +0200 Subject: [PATCH 1/3] feat(react-headless-components-preview): add Toast component Co-authored-by: Copilot --- ...-86a2a1c9-dfd8-4e9e-8286-6567597a6749.json | 7 + change/@fluentui-react-toast-base-hooks.json | 7 + .../bundle-size/AllComponents.fixture.js | 2 + .../library/etc/toast.api.md | 236 ++++++++++++++++++ .../library/package.json | 7 + .../library/src/components/Toast/Toast.tsx | 15 ++ .../src/components/Toast/Toast.types.ts | 36 +++ .../components/Toast/ToastBody/ToastBody.tsx | 13 + .../Toast/ToastBody/ToastBody.types.ts | 1 + .../src/components/Toast/ToastBody/index.ts | 4 + .../Toast/ToastBody/renderToastBody.ts | 8 + .../Toast/ToastBody/useToastBody.ts | 24 ++ .../Toast/ToastContainer/ToastContainer.tsx | 16 ++ .../ToastContainer/ToastContainer.types.ts | 37 +++ .../components/Toast/ToastContainer/index.ts | 11 + .../ToastContainer/renderToastContainer.tsx | 20 ++ .../Toast/ToastContainer/useToastContainer.ts | 198 +++++++++++++++ .../useToastContainerContextValues.ts | 28 +++ .../Toast/ToastFooter/ToastFooter.tsx | 13 + .../Toast/ToastFooter/ToastFooter.types.ts | 1 + .../src/components/Toast/ToastFooter/index.ts | 4 + .../Toast/ToastFooter/renderToastFooter.ts | 5 + .../Toast/ToastFooter/useToastFooter.ts | 17 ++ .../Toast/ToastTitle/ToastTitle.tsx | 13 + .../Toast/ToastTitle/ToastTitle.types.ts | 1 + .../src/components/Toast/ToastTitle/index.ts | 4 + .../Toast/ToastTitle/renderToastTitle.ts | 8 + .../Toast/ToastTitle/useToastTitle.ts | 31 +++ .../src/components/Toast/Toaster/Toaster.tsx | 40 +++ .../components/Toast/Toaster/Toaster.types.ts | 17 ++ .../src/components/Toast/Toaster/index.ts | 4 + .../Toast/Toaster/renderToaster.tsx | 36 +++ .../components/Toast/Toaster/useToaster.ts | 28 +++ .../library/src/components/Toast/index.ts | 51 ++++ .../src/components/Toast/renderToast.tsx | 17 ++ .../src/components/Toast/toastContext.ts | 34 +++ .../library/src/components/Toast/useToast.ts | 93 +++++++ .../components/Toast/useToastContextValues.ts | 16 ++ .../library/src/toast.ts | 51 ++++ .../src/Toast/ToastCustomTimeout.stories.tsx | 77 ++++++ .../src/Toast/ToastDefault.stories.tsx | 68 +++++ .../stories/src/Toast/ToastDescription.md | 72 ++++++ .../src/Toast/ToastDismissAll.stories.tsx | 47 ++++ .../src/Toast/ToastDismissToast.stories.tsx | 50 ++++ .../ToastDismissToastWithAction.stories.tsx | 69 +++++ .../stories/src/Toast/ToastIntent.stories.tsx | 81 ++++++ .../src/Toast/ToastLifecycle.stories.tsx | 119 +++++++++ .../Toast/ToastMultipleToasters.stories.tsx | 72 ++++++ .../src/Toast/ToastPauseAndPlay.stories.tsx | 74 ++++++ .../src/Toast/ToastPauseOnHover.stories.tsx | 41 +++ .../Toast/ToastPauseOnWindowBlur.stories.tsx | 41 +++ .../src/Toast/ToastProgressToast.stories.tsx | 114 +++++++++ .../stories/src/Toast/ToastStoryShared.ts | 23 ++ .../src/Toast/ToastUpdateToast.stories.tsx | 62 +++++ .../stories/src/Toast/index.stories.tsx | 35 +++ .../library/etc/react-toast.api.md | 78 ++++++ .../react-toast/library/src/Toast.ts | 10 +- .../react-toast/library/src/ToastBody.ts | 9 +- .../react-toast/library/src/ToastTitle.ts | 9 +- .../src/components/Toast/Toast.types.ts | 10 + .../library/src/components/Toast/index.ts | 11 +- .../library/src/components/Toast/useToast.ts | 29 ++- .../components/ToastBody/ToastBody.types.ts | 10 + .../library/src/components/ToastBody/index.ts | 10 +- .../src/components/ToastBody/useToastBody.ts | 29 ++- .../components/ToastTitle/ToastTitle.types.ts | 10 + .../src/components/ToastTitle/index.ts | 10 +- .../components/ToastTitle/useToastTitle.tsx | 65 +++-- .../react-toast/library/src/index.ts | 47 +++- 69 files changed, 2483 insertions(+), 53 deletions(-) create mode 100644 change/@fluentui-react-headless-components-preview-86a2a1c9-dfd8-4e9e-8286-6567597a6749.json create mode 100644 change/@fluentui-react-toast-base-hooks.json create mode 100644 packages/react-components/react-headless-components-preview/library/etc/toast.api.md create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/ToastBody.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/ToastBody.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/renderToastBody.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/useToastBody.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/ToastContainer.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/ToastContainer.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/renderToastContainer.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/useToastContainer.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/useToastContainerContextValues.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/ToastFooter.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/ToastFooter.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/renderToastFooter.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/useToastFooter.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/ToastTitle.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/ToastTitle.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/renderToastTitle.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/useToastTitle.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/renderToaster.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/useToaster.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/renderToast.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/toastContext.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/useToast.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/useToastContextValues.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/toast.ts create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Toast/ToastCustomTimeout.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDefault.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDescription.md create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDismissAll.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDismissToast.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDismissToastWithAction.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Toast/ToastIntent.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Toast/ToastLifecycle.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Toast/ToastMultipleToasters.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Toast/ToastPauseAndPlay.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Toast/ToastPauseOnHover.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Toast/ToastPauseOnWindowBlur.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Toast/ToastProgressToast.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Toast/ToastStoryShared.ts create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Toast/ToastUpdateToast.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Toast/index.stories.tsx 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..eccd5168081c70 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/toast.api.md @@ -0,0 +1,236 @@ +## 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 { ComponentProps } from '@fluentui/react-utilities'; +import type { ComponentState } from '@fluentui/react-utilities'; +import { DispatchToastOptions } from '@fluentui/react-toast'; +import type { EventHandler } from '@fluentui/react-utilities'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import * as React_2 from 'react'; +import type { Slot } from '@fluentui/react-utilities'; +import { ToastBodyBaseProps } from '@fluentui/react-toast'; +import { ToastBodyBaseState } from '@fluentui/react-toast'; +import { ToastBodySlots } from '@fluentui/react-toast'; +import { ToastChangeData } from '@fluentui/react-toast'; +import { ToastChangeHandler } from '@fluentui/react-toast'; +import type { ToastData } from '@fluentui/react-toast'; +import { ToasterId } 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 type { ToastIntent } from '@fluentui/react-toast'; +import { ToastPoliteness } from '@fluentui/react-toast'; +import { ToastPosition } from '@fluentui/react-toast'; +import { ToastStatus } from '@fluentui/react-toast'; +import { ToastTitleBaseProps } from '@fluentui/react-toast'; +import { ToastTitleBaseState } from '@fluentui/react-toast'; +import { ToastTitleSlots } from '@fluentui/react-toast'; +import { UpdateToastOptions } from '@fluentui/react-toast'; +import { useToastController } from '@fluentui/react-toast'; + +export { DispatchToastOptions } + +// @public (undocumented) +export const renderToast: (state: ToastState, contextValues: ToastContextValues) => JSXElement; + +// @public (undocumented) +export const renderToastBody: (state: ToastBodyBaseState) => JSXElement; + +// @public (undocumented) +export const renderToastContainer: (state: ToastContainerState, contextValues: ToastContextValues) => JSXElement; + +// @public (undocumented) +export const renderToaster: (state: ToasterState) => JSXElement; + +// @public (undocumented) +export const renderToastFooter: (state: ToastFooterState) => JSXElement; + +// @public (undocumented) +export const renderToastTitle: (state: ToastTitleBaseState) => JSXElement; + +// @public (undocumented) +export const Toast: ForwardRefComponent; + +// @public (undocumented) +export const ToastBody: ForwardRefComponent; + +export { ToastBodyBaseProps } + +export { ToastBodyBaseState } + +export { ToastBodySlots } + +export { ToastChangeData } + +export { ToastChangeHandler } + +// @public (undocumented) +export const ToastContainer: ForwardRefComponent; + +// @public +export type ToastContainerProps = Omit, 'content'> & ToastData & { + visible: boolean; + children?: React_2.ReactNode; + tryRestoreFocus: () => void; +}; + +// @public (undocumented) +export type ToastContainerSlots = { + root: Slot<'div'>; +}; + +// @public (undocumented) +export type ToastContainerState = ComponentState & { + intent: ToastIntent | undefined; + bodyId: string; + titleId: string; + close: () => void; +}; + +// @public (undocumented) +export const ToastContext: React_2.Context; + +// @public (undocumented) +type ToastContextValue = { + open: boolean; + intent: ToastIntent | undefined; + bodyId: string; + titleId: string; + requestOpenChange: (data: ToastOpenChangeData) => void; +}; +export { ToastContextValue as ToastContainerContextValue } +export { ToastContextValue } + +// @public (undocumented) +type ToastContextValues = { + toast: ToastContextValue; +}; +export { ToastContextValues as ToastContainerContextValues } +export { ToastContextValues } + +// @public +export const Toaster: ForwardRefComponent; + +export { ToasterId } + +// @public +export type ToasterProps = { + toasterId?: ToasterId; +}; + +// @public (undocumented) +export type ToasterState = { + toastsToRender: Map; + isToastVisible: (toastId: ToastId) => boolean; + tryRestoreFocus: () => void; + getStackTransform: (position: string, stackIndex: number) => string; +}; + +// @public (undocumented) +export const ToastFooter: ForwardRefComponent; + +export { ToastFooterProps } + +export { ToastFooterSlots } + +export { ToastFooterState } + +export { ToastId } + +export { ToastImperativeRef } + +export { ToastIntent } + +// @public (undocumented) +export type ToastOpenChangeData = { + type: 'dismissClick'; + open: false; + event: React_2.MouseEvent; +} | { + type: 'timeout'; + open: false; + event: null; +} | { + type: 'triggerClick'; + open: boolean; + event: React_2.MouseEvent; +}; + +export { ToastPoliteness } + +export { ToastPosition } + +// @public (undocumented) +export type ToastProps = ComponentProps & { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: EventHandler; + intent?: ToastIntent; + timeout?: number; +}; + +// @public (undocumented) +export type ToastSlots = { + root: Slot<'div'>; +}; + +// @public (undocumented) +export type ToastState = ComponentState & { + open: boolean; + intent: ToastIntent | undefined; + bodyId: string; + titleId: string; + requestOpenChange: ToastContextValue['requestOpenChange']; +}; + +export { ToastStatus } + +// @public (undocumented) +export const ToastTitle: ForwardRefComponent; + +export { ToastTitleBaseProps } + +export { ToastTitleBaseState } + +export { ToastTitleSlots } + +export { UpdateToastOptions } + +// @public (undocumented) +export const useToast: (props: ToastProps, ref: React_2.Ref) => ToastState; + +// @public (undocumented) +export const useToastBody: (props: ToastBodyBaseProps, ref: React_2.Ref) => ToastBodyBaseState; + +// @public (undocumented) +export const useToastContainer: (props: ToastContainerProps, ref: React_2.Ref) => ToastContainerState; + +// @public (undocumented) +export const useToastContainerContextValues: (state: ToastContainerState) => ToastContextValues; + +// @public (undocumented) +export const useToastContext: () => ToastContextValue; + +// @public (undocumented) +export const useToastContextValues: (state: ToastState) => ToastContextValues; + +export { useToastController } + +// @public (undocumented) +export const useToaster: ({ toasterId }: ToasterProps, _ref: React_2.Ref) => ToasterState; + +// @public (undocumented) +export const useToastFooter: (props: ToastFooterProps, ref: React_2.Ref) => ToastFooterState; + +// @public (undocumented) +export const useToastTitle: (props: ToastTitleBaseProps, ref: React_2.Ref) => ToastTitleBaseState; + +// (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/Toast.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.tsx new file mode 100644 index 00000000000000..d84f6bd6f291c9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.tsx @@ -0,0 +1,15 @@ +'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 { useToastContextValues } from './useToastContextValues'; +import { renderToast } from './renderToast'; + +export const Toast: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useToast(props, ref); + const contextValues = useToastContextValues(state); + return renderToast(state, contextValues); +}); +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..6bb04c85922614 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.types.ts @@ -0,0 +1,36 @@ +import type { ComponentProps, ComponentState, EventHandler, Slot } from '@fluentui/react-utilities'; +import type { ToastContextValue, ToastIntent, ToastOpenChangeData } from './toastContext'; + +export type { ToastIntent, ToastOpenChangeData }; + +export type ToastSlots = { + root: Slot<'div'>; +}; + +export type ToastProps = ComponentProps & { + /** Whether the toast is currently visible. Use with `onOpenChange` for controlled mode. */ + open?: boolean; + /** Initial open state for uncontrolled usage. */ + defaultOpen?: boolean; + /** Called when the toast should open or close. */ + onOpenChange?: EventHandler; + /** Semantic intent — affects accessible role and default icon in ToastTitle. */ + intent?: ToastIntent; + /** + * Auto-dismiss timeout in milliseconds. + * Negative value disables auto-dismiss (default: -1). + */ + timeout?: number; +}; + +export type ToastState = ComponentState & { + open: boolean; + intent: ToastIntent | undefined; + bodyId: string; + titleId: string; + requestOpenChange: ToastContextValue['requestOpenChange']; +}; + +export type ToastContextValues = { + toast: ToastContextValue; +}; 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..de4bb2d8e8cc73 --- /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 { ToastBodyBaseProps } 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..03ef0e0ad57bce --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/ToastBody.types.ts @@ -0,0 +1 @@ +export type { ToastBodyBaseProps, ToastBodyBaseState, 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..2e8a6b94974da5 --- /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 { ToastBodyBaseProps, ToastBodyBaseState, 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..67599580706bbc --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/renderToastBody.ts @@ -0,0 +1,8 @@ +import { renderToastBody_unstable } from '@fluentui/react-toast'; +import type { ToastBodyBaseState, ToastBodyState } from '@fluentui/react-toast'; +import type { JSXElement } from '@fluentui/react-utilities'; + +// Cast strips the style-only `backgroundAppearance` field; renderToastBody_unstable +// does not use it in its render path (only the style hook reads it). +export const renderToastBody = (state: ToastBodyBaseState): JSXElement => + renderToastBody_unstable(state as ToastBodyState); 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..ef4dd53ced26ea --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/useToastBody.ts @@ -0,0 +1,24 @@ +'use client'; + +import type * as React from 'react'; +import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; +import type { ToastBodyBaseProps, ToastBodyBaseState } from '@fluentui/react-toast'; +import { useToastContext } from '../toastContext'; + +export const useToastBody = (props: ToastBodyBaseProps, ref: React.Ref): ToastBodyBaseState => { + const { bodyId } = useToastContext(); + + return { + components: { root: 'div', subtitle: 'div' }, + root: slot.always( + getIntrinsicElementProps('div', { + // FIXME: ref typed as HTMLElement upstream; cast to correct type + ref: ref as React.Ref, + id: bodyId, + ...props, + }), + { elementType: 'div' }, + ), + subtitle: slot.optional(props.subtitle, { elementType: 'div' }), + }; +}; 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..558222b3483eee --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/ToastContainer.types.ts @@ -0,0 +1,37 @@ +import type * as React from 'react'; +import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import type { ToastData, ToastIntent } from '@fluentui/react-toast'; +import type { ToastContextValue } from '../toastContext'; +import type { ToastContextValues } from '../Toast.types'; + +export type { ToastContextValues as ToastContainerContextValues }; + +export type ToastContainerSlots = { + root: Slot<'div'>; +}; + +/** + * All fields from the state machine's ToastData object, plus the rendering extras + * added by useToaster (visible, tryRestoreFocus). + * ComponentProps is required for ForwardRefComponent to infer + * the correct ref element type (HTMLDivElement) from the root slot. + */ +export type ToastContainerProps = Omit, 'content'> & + ToastData & { + /** Whether the toast is currently in the visible set. */ + visible: boolean; + /** Children — the toast content dispatched via dispatchToast(). */ + children?: React.ReactNode; + /** Callback to restore focus after the toast closes. Provided by Toaster. */ + tryRestoreFocus: () => void; + }; + +export type ToastContainerState = ComponentState & { + intent: ToastIntent | undefined; + bodyId: string; + titleId: string; + /** Calls the state machine close(); used by context consumers (e.g. dismiss button). */ + close: () => void; +}; + +export type { ToastContextValue as ToastContainerContextValue }; 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..a848f15a2ee05e --- /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 { + ToastContainerProps, + ToastContainerSlots, + ToastContainerState, + ToastContainerContextValues, + 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..72567c7c875fc9 --- /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 type { ToastContainerContextValues, ToastContainerSlots, ToastContainerState } from './ToastContainer.types'; +import { ToastContext } from '../toastContext'; + +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..09e2f9684e5cfb --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/useToastContainer.ts @@ -0,0 +1,198 @@ +'use client'; + +import * as React from 'react'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; +import { + getIntrinsicElementProps, + slot, + useEventCallback, + useId, + useIsomorphicLayoutEffect, + useMergedRefs, +} from '@fluentui/react-utilities'; +import type { ToastContainerProps, ToastContainerState } from './ToastContainer.types'; + +type PopoverElement = HTMLDivElement & { + showPopover?: () => void; + hidePopover?: () => void; +}; + +export const useToastContainer = (props: ToastContainerProps, ref: React.Ref): ToastContainerState => { + const { + visible, + close: stateClose, + remove, + intent, + timeout: timeoutProp = -1, + pauseOnHover = false, + pauseOnWindowBlur = false, + imperativeRef, + tryRestoreFocus, + onStatusChange, + // State machine fields — destructured to keep them out of ...rest (not passed to the DOM). + content: _content, + toastId: _toastId, + toasterId: _toasterId, + position: _position, + order: _order, + updateId: _updateId, + priority: _priority, + politeness: _politeness, + data: _data, + ...rest + } = props; + + const { targetDocument } = useFluent(); + const win = targetDocument?.defaultView; + + const toastRef = React.useRef(null); + const mergedRef = useMergedRefs(ref, toastRef) as React.Ref; + const titleId = useId('toast-title-'); + const bodyId = useId('toast-body-'); + + const [running, setRunning] = React.useState(false); + // Tracks whether pause() was called via the imperative ref (as opposed to hover/blur). + const imperativePauseRef = React.useRef(false); + // Tracks whether focus was inside the toast at the time it closed (for focus restoration). + const focusedBeforeCloseRef = React.useRef(false); + + const close = useEventCallback(() => { + const activeEl = targetDocument?.activeElement; + if (activeEl && toastRef.current?.contains(activeEl)) { + focusedBeforeCloseRef.current = true; + } + stateClose(); + }); + + const play = useEventCallback(() => { + if (imperativePauseRef.current || timeoutProp < 0) { + return; + } + const containsActive = !!toastRef.current?.contains(targetDocument?.activeElement ?? null); + if (!containsActive) { + setRunning(true); + } + }); + + const pause = useEventCallback(() => setRunning(false)); + + // Expose imperative focus/pause/play to the state machine. + React.useImperativeHandle(imperativeRef, () => ({ + focus: () => toastRef.current?.focus(), + play: () => { + imperativePauseRef.current = false; + play(); + }, + pause: () => { + imperativePauseRef.current = true; + pause(); + }, + })); + + // Auto-dismiss timer. Uses targetDocument's window so timers are scoped to the + // correct browsing context (e.g. iframes), matching the project's no-globals rule. + React.useEffect(() => { + if (!running || !win || timeoutProp < 0) { + return; + } + const id = win.setTimeout(() => { + close(); + setRunning(false); + }, timeoutProp); + return () => win.clearTimeout(id); + }, [running, win, timeoutProp, close]); + + // Drive the Popover API from the state machine's visible flag. + // useIsomorphicLayoutEffect prevents a paint where the element is in the DOM + // but not yet in the top layer. + useIsomorphicLayoutEffect(() => { + const el = toastRef.current; + if (!el || !('showPopover' in el)) { + return; + } + + if (visible) { + if (!el.matches(':popover-open')) { + el.showPopover!(); + play(); // start the auto-dismiss timer as soon as the toast appears + } + } else { + if (el.matches(':popover-open')) { + el.hidePopover!(); + } + } + }, [visible, play]); + + // Remove the toast from the state machine once it's no longer visible. + // Without animation, removal is immediate after hide (replaces CollapseDelayed exit callback). + React.useEffect(() => { + if (!visible) { + remove(); + } + }, [visible, remove]); + + // Report 'unmounted' lifecycle status when the component is destroyed. + const reportStatus = useEventCallback(() => onStatusChange?.(null, { status: 'unmounted', ...props })); + React.useEffect(() => reportStatus, [reportStatus]); + + // Restore focus when the toast that had focus is closed. + React.useEffect(() => { + return () => { + if (focusedBeforeCloseRef.current) { + focusedBeforeCloseRef.current = false; + tryRestoreFocus(); + } + }; + }, [tryRestoreFocus]); + + const onMouseEnter = useEventCallback(() => { + if (pauseOnHover) { + pause(); + } + }); + + const onMouseLeave = useEventCallback(() => { + if (pauseOnHover) { + play(); + } + }); + + // Pause/resume when the browser window loses/regains focus. + React.useEffect(() => { + if (!pauseOnWindowBlur || !win) { + return; + } + win.addEventListener('focus', play); + win.addEventListener('blur', pause); + return () => { + win.removeEventListener('focus', play); + win.removeEventListener('blur', pause); + }; + }, [pauseOnWindowBlur, win, play, pause]); + + const role = intent === 'error' || intent === 'warning' ? 'alert' : 'status'; + + return { + components: { root: 'div' }, + root: slot.always( + getIntrinsicElementProps('div', { + // popover="manual": top-layer placement with no light-dismiss. + // Only explicit close() or timeout dismisses the toast. + ...({ popover: 'manual' } as {}), + ref: mergedRef, + role, + tabIndex: -1, + 'aria-labelledby': titleId, + 'aria-describedby': bodyId, + onMouseEnter, + onMouseLeave, + ...rest, + }), + { elementType: 'div' }, + ), + intent, + bodyId, + titleId, + close, + }; +}; 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..6264c6b11d191f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/useToastContainerContextValues.ts @@ -0,0 +1,28 @@ +'use client'; + +import * as React from 'react'; +import type { ToastContainerContextValues, ToastContainerState } from './ToastContainer.types'; +import type { ToastContextValue } from '../toastContext'; + +export const useToastContainerContextValues = (state: ToastContainerState): ToastContainerContextValues => { + const { intent, bodyId, titleId, close } = state; + + const toast = React.useMemo( + () => ({ + open: true, + intent, + bodyId, + titleId, + // ToastContext.requestOpenChange is used by child components (e.g. a dismiss button) + // to close the toast. In the Toaster DX, closing routes through the state machine. + requestOpenChange: data => { + if (!data.open) { + close(); + } + }, + }), + [intent, bodyId, titleId, close], + ); + + 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..7687fc44922260 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/renderToastFooter.ts @@ -0,0 +1,5 @@ +import { renderToastFooter_unstable } from '@fluentui/react-toast'; +import type { ToastFooterState } from '@fluentui/react-toast'; +import type { JSXElement } from '@fluentui/react-utilities'; + +export const renderToastFooter = (state: ToastFooterState): JSXElement => renderToastFooter_unstable(state); 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..37b6e542b50bf0 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/useToastFooter.ts @@ -0,0 +1,17 @@ +import type * as React from 'react'; +import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; +import type { ToastFooterProps, ToastFooterState } from '@fluentui/react-toast'; + +export const useToastFooter = (props: ToastFooterProps, ref: React.Ref): ToastFooterState => { + return { + components: { root: 'div' }, + root: slot.always( + getIntrinsicElementProps('div', { + // FIXME: ref typed as HTMLElement upstream; cast to correct type + ref: ref as React.Ref, + ...props, + }), + { elementType: 'div' }, + ), + }; +}; 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..e533933fb48652 --- /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 { ToastTitleBaseProps } 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..5d6afe52b2ca81 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/ToastTitle.types.ts @@ -0,0 +1 @@ +export type { ToastTitleBaseProps, ToastTitleBaseState, 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..2ce46c92971bf1 --- /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 { ToastTitleBaseProps, ToastTitleBaseState, 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..62ee30ecbf2c20 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/renderToastTitle.ts @@ -0,0 +1,8 @@ +import { renderToastTitle_unstable } from '@fluentui/react-toast'; +import type { ToastTitleBaseState, ToastTitleState } from '@fluentui/react-toast'; +import type { JSXElement } from '@fluentui/react-utilities'; + +// Cast strips the style-only `backgroundAppearance` field; renderToastTitle_unstable +// does not use it in its render path (only the style hook reads it). +export const renderToastTitle = (state: ToastTitleBaseState): JSXElement => + renderToastTitle_unstable(state as ToastTitleState); 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..fdeb1c1db46321 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/useToastTitle.ts @@ -0,0 +1,31 @@ +'use client'; + +import type * as React from 'react'; +import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; +import type { ToastTitleBaseProps, ToastTitleBaseState } from '@fluentui/react-toast'; +import { useToastContext } from '../toastContext'; + +export const useToastTitle = (props: ToastTitleBaseProps, ref: React.Ref): ToastTitleBaseState => { + const { intent, titleId } = useToastContext(); + + return { + components: { root: 'div', media: 'div', action: 'div' }, + root: slot.always( + getIntrinsicElementProps('div', { + // FIXME: ref typed as HTMLElement upstream; cast to correct type + ref: ref as React.Ref, + id: titleId, + ...props, + }), + { elementType: 'div' }, + ), + // Render the media slot by default only when an intent is set — the + // styled layer (or the consumer) fills it with the appropriate icon. + media: slot.optional(props.media, { + renderByDefault: !!intent, + elementType: 'div', + }), + action: slot.optional(props.action, { elementType: 'div' }), + intent, + }; +}; 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..11883cbf50b8a8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.tsx @@ -0,0 +1,40 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ToasterProps } from './Toaster.types'; +import { useToaster } from './useToaster'; +import { renderToaster } from './renderToaster'; + +/** + * Headless Toaster — subscribes to the event-driven toast state machine and + * renders a `ToastContainer` (div[popover="manual"]) for each active toast. + * + * Position and offset are intentionally omitted: the Popover API places each + * toast in the browser top layer, so layout is pure CSS. + * + * Pair with `useToastController` from `@fluentui/react-toast` to dispatch and + * dismiss toasts imperatively. + * + * @example + * ```tsx + * // App root + * + * + * // Anywhere inside FluentProvider + * const { dispatchToast } = useToastController('app'); + * dispatchToast( + * <> + * Saved + * Your changes have been saved. + * , + * { intent: 'success', timeout: 3000 }, + * ); + * ``` + */ +export const Toaster: ForwardRefComponent = React.forwardRef(({ toasterId }, _ref) => { + const state = useToaster({ toasterId }, _ref); + 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..cc48942cb5bd51 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.types.ts @@ -0,0 +1,17 @@ +import type { ToastData, ToastId, ToastPosition, ToasterId } from '@fluentui/react-toast'; + +/** + * Headless Toaster props. Position, offset, and aria-live are omitted because + * the Popover API places each toast in the browser top layer independently — + * consumers control position through CSS (e.g. :popover-open { inset: auto; ... }). + */ +export type ToasterProps = { + toasterId?: ToasterId; +}; + +export type ToasterState = { + toastsToRender: Map; + isToastVisible: (toastId: ToastId) => boolean; + tryRestoreFocus: () => void; + getStackTransform: (position: ToastPosition, stackIndex: number) => string; +}; 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..39d02f7e5d957a --- /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 { 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..441d8f0651c3e5 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/renderToaster.tsx @@ -0,0 +1,36 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ + +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { ToasterState } from './Toaster.types'; +import { ToastContainer } from '../ToastContainer'; + +export const renderToaster = (state: ToasterState): JSXElement => { + const { toastsToRender, isToastVisible, tryRestoreFocus, getStackTransform } = state; + + return ( + <> + {Array.from(toastsToRender.entries()).flatMap(([position, toasts]) => + toasts.map((toast, index) => { + const stackIndex = position.startsWith('bottom') ? toasts.length - 1 - index : index; + + return ( + + {toast.content as React.ReactNode} + + ); + }), + )} + + ); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/useToaster.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/useToaster.ts new file mode 100644 index 00000000000000..062511d51b5778 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/useToaster.ts @@ -0,0 +1,28 @@ +'use client'; + +import * as React from 'react'; +import { useToaster as useToasterState } from '@fluentui/react-toast'; +import type { ToastPosition } from '@fluentui/react-toast'; +import type { ToasterProps, ToasterState } from './Toaster.types'; + +export const useToaster = ({ toasterId }: ToasterProps, _ref: React.Ref): ToasterState => { + const { toastsToRender, isToastVisible, tryRestoreFocus } = useToasterState({ toasterId }); + + const getStackTransform = React.useCallback((position: ToastPosition, stackIndex: number): string => { + if (stackIndex === 0) { + return 'translateY(0)'; + } + + const direction = position.startsWith('bottom') ? '-' : ''; + // 100% uses each toast's own height. This keeps spacing consistent even when + // content varies slightly across toasts. + return `translateY(calc(${direction}${stackIndex} * (100% + 8px)))`; + }, []); + + return { + toastsToRender, + isToastVisible, + tryRestoreFocus, + getStackTransform, + }; +}; 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..a194bae184bb8d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/index.ts @@ -0,0 +1,51 @@ +// ─── Compound Toast (standalone open/close) ────────────────────────────────── +export { Toast } from './Toast'; +export { renderToast } from './renderToast'; +export { useToast } from './useToast'; +export { useToastContextValues } from './useToastContextValues'; +export { ToastContext, useToastContext } from './toastContext'; +export type { ToastProps, ToastState, ToastSlots, ToastContextValues } from './Toast.types'; +export type { ToastContextValue, ToastOpenChangeData, ToastIntent } from './toastContext'; + +// ─── Sub-components ─────────────────────────────────────────────────────────── +export { ToastTitle, renderToastTitle, useToastTitle } from './ToastTitle'; +export type { ToastTitleBaseProps, ToastTitleBaseState, ToastTitleSlots } from './ToastTitle'; + +export { ToastBody, renderToastBody, useToastBody } from './ToastBody'; +export type { ToastBodyBaseProps, ToastBodyBaseState, 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, + ToastContainerContextValues, + ToastContainerContextValue, +} from './ToastContainer'; + +// ─── Re-exported from @fluentui/react-toast for import convenience ──────────── +export { useToastController } 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/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..1ecbd1f0a60e33 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/renderToast.tsx @@ -0,0 +1,17 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ + +import { assertSlots } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { ToastContextValues, ToastSlots, ToastState } from './Toast.types'; +import { ToastContext } from './toastContext'; + +export const renderToast = (state: ToastState, contextValues: ToastContextValues): JSXElement => { + assertSlots(state); + + return ( + + + + ); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/toastContext.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/toastContext.ts new file mode 100644 index 00000000000000..ced39cabadc94d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/toastContext.ts @@ -0,0 +1,34 @@ +'use client'; + +import * as React from 'react'; +import type { ToastIntent } from '@fluentui/react-toast'; + +export type { ToastIntent }; + +export type ToastOpenChangeData = + | { type: 'dismissClick'; open: false; event: React.MouseEvent } + // Auto-dismiss has no DOM event; null satisfies the EventData constraint. + | { type: 'timeout'; open: false; event: null } + | { type: 'triggerClick'; open: boolean; event: React.MouseEvent }; + +export type ToastContextValue = { + open: boolean; + intent: ToastIntent | undefined; + bodyId: string; + titleId: string; + requestOpenChange: (data: ToastOpenChangeData) => void; +}; + +const defaultToastContextValue: ToastContextValue = { + open: false, + intent: undefined, + bodyId: '', + titleId: '', + requestOpenChange() { + /* noop */ + }, +}; + +export const ToastContext = React.createContext(undefined); + +export const useToastContext = (): ToastContextValue => React.useContext(ToastContext) ?? defaultToastContextValue; 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..058b4cf42121e6 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/useToast.ts @@ -0,0 +1,93 @@ +'use client'; + +import * as React from 'react'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; +import { + getIntrinsicElementProps, + slot, + useControllableState, + useEventCallback, + useId, + useIsomorphicLayoutEffect, + useMergedRefs, +} from '@fluentui/react-utilities'; +import type { ToastProps, ToastState } from './Toast.types'; +import type { ToastOpenChangeData } from './toastContext'; + +export const useToast = (props: ToastProps, ref: React.Ref): ToastState => { + const { open: openProp, defaultOpen, onOpenChange, intent, timeout = -1, ...rest } = props; + const { targetDocument } = useFluent(); + + const [open, setOpen] = useControllableState({ + state: openProp, + defaultState: defaultOpen, + initialState: false, + }); + + const toastRef = React.useRef(null); + const mergedRef = useMergedRefs(ref, toastRef) as React.Ref; + + const titleId = useId('toast-title-'); + const bodyId = useId('toast-body-'); + + const requestOpenChange = useEventCallback((data: ToastOpenChangeData) => { + // EventHandler signature is (ev, data). For timeout events there is no + // DOM event; the double assertion satisfies the type without losing information. + onOpenChange?.(data.event as unknown as React.SyntheticEvent, data); + setOpen(data.open); + }); + + // Drive the Popover API from React open state. + // useIsomorphicLayoutEffect keeps the element in the top layer synchronously + // after the DOM update, preventing a flash where the element is in the DOM + // but not yet visible. + useIsomorphicLayoutEffect(() => { + const el = toastRef.current; + if (!el) { + return; + } + + if (open && !el.matches(':popover-open')) { + el.showPopover(); + } else if (!open && el.matches(':popover-open')) { + el.hidePopover(); + } + }, [open]); + + // Auto-dismiss after `timeout` ms. Uses the window from FluentProvider so + // timers are scoped to the correct browsing context (e.g. iframes). + const win = targetDocument?.defaultView; + React.useEffect(() => { + if (!open || timeout < 0 || !win) { + return; + } + const id = win.setTimeout(() => requestOpenChange({ type: 'timeout', open: false, event: null }), timeout); + return () => win.clearTimeout(id); + }, [open, timeout, requestOpenChange, win]); + + // error/warning → role="alert" (assertive); info/success/undefined → role="status" (polite) + const role = intent === 'error' || intent === 'warning' ? 'alert' : 'status'; + + return { + components: { root: 'div' }, + root: slot.always( + getIntrinsicElementProps('div', { + // popover="manual": top-layer placement, no light-dismiss on outside click. + // Users retain full control; only explicit calls to requestOpenChange or + // the timeout dismiss the toast. + ...({ popover: 'manual' } as {}), + ref: mergedRef, + role, + 'aria-labelledby': titleId, + 'aria-describedby': bodyId, + ...rest, + }), + { elementType: 'div' }, + ), + open, + intent, + bodyId, + titleId, + requestOpenChange, + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/useToastContextValues.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/useToastContextValues.ts new file mode 100644 index 00000000000000..51da303a2803ff --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/useToastContextValues.ts @@ -0,0 +1,16 @@ +'use client'; + +import * as React from 'react'; +import type { ToastContextValues, ToastState } from './Toast.types'; +import type { ToastContextValue } from './toastContext'; + +export const useToastContextValues = (state: ToastState): ToastContextValues => { + const { open, intent, bodyId, titleId, requestOpenChange } = state; + + const toast = React.useMemo( + () => ({ open, intent, bodyId, titleId, requestOpenChange }), + [open, intent, bodyId, titleId, requestOpenChange], + ); + + return { toast }; +}; 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..b827c85d6ca9ba --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/toast.ts @@ -0,0 +1,51 @@ +export { Toast, renderToast, useToast, useToastContextValues, ToastContext, useToastContext } from './components/Toast'; +export type { + ToastProps, + ToastState, + ToastSlots, + ToastContextValues, + ToastContextValue, + ToastOpenChangeData, + ToastIntent, +} from './components/Toast'; + +export { ToastTitle, renderToastTitle, useToastTitle } from './components/Toast/ToastTitle'; +export type { ToastTitleBaseProps, ToastTitleBaseState, ToastTitleSlots } from './components/Toast/ToastTitle'; + +export { ToastBody, renderToastBody, useToastBody } from './components/Toast/ToastBody'; +export type { ToastBodyBaseProps, ToastBodyBaseState, 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, + ToastContainerContextValues, + ToastContainerContextValue, +} from './components/Toast/ToastContainer'; + +// ─── Re-exported from @fluentui/react-toast ────────────────────────────────── +export { useToastController } 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..f7e9cd9132d82a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastCustomTimeout.stories.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; +import { + Toaster, + ToastTitle, + useToastController, + useToastContext, +} from '@fluentui/react-headless-components-preview/toast'; +import { popoverStyle, cardBase, intentAccent } from './ToastStoryShared'; + +const DismissButton = ({ children }: { children: React.ReactNode }) => { + const { requestOpenChange } = useToastContext(); + 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..92f036c6597f1e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDefault.stories.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { + Toaster, + ToastTitle, + ToastBody, + ToastFooter, + useToastController, +} from '@fluentui/react-headless-components-preview/toast'; +import { popoverStyle, cardBase, intentAccent } from './ToastStoryShared'; + +export const Default = (): React.ReactNode => { + const toasterId = React.useId(); + const { dispatchToast } = useToastController(toasterId); + + const notify = () => + dispatchToast( +
+ + Undo + + } + > + Email sent + + Subtitle} + className="text-sm text-zinc-600 mt-1" + > + This is a toast body + + + + + +
, + { intent: 'success' }, + ); + + 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..4031f7847c7f9b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDescription.md @@ -0,0 +1,72 @@ +# Headless Toast + +A **headless** Toast + Toaster system powered by the same `@fluentui/react-toast` state machine +as the styled Fluent v9 implementation, but with zero built-in CSS — all visual design comes from you. + +## How it works + +| Concept | Responsibility | +| --------------- | ----------------------------------------------------------------------- | +| State machine | `@fluentui/react-toast` (shared with Fluent v9) | +| Rendering shell | `ToastContainer` — renders a `div[popover="manual"]` per active toast | +| Positioning | **Your CSS** — target `[popover="manual"]` or a custom wrapper | +| Styling | **Your CSS / Tailwind / CSS-in-JS** — applied to the dispatched content | + +## Basic usage + +```tsx +// 1. Mount the Toaster (usually near the app root) +; + +// 2. Dispatch from anywhere +const { dispatchToast } = useToastController('app'); + +dispatchToast( +
+ Changes saved + Your file has been uploaded. +
, + { intent: 'success', timeout: 4000 }, +); +``` + +## Positioning toasts with CSS + +Because `ToastContainer` uses the [Popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API), +each toast is painted in the browser **top layer** outside the normal document flow. +Override the UA defaults to place toasts wherever you need: + +```css +/* Bottom-end corner — adjust to taste */ +[popover='manual'] { + position: fixed; + inset: auto 16px 16px auto; /* top right bottom left */ + padding: 0; + margin: 0; + border: none; + background: transparent; +} +``` + +## Stacking & animation + +Stacking multiple toasts (spacing them vertically) and entry / exit animations are **your responsibility**. +Common approaches: + +- **CSS `@starting-style` + `transition`** for fade/slide animations +- **JS layout** — measure rendered popovers and offset each one by the height of the previous +- **A portal wrapper** — forgo `Toaster` and use `useToaster` from `@fluentui/react-toast` directly + to render toasts inside a custom positioned container + +## Context API + +Sub-components (`ToastTitle`, `ToastBody`, `ToastFooter`) read from `ToastContext` automatically +when rendered inside dispatched content. Access the context yourself with `useToastContext()` to +build custom dismiss buttons without a dedicated `ToastTrigger`: + +```tsx +const DismissButton = () => { + const { requestOpenChange } = useToastContext(); + return ; +}; +``` 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..f6cb04c6d93630 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDismissAll.stories.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import { popoverStyle, cardBase, intentAccent } from './ToastStoryShared'; + +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..1110f4cf0701cb --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDismissToast.stories.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import { popoverStyle, cardBase, intentAccent } from './ToastStoryShared'; + +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..653358683b2c22 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDismissToastWithAction.stories.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import { + Toaster, + ToastTitle, + useToastController, + useToastContext, +} from '@fluentui/react-headless-components-preview/toast'; +import { popoverStyle, cardBase, intentAccent } from './ToastStoryShared'; + +/** + * A dismiss button that reads `requestOpenChange` from `ToastContext`. + * This is the headless equivalent of the styled layer's `ToastTrigger`. + */ +const DismissButton = ({ children }: { children: React.ReactNode }) => { + const { requestOpenChange } = useToastContext(); + 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 `useToastContext()` to access `requestOpenChange` inside the dispatched content.', + 'Calling it with `{ open: false }` 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..21a315d69b1849 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastIntent.stories.tsx @@ -0,0 +1,81 @@ +import * as React from 'react'; +import { Toaster, ToastTitle, Toast, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import type { ToastIntent } from '@fluentui/react-headless-components-preview/toast'; +import { popoverStyle, cardBase, intentAccent } from './ToastStoryShared'; + +const intentIcon: Record = { + success: '✓', + info: 'i', + warning: '⚠', + error: '✕', +}; + +export const Intent = (): React.ReactNode => { + const toasterId = React.useId(); + const { dispatchToast } = useToastController(toasterId); + const [intent, setIntent] = React.useState('success'); + + const notify = () => + dispatchToast( +
+ + {intentIcon[intent]} + + } + > + 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..c5c79b0ea68893 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastLifecycle.stories.tsx @@ -0,0 +1,119 @@ +import * as React from 'react'; +import { + Toaster, + ToastTitle, + ToastBody, + ToastFooter, + useToastController, +} from '@fluentui/react-headless-components-preview/toast'; +import type { ToastStatus } from '@fluentui/react-headless-components-preview/toast'; +import { popoverStyle, cardBase, intentAccent } from './ToastStoryShared'; + +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="text-sm text-zinc-600 mt-1" + > + 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..5766fa93953357 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastMultipleToasters.stories.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import { Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import { popoverStyle, cardBase, intentAccent } from './ToastStoryShared'; + +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..3004caaaf0c302 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastPauseAndPlay.stories.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import { Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import { popoverStyle, cardBase, intentAccent } from './ToastStoryShared'; + +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..c0362327929a6a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastPauseOnHover.stories.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import { popoverStyle, cardBase, intentAccent } from './ToastStoryShared'; + +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..8ee9ae3d269b71 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastPauseOnWindowBlur.stories.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import { popoverStyle, cardBase, intentAccent } from './ToastStoryShared'; + +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..a782eace877c86 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastProgressToast.stories.tsx @@ -0,0 +1,114 @@ +import * as React from 'react'; +import { + Toaster, + ToastTitle, + ToastBody, + ToastFooter, + useToastController, + useToastContext, +} from '@fluentui/react-headless-components-preview/toast'; +import { popoverStyle, cardBase, intentAccent } from './ToastStoryShared'; + +const intervalDelay = 100; +const intervalIncrement = 5; + +const DismissButton = ({ children }: { children: React.ReactNode }) => { + const { requestOpenChange } = useToastContext(); + 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/ToastStoryShared.ts b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastStoryShared.ts new file mode 100644 index 00000000000000..fa76e97140c75d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastStoryShared.ts @@ -0,0 +1,23 @@ +/** CSS applied in every story — positions each div[popover="manual"] in the bottom-end corner. */ +export const popoverStyle = ` + [popover="manual"] { + position: fixed; + inset: auto 16px 16px auto; + max-width: 360px; + padding: 0; + margin: 0; + border: none; + background: transparent; + } +`; + +/** Base Tailwind classes for the toast card wrapper. */ +export const cardBase = 'bg-white border border-zinc-200 rounded-lg shadow-lg p-4 min-w-[280px]'; + +/** Per-intent left-border accent class. */ +export const intentAccent: Record = { + success: 'border-l-4 border-l-green-500', + info: 'border-l-4 border-l-blue-500', + warning: 'border-l-4 border-l-yellow-500', + error: 'border-l-4 border-l-red-500', +}; 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..5cd429a698c9a1 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastUpdateToast.stories.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import { popoverStyle, cardBase, intentAccent } from './ToastStoryShared'; + +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..544552152599d3 --- /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: 'Headless Components/Toast', + component: Toast, + subcomponents: { + ToastTitle, + ToastBody, + ToastFooter, + Toaster, + }, + parameters: { + docs: { + description: { + component: descriptionMd, + }, + }, + }, +}; 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..6bae76e2d2601e 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,6 +17,12 @@ import type { Slot } from '@fluentui/react-utilities'; import type { SlotClassNames } from '@fluentui/react-utilities'; import type { TriggerProps } from '@fluentui/react-utilities'; +// @public (undocumented) +export interface DispatchToastOptions extends Partial> { + // (undocumented) + root?: RootSlot; +} + // @public export const renderToast_unstable: (state: ToastState, contextValues: ToastContextValues) => JSXElement; @@ -38,9 +44,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,6 +76,15 @@ 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; @@ -76,12 +103,25 @@ 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 export type ToasterProps = Omit, 'children'> & Partial & Pick & { announce?: Announce; @@ -120,6 +160,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 +201,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,12 +241,24 @@ 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 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; @@ -207,6 +272,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 +300,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/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/index.ts b/packages/react-components/react-toast/library/src/index.ts index 3e57242a6cefe6..7ee9e13581d1d6 100644 --- a/packages/react-components/react-toast/library/src/index.ts +++ b/packages/react-components/react-toast/library/src/index.ts @@ -1,5 +1,19 @@ -export { useToastController } from './state'; -export type { ToastPosition, ToastId, ToastOffset, ToastPoliteness, ToastStatus, ToastIntent } from './state'; +export { useToastController, useToaster } from './state'; +export type { + ToastPosition, + ToastId, + ToastOffset, + ToastPoliteness, + ToastStatus, + ToastIntent, + ToasterId, + 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'; @@ -11,26 +25,47 @@ export { toasterClassNames, } 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, From 6e6973dd1a1247add308d7d6e288f777c20ab7b5 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Tue, 12 May 2026 14:57:24 +0200 Subject: [PATCH 2/3] update snapshot --- .../react-headless-components-preview/library/etc/toast.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index eccd5168081c70..40c4e37854a45d 100644 --- 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 @@ -129,7 +129,7 @@ export type ToasterState = { toastsToRender: Map; isToastVisible: (toastId: ToastId) => boolean; tryRestoreFocus: () => void; - getStackTransform: (position: string, stackIndex: number) => string; + getStackTransform: (position: ToastPosition, stackIndex: number) => string; }; // @public (undocumented) From af7a2459c129e1e5f285503706d520eb7da1a098 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Thu, 14 May 2026 11:45:44 +0200 Subject: [PATCH 3/3] update toaster components, add tests, improve stories --- .../library/etc/toast.api.md | 170 ++---- .../components/Toast/AriaLive/AriaLive.tsx | 87 +++ .../src/components/Toast/AriaLive/index.ts | 2 + .../library/src/components/Toast/Toast.cy.tsx | 371 ++++++++++++ .../src/components/Toast/Toast.test.tsx | 70 +++ .../library/src/components/Toast/Toast.tsx | 4 +- .../src/components/Toast/Toast.types.ts | 37 +- .../components/Toast/ToastBody/ToastBody.tsx | 4 +- .../Toast/ToastBody/ToastBody.types.ts | 6 +- .../src/components/Toast/ToastBody/index.ts | 2 +- .../Toast/ToastBody/renderToastBody.ts | 8 +- .../Toast/ToastBody/useToastBody.ts | 25 +- .../ToastContainer/ToastContainer.test.tsx | 88 +++ .../ToastContainer/ToastContainer.types.ts | 45 +- .../components/Toast/ToastContainer/index.ts | 2 +- .../ToastContainer/renderToastContainer.tsx | 6 +- .../Toast/ToastContainer/useToastContainer.ts | 220 ++++---- .../useToastContainerContextValues.ts | 18 +- .../Toast/ToastFooter/renderToastFooter.ts | 6 +- .../Toast/ToastFooter/useToastFooter.ts | 18 +- .../Toast/ToastTitle/ToastTitle.tsx | 4 +- .../Toast/ToastTitle/ToastTitle.types.ts | 6 +- .../src/components/Toast/ToastTitle/index.ts | 2 +- .../Toast/ToastTitle/renderToastTitle.ts | 8 +- .../Toast/ToastTitle/useToastTitle.ts | 32 +- .../components/Toast/Toaster/Toaster.test.tsx | 35 ++ .../src/components/Toast/Toaster/Toaster.tsx | 35 +- .../components/Toast/Toaster/Toaster.types.ts | 24 +- .../src/components/Toast/Toaster/index.ts | 2 +- .../Toast/Toaster/renderToaster.tsx | 63 ++- .../components/Toast/Toaster/useToaster.ts | 28 - .../components/Toast/Toaster/useToaster.tsx | 103 ++++ .../library/src/components/Toast/index.ts | 20 +- .../src/components/Toast/renderToast.tsx | 14 +- .../src/components/Toast/toastContext.ts | 34 -- .../library/src/components/Toast/useToast.ts | 91 +-- .../components/Toast/useToastContextValues.ts | 16 - .../library/src/toast.ts | 19 +- .../src/Toast/ToastCustomTimeout.stories.tsx | 44 +- .../src/Toast/ToastDefault.stories.tsx | 42 +- .../stories/src/Toast/ToastDescription.md | 89 +-- .../src/Toast/ToastDismissAll.stories.tsx | 27 +- .../src/Toast/ToastDismissToast.stories.tsx | 19 +- .../ToastDismissToastWithAction.stories.tsx | 39 +- .../stories/src/Toast/ToastIntent.stories.tsx | 72 ++- .../src/Toast/ToastLifecycle.stories.tsx | 61 +- .../Toast/ToastMultipleToasters.stories.tsx | 39 +- .../src/Toast/ToastPauseAndPlay.stories.tsx | 29 +- .../src/Toast/ToastPauseOnHover.stories.tsx | 19 +- .../Toast/ToastPauseOnWindowBlur.stories.tsx | 19 +- .../src/Toast/ToastProgressToast.stories.tsx | 50 +- .../stories/src/Toast/ToastStoryShared.ts | 23 - .../src/Toast/ToastUpdateToast.stories.tsx | 25 +- .../stories/src/Toast/index.stories.tsx | 2 +- .../stories/src/Toast/toast.module.css | 533 ++++++++++++++++++ .../stories/src/Tooltip/tooltip.module.css | 1 + .../library/etc/react-toast.api.md | 79 +++ .../react-toast/library/src/Toaster.ts | 1 + .../library/src/components/Toaster/index.ts | 1 + .../react-toast/library/src/index.ts | 13 +- 60 files changed, 1900 insertions(+), 1052 deletions(-) create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/AriaLive/AriaLive.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/AriaLive/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.cy.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/ToastContainer.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.test.tsx delete mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/useToaster.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/useToaster.tsx delete mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/toastContext.ts delete mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Toast/useToastContextValues.ts delete mode 100644 packages/react-components/react-headless-components-preview/stories/src/Toast/ToastStoryShared.ts create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Toast/toast.module.css 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 index 40c4e37854a45d..673cbd8305a48e 100644 --- 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 @@ -4,68 +4,78 @@ ```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 { EventHandler } from '@fluentui/react-utilities'; 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 } from '@fluentui/react-toast'; -import { ToastBodyBaseState } from '@fluentui/react-toast'; +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 type { ToastIntent } 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 } from '@fluentui/react-toast'; -import { ToastTitleBaseState } 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 (undocumented) -export const renderToast: (state: ToastState, contextValues: ToastContextValues) => JSXElement; +// @public +export const renderToast: (state: ToastState) => JSXElement; // @public (undocumented) -export const renderToastBody: (state: ToastBodyBaseState) => JSXElement; +export const renderToastBody: (state: ToastBodyState) => JSXElement; // @public (undocumented) -export const renderToastContainer: (state: ToastContainerState, contextValues: ToastContextValues) => JSXElement; +export const renderToastContainer: (state: ToastContainerState, contextValues: ToastContainerContextValues) => JSXElement; -// @public (undocumented) +// @public export const renderToaster: (state: ToasterState) => JSXElement; -// @public (undocumented) -export const renderToastFooter: (state: ToastFooterState) => JSXElement; +export { renderToastFooter } // @public (undocumented) -export const renderToastTitle: (state: ToastTitleBaseState) => JSXElement; +export const renderToastTitle: (state: ToastTitleState) => JSXElement; // @public (undocumented) export const Toast: ForwardRefComponent; // @public (undocumented) -export const ToastBody: ForwardRefComponent; - -export { ToastBodyBaseProps } +export const ToastBody: ForwardRefComponent; -export { ToastBodyBaseState } +export { ToastBodyProps } export { ToastBodySlots } +export { ToastBodyState } + export { ToastChangeData } export { ToastChangeHandler } @@ -73,64 +83,37 @@ export { ToastChangeHandler } // @public (undocumented) export const ToastContainer: ForwardRefComponent; -// @public -export type ToastContainerProps = Omit, 'content'> & ToastData & { +export { ToastContainerContextValue } + +// @public (undocumented) +export type ToastContainerProps = Omit>, 'content'> & ToastData & { visible: boolean; - children?: React_2.ReactNode; tryRestoreFocus: () => void; + announce?: Announce; }; // @public (undocumented) export type ToastContainerSlots = { - root: Slot<'div'>; -}; - -// @public (undocumented) -export type ToastContainerState = ComponentState & { - intent: ToastIntent | undefined; - bodyId: string; - titleId: string; - close: () => void; + root: NonNullable>; }; // @public (undocumented) -export const ToastContext: React_2.Context; - -// @public (undocumented) -type ToastContextValue = { - open: boolean; - intent: ToastIntent | undefined; - bodyId: string; - titleId: string; - requestOpenChange: (data: ToastOpenChangeData) => void; +export type ToastContainerState = ComponentState & Pick & Pick & { + running: boolean; + nodeRef: React_2.Ref; }; -export { ToastContextValue as ToastContainerContextValue } -export { ToastContextValue } - -// @public (undocumented) -type ToastContextValues = { - toast: ToastContextValue; -}; -export { ToastContextValues as ToastContainerContextValues } -export { ToastContextValues } // @public -export const Toaster: ForwardRefComponent; +export const Toaster: { + (props: ToasterProps): JSXElement; + displayName: string; +}; export { ToasterId } -// @public -export type ToasterProps = { - toasterId?: ToasterId; -}; +export { ToasterProps } -// @public (undocumented) -export type ToasterState = { - toastsToRender: Map; - isToastVisible: (toastId: ToastId) => boolean; - tryRestoreFocus: () => void; - getStackTransform: (position: ToastPosition, stackIndex: number) => string; -}; +export { ToasterState } // @public (undocumented) export const ToastFooter: ForwardRefComponent; @@ -147,89 +130,50 @@ export { ToastImperativeRef } export { ToastIntent } -// @public (undocumented) -export type ToastOpenChangeData = { - type: 'dismissClick'; - open: false; - event: React_2.MouseEvent; -} | { - type: 'timeout'; - open: false; - event: null; -} | { - type: 'triggerClick'; - open: boolean; - event: React_2.MouseEvent; -}; - export { ToastPoliteness } export { ToastPosition } -// @public (undocumented) -export type ToastProps = ComponentProps & { - open?: boolean; - defaultOpen?: boolean; - onOpenChange?: EventHandler; - intent?: ToastIntent; - timeout?: number; -}; +export { ToastProps } -// @public (undocumented) -export type ToastSlots = { - root: Slot<'div'>; -}; +export { ToastSlots } -// @public (undocumented) -export type ToastState = ComponentState & { - open: boolean; - intent: ToastIntent | undefined; - bodyId: string; - titleId: string; - requestOpenChange: ToastContextValue['requestOpenChange']; -}; +export { ToastState } export { ToastStatus } // @public (undocumented) -export const ToastTitle: ForwardRefComponent; +export const ToastTitle: ForwardRefComponent; -export { ToastTitleBaseProps } - -export { ToastTitleBaseState } +export { ToastTitleProps } export { ToastTitleSlots } +export { ToastTitleState } + export { UpdateToastOptions } // @public (undocumented) export const useToast: (props: ToastProps, ref: React_2.Ref) => ToastState; -// @public (undocumented) -export const useToastBody: (props: ToastBodyBaseProps, ref: React_2.Ref) => ToastBodyBaseState; +export { useToastBody } // @public (undocumented) export const useToastContainer: (props: ToastContainerProps, ref: React_2.Ref) => ToastContainerState; -// @public (undocumented) -export const useToastContainerContextValues: (state: ToastContainerState) => ToastContextValues; - -// @public (undocumented) -export const useToastContext: () => ToastContextValue; +export { useToastContainerContext } // @public (undocumented) -export const useToastContextValues: (state: ToastState) => ToastContextValues; +export const useToastContainerContextValues: (state: ToastContainerState) => ToastContainerContextValues; export { useToastController } -// @public (undocumented) -export const useToaster: ({ toasterId }: ToasterProps, _ref: React_2.Ref) => ToasterState; +// @public +export const useToaster: (props: ToasterProps) => ToasterState; -// @public (undocumented) -export const useToastFooter: (props: ToastFooterProps, ref: React_2.Ref) => ToastFooterState; +export { useToastFooter } -// @public (undocumented) -export const useToastTitle: (props: ToastTitleBaseProps, ref: React_2.Ref) => ToastTitleBaseState; +export { useToastTitle } // (No @packageDocumentation comment for this package) 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 index d84f6bd6f291c9..91de01ae6c90aa 100644 --- 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 @@ -4,12 +4,10 @@ import * as React from 'react'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { ToastProps } from './Toast.types'; import { useToast } from './useToast'; -import { useToastContextValues } from './useToastContextValues'; import { renderToast } from './renderToast'; export const Toast: ForwardRefComponent = React.forwardRef((props, ref) => { const state = useToast(props, ref); - const contextValues = useToastContextValues(state); - return renderToast(state, contextValues); + 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 index 6bb04c85922614..d02a4161d2d042 100644 --- 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 @@ -1,36 +1 @@ -import type { ComponentProps, ComponentState, EventHandler, Slot } from '@fluentui/react-utilities'; -import type { ToastContextValue, ToastIntent, ToastOpenChangeData } from './toastContext'; - -export type { ToastIntent, ToastOpenChangeData }; - -export type ToastSlots = { - root: Slot<'div'>; -}; - -export type ToastProps = ComponentProps & { - /** Whether the toast is currently visible. Use with `onOpenChange` for controlled mode. */ - open?: boolean; - /** Initial open state for uncontrolled usage. */ - defaultOpen?: boolean; - /** Called when the toast should open or close. */ - onOpenChange?: EventHandler; - /** Semantic intent — affects accessible role and default icon in ToastTitle. */ - intent?: ToastIntent; - /** - * Auto-dismiss timeout in milliseconds. - * Negative value disables auto-dismiss (default: -1). - */ - timeout?: number; -}; - -export type ToastState = ComponentState & { - open: boolean; - intent: ToastIntent | undefined; - bodyId: string; - titleId: string; - requestOpenChange: ToastContextValue['requestOpenChange']; -}; - -export type ToastContextValues = { - toast: ToastContextValue; -}; +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 index de4bb2d8e8cc73..a0c5327e0b5175 100644 --- 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 @@ -2,11 +2,11 @@ import * as React from 'react'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; -import type { ToastBodyBaseProps } from './ToastBody.types'; +import type { ToastBodyProps } from './ToastBody.types'; import { useToastBody } from './useToastBody'; import { renderToastBody } from './renderToastBody'; -export const ToastBody: ForwardRefComponent = React.forwardRef((props, ref) => { +export const ToastBody: ForwardRefComponent = React.forwardRef((props, ref) => { const state = useToastBody(props, ref); return renderToastBody(state); }); 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 index 03ef0e0ad57bce..13ba2c9c2702c4 100644 --- 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 @@ -1 +1,5 @@ -export type { ToastBodyBaseProps, ToastBodyBaseState, ToastBodySlots } from '@fluentui/react-toast'; +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 index 2e8a6b94974da5..5e0c19a1304098 100644 --- 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 @@ -1,4 +1,4 @@ export { ToastBody } from './ToastBody'; export { renderToastBody } from './renderToastBody'; export { useToastBody } from './useToastBody'; -export type { ToastBodyBaseProps, ToastBodyBaseState, ToastBodySlots } from './ToastBody.types'; +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 index 67599580706bbc..1adecafed2812d 100644 --- 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 @@ -1,8 +1,6 @@ import { renderToastBody_unstable } from '@fluentui/react-toast'; -import type { ToastBodyBaseState, ToastBodyState } from '@fluentui/react-toast'; import type { JSXElement } from '@fluentui/react-utilities'; -// Cast strips the style-only `backgroundAppearance` field; renderToastBody_unstable -// does not use it in its render path (only the style hook reads it). -export const renderToastBody = (state: ToastBodyBaseState): JSXElement => - renderToastBody_unstable(state as ToastBodyState); +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 index ef4dd53ced26ea..1c3a72ede7823f 100644 --- 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 @@ -1,24 +1 @@ -'use client'; - -import type * as React from 'react'; -import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; -import type { ToastBodyBaseProps, ToastBodyBaseState } from '@fluentui/react-toast'; -import { useToastContext } from '../toastContext'; - -export const useToastBody = (props: ToastBodyBaseProps, ref: React.Ref): ToastBodyBaseState => { - const { bodyId } = useToastContext(); - - return { - components: { root: 'div', subtitle: 'div' }, - root: slot.always( - getIntrinsicElementProps('div', { - // FIXME: ref typed as HTMLElement upstream; cast to correct type - ref: ref as React.Ref, - id: bodyId, - ...props, - }), - { elementType: 'div' }, - ), - subtitle: slot.optional(props.subtitle, { elementType: 'div' }), - }; -}; +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.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/ToastContainer.types.ts index 558222b3483eee..0b6a6982e0542c 100644 --- 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 @@ -1,37 +1,32 @@ import type * as React from 'react'; import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; -import type { ToastData, ToastIntent } from '@fluentui/react-toast'; -import type { ToastContextValue } from '../toastContext'; -import type { ToastContextValues } from '../Toast.types'; +import type { Announce, ToastData } from '@fluentui/react-toast'; +import type { ToastContainerContextValue } from '@fluentui/react-toast'; -export type { ToastContextValues as ToastContainerContextValues }; +export type { ToastContainerContextValue }; + +export type ToastContainerContextValues = { + toast: ToastContainerContextValue; +}; export type ToastContainerSlots = { - root: Slot<'div'>; + root: NonNullable>; }; -/** - * All fields from the state machine's ToastData object, plus the rendering extras - * added by useToaster (visible, tryRestoreFocus). - * ComponentProps is required for ForwardRefComponent to infer - * the correct ref element type (HTMLDivElement) from the root slot. - */ -export type ToastContainerProps = Omit, 'content'> & +export type ToastContainerProps = Omit>, 'content'> & ToastData & { - /** Whether the toast is currently in the visible set. */ visible: boolean; - /** Children — the toast content dispatched via dispatchToast(). */ - children?: React.ReactNode; - /** Callback to restore focus after the toast closes. Provided by Toaster. */ 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 & { - intent: ToastIntent | undefined; - bodyId: string; - titleId: string; - /** Calls the state machine close(); used by context consumers (e.g. dismiss button). */ - close: () => void; -}; - -export type { ToastContextValue as ToastContainerContextValue }; +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 index a848f15a2ee05e..98cc812b136408 100644 --- 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 @@ -3,9 +3,9 @@ export { renderToastContainer } from './renderToastContainer'; export { useToastContainer } from './useToastContainer'; export { useToastContainerContextValues } from './useToastContainerContextValues'; export type { + ToastContainerContextValues, ToastContainerProps, ToastContainerSlots, ToastContainerState, - ToastContainerContextValues, 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 index 72567c7c875fc9..02428bd5ce4bcb 100644 --- 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 @@ -3,8 +3,8 @@ 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'; -import { ToastContext } from '../toastContext'; export const renderToastContainer = ( state: ToastContainerState, @@ -13,8 +13,8 @@ export const renderToastContainer = ( 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 index 09e2f9684e5cfb..1c7834391c45e1 100644 --- 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 @@ -1,84 +1,80 @@ 'use client'; import * as React from 'react'; -import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; -import { - getIntrinsicElementProps, - slot, - useEventCallback, - useId, - useIsomorphicLayoutEffect, - useMergedRefs, -} from '@fluentui/react-utilities'; +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'; -type PopoverElement = HTMLDivElement & { - showPopover?: () => void; - hidePopover?: () => void; +const intentPolitenessMap: Record, ToastPoliteness> = { + success: 'assertive', + warning: 'assertive', + error: 'assertive', + info: 'polite', }; export const useToastContainer = (props: ToastContainerProps, ref: React.Ref): ToastContainerState => { const { visible, - close: stateClose, + children, + close: closeProp, remove, - intent, - timeout: timeoutProp = -1, - pauseOnHover = false, - pauseOnWindowBlur = false, + updateId, + onStatusChange, + data, + timeout: timerTimeout = -1, + intent = 'info', + politeness, + pauseOnHover, + pauseOnWindowBlur, imperativeRef, tryRestoreFocus, - onStatusChange, - // State machine fields — destructured to keep them out of ...rest (not passed to the DOM). + announce, content: _content, - toastId: _toastId, - toasterId: _toasterId, - position: _position, - order: _order, - updateId: _updateId, - priority: _priority, - politeness: _politeness, - data: _data, ...rest } = props; - const { targetDocument } = useFluent(); - const win = targetDocument?.defaultView; - - const toastRef = React.useRef(null); - const mergedRef = useMergedRefs(ref, toastRef) as React.Ref; - const titleId = useId('toast-title-'); - const bodyId = useId('toast-body-'); - + 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); - // Tracks whether pause() was called via the imperative ref (as opposed to hover/blur). const imperativePauseRef = React.useRef(false); - // Tracks whether focus was inside the toast at the time it closed (for focus restoration). - const focusedBeforeCloseRef = React.useRef(false); + const focusedToastBeforeClose = React.useRef(false); const close = useEventCallback(() => { - const activeEl = targetDocument?.activeElement; - if (activeEl && toastRef.current?.contains(activeEl)) { - focusedBeforeCloseRef.current = true; + const activeElement = targetDocument?.activeElement; + if (activeElement && toastRef.current?.contains(activeElement)) { + focusedToastBeforeClose.current = true; } - stateClose(); + + closeProp(); }); + const reportStatus = useEventCallback((status: ToastStatus) => onStatusChange?.(null, { status, ...props })); + const pause = useEventCallback(() => setRunning(false)); const play = useEventCallback(() => { - if (imperativePauseRef.current || timeoutProp < 0) { + if (imperativePauseRef.current) { return; } - const containsActive = !!toastRef.current?.contains(targetDocument?.activeElement ?? null); + + if (timerTimeout < 0) { + setRunning(true); + return; + } + + const activeElement = targetDocument?.activeElement; + const containsActive = !!(activeElement && toastRef.current?.contains(activeElement)); if (!containsActive) { setRunning(true); } }); - const pause = useEventCallback(() => setRunning(false)); - - // Expose imperative focus/pause/play to the state machine. React.useImperativeHandle(imperativeRef, () => ({ - focus: () => toastRef.current?.focus(), + focus: () => { + toastRef.current?.focus(); + }, play: () => { imperativePauseRef.current = false; play(); @@ -89,110 +85,118 @@ export const useToastContainer = (props: ToastContainerProps, ref: React.Ref { - if (!running || !win || timeoutProp < 0) { + return () => reportStatus('unmounted'); + }, [reportStatus]); + + React.useEffect(() => { + if (!targetDocument || !pauseOnWindowBlur) { return; } - const id = win.setTimeout(() => { - close(); - setRunning(false); - }, timeoutProp); - return () => win.clearTimeout(id); - }, [running, win, timeoutProp, close]); - - // Drive the Popover API from the state machine's visible flag. - // useIsomorphicLayoutEffect prevents a paint where the element is in the DOM - // but not yet in the top layer. - useIsomorphicLayoutEffect(() => { - const el = toastRef.current; - if (!el || !('showPopover' in el)) { + + 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; } - if (visible) { - if (!el.matches(':popover-open')) { - el.showPopover!(); - play(); // start the auto-dismiss timer as soon as the toast appears - } - } else { - if (el.matches(':popover-open')) { - el.hidePopover!(); - } + play(); + reportStatus('visible'); + }, [visible, play, reportStatus, updateId]); + + React.useEffect(() => { + if (!running || timerTimeout < 0 || !targetDocument?.defaultView) { + return; } - }, [visible, play]); - // Remove the toast from the state machine once it's no longer visible. - // Without animation, removal is immediate after hide (replaces CollapseDelayed exit callback). + 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]); + }, [visible, remove, reportStatus]); - // Report 'unmounted' lifecycle status when the component is destroyed. - const reportStatus = useEventCallback(() => onStatusChange?.(null, { status: 'unmounted', ...props })); - React.useEffect(() => reportStatus, [reportStatus]); - - // Restore focus when the toast that had focus is closed. React.useEffect(() => { return () => { - if (focusedBeforeCloseRef.current) { - focusedBeforeCloseRef.current = false; + if (focusedToastBeforeClose.current) { + focusedToastBeforeClose.current = false; tryRestoreFocus(); } }; }, [tryRestoreFocus]); - const onMouseEnter = useEventCallback(() => { + 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(() => { + const onMouseLeave = useEventCallback((e: React.MouseEvent) => { if (pauseOnHover) { play(); } + userRootSlot?.onMouseLeave?.(e); }); - // Pause/resume when the browser window loses/regains focus. - React.useEffect(() => { - if (!pauseOnWindowBlur || !win) { - return; + const onKeyDown = useEventCallback((e: React.KeyboardEvent) => { + if (e.key === Delete) { + e.preventDefault(); + close(); } - win.addEventListener('focus', play); - win.addEventListener('blur', pause); - return () => { - win.removeEventListener('focus', play); - win.removeEventListener('blur', pause); - }; - }, [pauseOnWindowBlur, win, play, pause]); - const role = intent === 'error' || intent === 'warning' ? 'alert' : 'status'; + userRootSlot?.onKeyDown?.(e); + }); return { - components: { root: 'div' }, + components: { + root: 'div', + }, root: slot.always( getIntrinsicElementProps('div', { - // popover="manual": top-layer placement with no light-dismiss. - // Only explicit close() or timeout dismisses the toast. - ...({ popover: 'manual' } as {}), - ref: mergedRef, - role, - tabIndex: -1, + ref: useMergedRefs(ref, toastRef) as React.Ref, + children, + tabIndex: 0, + role: 'listitem', 'aria-labelledby': titleId, 'aria-describedby': bodyId, + ...rest, + ...userRootSlot, onMouseEnter, onMouseLeave, - ...rest, + onKeyDown, }), { elementType: 'div' }, ), + running, + visible, + remove, + close, + updateId, + nodeRef: toastRef, intent, - bodyId, titleId, - close, + 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 index 6264c6b11d191f..20d8b83ee81e88 100644 --- 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 @@ -2,26 +2,18 @@ import * as React from 'react'; import type { ToastContainerContextValues, ToastContainerState } from './ToastContainer.types'; -import type { ToastContextValue } from '../toastContext'; export const useToastContainerContextValues = (state: ToastContainerState): ToastContainerContextValues => { - const { intent, bodyId, titleId, close } = state; + const { close, intent, titleId, bodyId } = state; - const toast = React.useMemo( + const toast = React.useMemo( () => ({ - open: true, + close, intent, - bodyId, titleId, - // ToastContext.requestOpenChange is used by child components (e.g. a dismiss button) - // to close the toast. In the Toaster DX, closing routes through the state machine. - requestOpenChange: data => { - if (!data.open) { - close(); - } - }, + bodyId, }), - [intent, bodyId, titleId, close], + [close, intent, titleId, bodyId], ); return { toast }; 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 index 7687fc44922260..e85df81c6a19b2 100644 --- 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 @@ -1,5 +1 @@ -import { renderToastFooter_unstable } from '@fluentui/react-toast'; -import type { ToastFooterState } from '@fluentui/react-toast'; -import type { JSXElement } from '@fluentui/react-utilities'; - -export const renderToastFooter = (state: ToastFooterState): JSXElement => renderToastFooter_unstable(state); +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 index 37b6e542b50bf0..c00656749180cd 100644 --- 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 @@ -1,17 +1 @@ -import type * as React from 'react'; -import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; -import type { ToastFooterProps, ToastFooterState } from '@fluentui/react-toast'; - -export const useToastFooter = (props: ToastFooterProps, ref: React.Ref): ToastFooterState => { - return { - components: { root: 'div' }, - root: slot.always( - getIntrinsicElementProps('div', { - // FIXME: ref typed as HTMLElement upstream; cast to correct type - ref: ref as React.Ref, - ...props, - }), - { elementType: 'div' }, - ), - }; -}; +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 index e533933fb48652..e322f008912448 100644 --- 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 @@ -2,11 +2,11 @@ import * as React from 'react'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; -import type { ToastTitleBaseProps } from './ToastTitle.types'; +import type { ToastTitleProps } from './ToastTitle.types'; import { useToastTitle } from './useToastTitle'; import { renderToastTitle } from './renderToastTitle'; -export const ToastTitle: ForwardRefComponent = React.forwardRef((props, ref) => { +export const ToastTitle: ForwardRefComponent = React.forwardRef((props, ref) => { const state = useToastTitle(props, ref); return renderToastTitle(state); }); 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 index 5d6afe52b2ca81..61d0ac9d9f39f1 100644 --- 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 @@ -1 +1,5 @@ -export type { ToastTitleBaseProps, ToastTitleBaseState, ToastTitleSlots } from '@fluentui/react-toast'; +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 index 2ce46c92971bf1..222539f40b00fc 100644 --- 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 @@ -1,4 +1,4 @@ export { ToastTitle } from './ToastTitle'; export { renderToastTitle } from './renderToastTitle'; export { useToastTitle } from './useToastTitle'; -export type { ToastTitleBaseProps, ToastTitleBaseState, ToastTitleSlots } from './ToastTitle.types'; +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 index 62ee30ecbf2c20..cf8ed3720650c0 100644 --- 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 @@ -1,8 +1,6 @@ import { renderToastTitle_unstable } from '@fluentui/react-toast'; -import type { ToastTitleBaseState, ToastTitleState } from '@fluentui/react-toast'; import type { JSXElement } from '@fluentui/react-utilities'; -// Cast strips the style-only `backgroundAppearance` field; renderToastTitle_unstable -// does not use it in its render path (only the style hook reads it). -export const renderToastTitle = (state: ToastTitleBaseState): JSXElement => - renderToastTitle_unstable(state as ToastTitleState); +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 index fdeb1c1db46321..28da64324363cd 100644 --- 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 @@ -1,31 +1 @@ -'use client'; - -import type * as React from 'react'; -import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; -import type { ToastTitleBaseProps, ToastTitleBaseState } from '@fluentui/react-toast'; -import { useToastContext } from '../toastContext'; - -export const useToastTitle = (props: ToastTitleBaseProps, ref: React.Ref): ToastTitleBaseState => { - const { intent, titleId } = useToastContext(); - - return { - components: { root: 'div', media: 'div', action: 'div' }, - root: slot.always( - getIntrinsicElementProps('div', { - // FIXME: ref typed as HTMLElement upstream; cast to correct type - ref: ref as React.Ref, - id: titleId, - ...props, - }), - { elementType: 'div' }, - ), - // Render the media slot by default only when an intent is set — the - // styled layer (or the consumer) fills it with the appropriate icon. - media: slot.optional(props.media, { - renderByDefault: !!intent, - elementType: 'div', - }), - action: slot.optional(props.action, { elementType: 'div' }), - intent, - }; -}; +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 index 11883cbf50b8a8..bf3f6029a6dee6 100644 --- 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 @@ -1,40 +1,19 @@ 'use client'; -import * as React from 'react'; -import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; import type { ToasterProps } from './Toaster.types'; import { useToaster } from './useToaster'; import { renderToaster } from './renderToaster'; /** - * Headless Toaster — subscribes to the event-driven toast state machine and - * renders a `ToastContainer` (div[popover="manual"]) for each active toast. + * Toaster — subscribes to the event-driven toast state machine and + * renders toasts in a Portal with position-based slot containers. * - * Position and offset are intentionally omitted: the Popover API places each - * toast in the browser top layer, so layout is pure CSS. - * - * Pair with `useToastController` from `@fluentui/react-toast` to dispatch and - * dismiss toasts imperatively. - * - * @example - * ```tsx - * // App root - * - * - * // Anywhere inside FluentProvider - * const { dispatchToast } = useToastController('app'); - * dispatchToast( - * <> - * Saved - * Your changes have been saved. - * , - * { intent: 'success', timeout: 3000 }, - * ); - * ``` + * Pair with useToastController from @fluentui/react-toast to dispatch and dismiss toasts imperatively. */ -export const Toaster: ForwardRefComponent = React.forwardRef(({ toasterId }, _ref) => { - const state = useToaster({ toasterId }, _ref); +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 index cc48942cb5bd51..6de05d98f5611b 100644 --- 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 @@ -1,17 +1,13 @@ -import type { ToastData, ToastId, ToastPosition, ToasterId } from '@fluentui/react-toast'; +import type { Slot } from '@fluentui/react-utilities'; -/** - * Headless Toaster props. Position, offset, and aria-live are omitted because - * the Popover API places each toast in the browser top layer independently — - * consumers control position through CSS (e.g. :popover-open { inset: auto; ... }). - */ -export type ToasterProps = { - toasterId?: ToasterId; -}; +export type { ToasterSlots, ToasterProps, ToasterState } from '@fluentui/react-toast'; -export type ToasterState = { - toastsToRender: Map; - isToastVisible: (toastId: ToastId) => boolean; - tryRestoreFocus: () => void; - getStackTransform: (position: ToastPosition, stackIndex: number) => string; +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 index 39d02f7e5d957a..bd6856eb7f4603 100644 --- 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 @@ -1,4 +1,4 @@ export { Toaster } from './Toaster'; export { renderToaster } from './renderToaster'; export { useToaster } from './useToaster'; -export type { ToasterProps, ToasterState } from './Toaster.types'; +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 index 441d8f0651c3e5..7d03785068067d 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/renderToaster.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/renderToaster.tsx @@ -1,36 +1,51 @@ /** @jsxRuntime automatic */ /** @jsxImportSource @fluentui/react-jsx-runtime */ -import * as React from 'react'; +import { assertSlots } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; -import type { ToasterState } from './Toaster.types'; -import { ToastContainer } from '../ToastContainer'; +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 { toastsToRender, isToastVisible, tryRestoreFocus, getStackTransform } = state; + const { announceRef, renderAriaLive, inline, mountNode } = state; + assertSlots(state); - return ( + const hasToasts = + !!state.bottomStart || !!state.bottomEnd || !!state.topStart || !!state.topEnd || !!state.top || !!state.bottom; + + const ariaLive = renderAriaLive ? : null; + const positionSlots = ( <> - {Array.from(toastsToRender.entries()).flatMap(([position, toasts]) => - toasts.map((toast, index) => { - const stackIndex = position.startsWith('bottom') ? toasts.length - 1 - index : index; + {state.bottom ? : null} + {state.bottomStart ? : null} + {state.bottomEnd ? : null} + {state.topStart ? : null} + {state.topEnd ? : null} + {state.top ? : null} + + ); - return ( - - {toast.content as React.ReactNode} - - ); - }), - )} + 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.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/useToaster.ts deleted file mode 100644 index 062511d51b5778..00000000000000 --- a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/useToaster.ts +++ /dev/null @@ -1,28 +0,0 @@ -'use client'; - -import * as React from 'react'; -import { useToaster as useToasterState } from '@fluentui/react-toast'; -import type { ToastPosition } from '@fluentui/react-toast'; -import type { ToasterProps, ToasterState } from './Toaster.types'; - -export const useToaster = ({ toasterId }: ToasterProps, _ref: React.Ref): ToasterState => { - const { toastsToRender, isToastVisible, tryRestoreFocus } = useToasterState({ toasterId }); - - const getStackTransform = React.useCallback((position: ToastPosition, stackIndex: number): string => { - if (stackIndex === 0) { - return 'translateY(0)'; - } - - const direction = position.startsWith('bottom') ? '-' : ''; - // 100% uses each toast's own height. This keeps spacing consistent even when - // content varies slightly across toasts. - return `translateY(calc(${direction}${stackIndex} * (100% + 8px)))`; - }, []); - - return { - toastsToRender, - isToastVisible, - tryRestoreFocus, - getStackTransform, - }; -}; 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 index a194bae184bb8d..6993862db1159e 100644 --- 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 @@ -1,18 +1,15 @@ -// ─── Compound Toast (standalone open/close) ────────────────────────────────── +// ─── Compound Toast content ────────────────────────────────────────────────── export { Toast } from './Toast'; export { renderToast } from './renderToast'; export { useToast } from './useToast'; -export { useToastContextValues } from './useToastContextValues'; -export { ToastContext, useToastContext } from './toastContext'; -export type { ToastProps, ToastState, ToastSlots, ToastContextValues } from './Toast.types'; -export type { ToastContextValue, ToastOpenChangeData, ToastIntent } from './toastContext'; +export type { ToastProps, ToastState, ToastSlots } from './Toast.types'; // ─── Sub-components ─────────────────────────────────────────────────────────── export { ToastTitle, renderToastTitle, useToastTitle } from './ToastTitle'; -export type { ToastTitleBaseProps, ToastTitleBaseState, ToastTitleSlots } from './ToastTitle'; +export type { ToastTitleProps, ToastTitleState, ToastTitleSlots } from './ToastTitle'; export { ToastBody, renderToastBody, useToastBody } from './ToastBody'; -export type { ToastBodyBaseProps, ToastBodyBaseState, ToastBodySlots } from './ToastBody'; +export type { ToastBodyProps, ToastBodyState, ToastBodySlots } from './ToastBody'; export { ToastFooter, renderToastFooter, useToastFooter } from './ToastFooter'; export type { ToastFooterProps, ToastFooterSlots, ToastFooterState } from './ToastFooter'; @@ -27,19 +24,14 @@ export { useToastContainer, useToastContainerContextValues, } from './ToastContainer'; -export type { - ToastContainerProps, - ToastContainerSlots, - ToastContainerState, - ToastContainerContextValues, - ToastContainerContextValue, -} 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, 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 index 1ecbd1f0a60e33..ad045ee391772b 100644 --- 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 @@ -3,15 +3,13 @@ import { assertSlots } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; -import type { ToastContextValues, ToastSlots, ToastState } from './Toast.types'; -import { ToastContext } from './toastContext'; +import type { ToastState, ToastSlots } from './Toast.types'; -export const renderToast = (state: ToastState, contextValues: ToastContextValues): JSXElement => { +/** + * Render the final JSX of Toast + */ +export const renderToast = (state: ToastState): JSXElement => { assertSlots(state); - return ( - - - - ); + return ; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/toastContext.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/toastContext.ts deleted file mode 100644 index ced39cabadc94d..00000000000000 --- a/packages/react-components/react-headless-components-preview/library/src/components/Toast/toastContext.ts +++ /dev/null @@ -1,34 +0,0 @@ -'use client'; - -import * as React from 'react'; -import type { ToastIntent } from '@fluentui/react-toast'; - -export type { ToastIntent }; - -export type ToastOpenChangeData = - | { type: 'dismissClick'; open: false; event: React.MouseEvent } - // Auto-dismiss has no DOM event; null satisfies the EventData constraint. - | { type: 'timeout'; open: false; event: null } - | { type: 'triggerClick'; open: boolean; event: React.MouseEvent }; - -export type ToastContextValue = { - open: boolean; - intent: ToastIntent | undefined; - bodyId: string; - titleId: string; - requestOpenChange: (data: ToastOpenChangeData) => void; -}; - -const defaultToastContextValue: ToastContextValue = { - open: false, - intent: undefined, - bodyId: '', - titleId: '', - requestOpenChange() { - /* noop */ - }, -}; - -export const ToastContext = React.createContext(undefined); - -export const useToastContext = (): ToastContextValue => React.useContext(ToastContext) ?? defaultToastContextValue; 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 index 058b4cf42121e6..369c1b02b09c95 100644 --- 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 @@ -1,93 +1,14 @@ 'use client'; -import * as React from 'react'; -import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; -import { - getIntrinsicElementProps, - slot, - useControllableState, - useEventCallback, - useId, - useIsomorphicLayoutEffect, - useMergedRefs, -} from '@fluentui/react-utilities'; +import type * as React from 'react'; +import { useToastBase_unstable } from '@fluentui/react-toast'; + import type { ToastProps, ToastState } from './Toast.types'; -import type { ToastOpenChangeData } from './toastContext'; export const useToast = (props: ToastProps, ref: React.Ref): ToastState => { - const { open: openProp, defaultOpen, onOpenChange, intent, timeout = -1, ...rest } = props; - const { targetDocument } = useFluent(); - - const [open, setOpen] = useControllableState({ - state: openProp, - defaultState: defaultOpen, - initialState: false, - }); - - const toastRef = React.useRef(null); - const mergedRef = useMergedRefs(ref, toastRef) as React.Ref; - - const titleId = useId('toast-title-'); - const bodyId = useId('toast-body-'); - - const requestOpenChange = useEventCallback((data: ToastOpenChangeData) => { - // EventHandler signature is (ev, data). For timeout events there is no - // DOM event; the double assertion satisfies the type without losing information. - onOpenChange?.(data.event as unknown as React.SyntheticEvent, data); - setOpen(data.open); - }); - - // Drive the Popover API from React open state. - // useIsomorphicLayoutEffect keeps the element in the top layer synchronously - // after the DOM update, preventing a flash where the element is in the DOM - // but not yet visible. - useIsomorphicLayoutEffect(() => { - const el = toastRef.current; - if (!el) { - return; - } - - if (open && !el.matches(':popover-open')) { - el.showPopover(); - } else if (!open && el.matches(':popover-open')) { - el.hidePopover(); - } - }, [open]); - - // Auto-dismiss after `timeout` ms. Uses the window from FluentProvider so - // timers are scoped to the correct browsing context (e.g. iframes). - const win = targetDocument?.defaultView; - React.useEffect(() => { - if (!open || timeout < 0 || !win) { - return; - } - const id = win.setTimeout(() => requestOpenChange({ type: 'timeout', open: false, event: null }), timeout); - return () => win.clearTimeout(id); - }, [open, timeout, requestOpenChange, win]); + const state = useToastBase_unstable(props, ref); - // error/warning → role="alert" (assertive); info/success/undefined → role="status" (polite) - const role = intent === 'error' || intent === 'warning' ? 'alert' : 'status'; + Object.assign(state.root, { 'data-intent': state.intent }); - return { - components: { root: 'div' }, - root: slot.always( - getIntrinsicElementProps('div', { - // popover="manual": top-layer placement, no light-dismiss on outside click. - // Users retain full control; only explicit calls to requestOpenChange or - // the timeout dismiss the toast. - ...({ popover: 'manual' } as {}), - ref: mergedRef, - role, - 'aria-labelledby': titleId, - 'aria-describedby': bodyId, - ...rest, - }), - { elementType: 'div' }, - ), - open, - intent, - bodyId, - titleId, - requestOpenChange, - }; + return state; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/useToastContextValues.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/useToastContextValues.ts deleted file mode 100644 index 51da303a2803ff..00000000000000 --- a/packages/react-components/react-headless-components-preview/library/src/components/Toast/useToastContextValues.ts +++ /dev/null @@ -1,16 +0,0 @@ -'use client'; - -import * as React from 'react'; -import type { ToastContextValues, ToastState } from './Toast.types'; -import type { ToastContextValue } from './toastContext'; - -export const useToastContextValues = (state: ToastState): ToastContextValues => { - const { open, intent, bodyId, titleId, requestOpenChange } = state; - - const toast = React.useMemo( - () => ({ open, intent, bodyId, titleId, requestOpenChange }), - [open, intent, bodyId, titleId, requestOpenChange], - ); - - return { toast }; -}; 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 index b827c85d6ca9ba..5144f6a2b9f3ca 100644 --- a/packages/react-components/react-headless-components-preview/library/src/toast.ts +++ b/packages/react-components/react-headless-components-preview/library/src/toast.ts @@ -1,19 +1,11 @@ -export { Toast, renderToast, useToast, useToastContextValues, ToastContext, useToastContext } from './components/Toast'; -export type { - ToastProps, - ToastState, - ToastSlots, - ToastContextValues, - ToastContextValue, - ToastOpenChangeData, - ToastIntent, -} from './components/Toast'; +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 { ToastTitleBaseProps, ToastTitleBaseState, ToastTitleSlots } from './components/Toast/ToastTitle'; +export type { ToastTitleProps, ToastTitleState, ToastTitleSlots } from './components/Toast/ToastTitle'; export { ToastBody, renderToastBody, useToastBody } from './components/Toast/ToastBody'; -export type { ToastBodyBaseProps, ToastBodyBaseState, ToastBodySlots } 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'; @@ -31,12 +23,11 @@ export type { ToastContainerProps, ToastContainerSlots, ToastContainerState, - ToastContainerContextValues, ToastContainerContextValue, } from './components/Toast/ToastContainer'; // ─── Re-exported from @fluentui/react-toast ────────────────────────────────── -export { useToastController } from '@fluentui/react-toast'; +export { useToastController, useToastContainerContext } from '@fluentui/react-toast'; export type { ToastId, ToasterId, 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 index f7e9cd9132d82a..acc43ce879f857 100644 --- 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 @@ -1,20 +1,17 @@ import * as React from 'react'; import { + Toast, Toaster, ToastTitle, useToastController, - useToastContext, + useToastContainerContext, } from '@fluentui/react-headless-components-preview/toast'; -import { popoverStyle, cardBase, intentAccent } from './ToastStoryShared'; +import styles from './toast.module.css'; const DismissButton = ({ children }: { children: React.ReactNode }) => { - const { requestOpenChange } = useToastContext(); + const { close } = useToastContainerContext(); return ( - ); @@ -27,37 +24,24 @@ export const CustomTimeout = (): React.ReactNode => { const notify = () => dispatchToast( -
- Dismiss} - > + + Dismiss}> {timeout >= 0 ? `Custom timeout ${timeout} ms` : 'Dismiss manually'} -
, + , { timeout, intent: 'info' }, ); return ( <> - - -
-