diff --git a/change/@fluentui-react-headless-components-preview-7c305eb6-2458-4126-b561-228475292c80.json b/change/@fluentui-react-headless-components-preview-7c305eb6-2458-4126-b561-228475292c80.json new file mode 100644 index 0000000000000..37cadcb05535d --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-7c305eb6-2458-4126-b561-228475292c80.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: add headless TeachingPopover composed on top of the headless Popover and the v9 react-teaching-popover base hooks", + "packageName": "@fluentui/react-headless-components-preview", + "email": "viktorgenaev@microsoft.com", + "dependentChangeType": "patch" +} 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 0000000000000..9594a324aaac4 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-86a2a1c9-dfd8-4e9e-8286-6567597a6749.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: add Toast component", + "packageName": "@fluentui/react-headless-components-preview", + "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 4f6690a61813a..81812ffed92a0 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 @@ -20,13 +20,13 @@ import * as Link from '@fluentui/react-headless-components-preview/link'; import * as Menu from '@fluentui/react-headless-components-preview/menu'; import * as MessageBar from '@fluentui/react-headless-components-preview/message-bar'; import * as Nav from '@fluentui/react-headless-components-preview/nav'; -import * as ProgressBar from '@fluentui/react-headless-components-preview/progress-bar'; import * as Persona from '@fluentui/react-headless-components-preview/persona'; import * as Popover from '@fluentui/react-headless-components-preview/popover'; +import * as ProgressBar from '@fluentui/react-headless-components-preview/progress-bar'; import * as Provider from '@fluentui/react-headless-components-preview/provider'; import * as RadioGroup from '@fluentui/react-headless-components-preview/radio-group'; -import * as RatingDisplay from '@fluentui/react-headless-components-preview/rating-display'; import * as Rating from '@fluentui/react-headless-components-preview/rating'; +import * as RatingDisplay from '@fluentui/react-headless-components-preview/rating-display'; import * as SearchBox from '@fluentui/react-headless-components-preview/search-box'; import * as Select from '@fluentui/react-headless-components-preview/select'; import * as Skeleton from '@fluentui/react-headless-components-preview/skeleton'; @@ -37,7 +37,9 @@ import * as Switch from '@fluentui/react-headless-components-preview/switch'; import * as TabList from '@fluentui/react-headless-components-preview/tab-list'; import * as Tag from '@fluentui/react-headless-components-preview/tag'; import * as TagGroup from '@fluentui/react-headless-components-preview/tag-group'; +import * as TeachingPopover from '@fluentui/react-headless-components-preview/teaching-popover'; 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'; @@ -70,8 +72,8 @@ console.log({ ProgressBar, Provider, RadioGroup, - RatingDisplay, Rating, + RatingDisplay, SearchBox, Select, Skeleton, @@ -82,7 +84,9 @@ console.log({ TabList, Tag, TagGroup, + TeachingPopover, Textarea, + Toast, ToggleButton, Toolbar, Tooltip, diff --git a/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md b/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md new file mode 100644 index 0000000000000..acff453f9affc --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md @@ -0,0 +1,312 @@ +## 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 { ARIAButtonType } from '@fluentui/react-aria'; +import type { ComponentProps } from '@fluentui/react-utilities'; +import type { ComponentState } from '@fluentui/react-utilities'; +import type { EventData } from '@fluentui/react-utilities'; +import type { EventHandler } from '@fluentui/react-utilities'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { PopoverContextValue as PopoverContextValue_2 } from '@fluentui/react-popover'; +import type { PopoverTriggerChildProps } from '@fluentui/react-popover'; +import { PositioningShorthand } from '@fluentui/react-positioning'; +import * as React_2 from 'react'; +import { renderTeachingPopoverBody_unstable as renderTeachingPopoverBody } from '@fluentui/react-teaching-popover'; +import { renderTeachingPopoverCarouselCard_unstable as renderTeachingPopoverCarouselCard } from '@fluentui/react-teaching-popover'; +import { renderTeachingPopoverCarouselNav_unstable as renderTeachingPopoverCarouselNav } from '@fluentui/react-teaching-popover'; +import { renderTeachingPopoverCarouselPageCount_unstable as renderTeachingPopoverCarouselPageCount } from '@fluentui/react-teaching-popover'; +import type { Slot } from '@fluentui/react-utilities'; +import { TeachingPopoverBodyProps } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverBodySlots } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverBodyState } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverCarouselCardProps } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverCarouselCardSlots } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverCarouselCardState } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverCarouselFooterButtonBaseProps as TeachingPopoverCarouselFooterButtonProps } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverCarouselFooterButtonSlots } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverCarouselFooterButtonBaseState as TeachingPopoverCarouselFooterButtonState } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverCarouselNavButtonBaseProps as TeachingPopoverCarouselNavButtonProps } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverCarouselNavButtonSlots } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverCarouselNavButtonBaseState as TeachingPopoverCarouselNavButtonState } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverCarouselNavBaseProps as TeachingPopoverCarouselNavProps } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverCarouselNavSlots } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverCarouselNavBaseState as TeachingPopoverCarouselNavState } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverCarouselPageCountProps } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverCarouselPageCountRenderFunction } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverCarouselPageCountSlots } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverCarouselPageCountState } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverCarouselBaseProps as TeachingPopoverCarouselProps } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverCarouselSlots } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverCarouselBaseState as TeachingPopoverCarouselState } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverFooterBaseProps as TeachingPopoverFooterProps } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverFooterBaseState as TeachingPopoverFooterState } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverHeaderBaseProps as TeachingPopoverHeaderProps } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverHeaderSlots } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverHeaderBaseState as TeachingPopoverHeaderState } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverTitleBaseProps as TeachingPopoverTitleProps } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverTitleSlots } from '@fluentui/react-teaching-popover'; +import { TeachingPopoverTitleBaseState as TeachingPopoverTitleState } from '@fluentui/react-teaching-popover'; +import type { TriggerProps } from '@fluentui/react-utilities'; +import { useTeachingPopoverBody_unstable as useTeachingPopoverBody } from '@fluentui/react-teaching-popover'; +import { useTeachingPopoverCarouselBase_unstable as useTeachingPopoverCarousel } from '@fluentui/react-teaching-popover'; +import { useTeachingPopoverCarouselCard_unstable as useTeachingPopoverCarouselCard } from '@fluentui/react-teaching-popover'; +import type { useTeachingPopoverCarouselContextValues_unstable } from '@fluentui/react-teaching-popover'; +import { useTeachingPopoverCarouselFooterButtonBase_unstable as useTeachingPopoverCarouselFooterButton } from '@fluentui/react-teaching-popover'; +import { useTeachingPopoverCarouselNavBase_unstable as useTeachingPopoverCarouselNav } from '@fluentui/react-teaching-popover'; +import { useTeachingPopoverCarouselNavButtonBase_unstable as useTeachingPopoverCarouselNavButton } from '@fluentui/react-teaching-popover'; +import { useTeachingPopoverCarouselPageCount_unstable as useTeachingPopoverCarouselPageCount } from '@fluentui/react-teaching-popover'; +import { useTeachingPopoverFooterBase_unstable as useTeachingPopoverFooter } from '@fluentui/react-teaching-popover'; +import { useTeachingPopoverHeaderBase_unstable as useTeachingPopoverHeader } from '@fluentui/react-teaching-popover'; +import { useTeachingPopoverTitleBase_unstable as useTeachingPopoverTitle } from '@fluentui/react-teaching-popover'; + +// @public +export type NavButtonRenderFunction = TeachingPopoverCarouselNavState['renderNavButton']; + +// @public +export const renderTeachingPopover: (state: TeachingPopoverState, contextValues: TeachingPopoverContextValues) => React_2.ReactElement; + +export { renderTeachingPopoverBody } + +// @public (undocumented) +export const renderTeachingPopoverCarousel: (state: TeachingPopoverCarouselState, contextValues: TeachingPopoverCarouselContextValues) => JSXElement; + +export { renderTeachingPopoverCarouselCard } + +// @public (undocumented) +export const renderTeachingPopoverCarouselFooter: (state: TeachingPopoverCarouselFooterState) => JSXElement; + +// @public (undocumented) +export const renderTeachingPopoverCarouselFooterButton: (state: TeachingPopoverCarouselFooterButtonState) => JSXElement; + +export { renderTeachingPopoverCarouselNav } + +// @public (undocumented) +export const renderTeachingPopoverCarouselNavButton: (state: TeachingPopoverCarouselNavButtonState) => JSXElement; + +export { renderTeachingPopoverCarouselPageCount } + +// @public +export const renderTeachingPopoverFooter: (state: TeachingPopoverFooterState) => JSXElement; + +// @public (undocumented) +export const renderTeachingPopoverHeader: (state: TeachingPopoverHeaderState) => JSXElement; + +// @public (undocumented) +export const renderTeachingPopoverSurface: (state: TeachingPopoverSurfaceState) => JSXElement; + +// @public (undocumented) +export const renderTeachingPopoverTitle: (state: TeachingPopoverTitleState) => JSXElement; + +// @public +export const renderTeachingPopoverTrigger: (state: TeachingPopoverTriggerState) => JSXElement | null; + +// @public +export const TeachingPopover: { + (props: TeachingPopoverProps): JSXElement; + displayName: string; +}; + +// @public +export type TeachingPopoverBaseBridgedContextValue = Pick; + +// @public (undocumented) +export const TeachingPopoverBody: ForwardRefComponent; + +export { TeachingPopoverBodyProps } + +export { TeachingPopoverBodySlots } + +export { TeachingPopoverBodyState } + +// @public (undocumented) +export const TeachingPopoverCarousel: ForwardRefComponent; + +// @public (undocumented) +export const TeachingPopoverCarouselCard: ForwardRefComponent; + +export { TeachingPopoverCarouselCardProps } + +export { TeachingPopoverCarouselCardSlots } + +export { TeachingPopoverCarouselCardState } + +// @public +export type TeachingPopoverCarouselContextValues = ReturnType; + +// @public (undocumented) +export const TeachingPopoverCarouselFooter: ForwardRefComponent; + +// @public (undocumented) +export const TeachingPopoverCarouselFooterButton: ForwardRefComponent; + +export { TeachingPopoverCarouselFooterButtonProps } + +export { TeachingPopoverCarouselFooterButtonSlots } + +export { TeachingPopoverCarouselFooterButtonState } + +// @public (undocumented) +export type TeachingPopoverCarouselFooterProps = ComponentProps; + +// @public (undocumented) +export type TeachingPopoverCarouselFooterSlots = { + root: NonNullable>; + previous?: Slot; + next: NonNullable>; +}; + +// @public (undocumented) +export type TeachingPopoverCarouselFooterState = ComponentState; + +// @public (undocumented) +export const TeachingPopoverCarouselNav: ForwardRefComponent; + +// @public (undocumented) +export const TeachingPopoverCarouselNavButton: ForwardRefComponent; + +export { TeachingPopoverCarouselNavButtonProps } + +export { TeachingPopoverCarouselNavButtonSlots } + +export { TeachingPopoverCarouselNavButtonState } + +export { TeachingPopoverCarouselNavProps } + +export { TeachingPopoverCarouselNavSlots } + +export { TeachingPopoverCarouselNavState } + +// @public (undocumented) +export const TeachingPopoverCarouselPageCount: ForwardRefComponent; + +export { TeachingPopoverCarouselPageCountProps } + +export { TeachingPopoverCarouselPageCountRenderFunction } + +export { TeachingPopoverCarouselPageCountSlots } + +export { TeachingPopoverCarouselPageCountState } + +export { TeachingPopoverCarouselProps } + +export { TeachingPopoverCarouselSlots } + +export { TeachingPopoverCarouselState } + +// @public (undocumented) +export type TeachingPopoverContextValues = { + popover: PopoverContextValue; + basePopover: TeachingPopoverBaseBridgedContextValue; +}; + +// @public (undocumented) +export const TeachingPopoverFooter: ForwardRefComponent; + +export { TeachingPopoverFooterProps } + +export { TeachingPopoverFooterState } + +// @public (undocumented) +export const TeachingPopoverHeader: ForwardRefComponent; + +export { TeachingPopoverHeaderProps } + +export { TeachingPopoverHeaderSlots } + +export { TeachingPopoverHeaderState } + +// @public +export type TeachingPopoverProps = PopoverProps; + +// @public +export type TeachingPopoverState = PopoverState; + +// @public +export const TeachingPopoverSurface: ForwardRefComponent; + +// @public (undocumented) +export type TeachingPopoverSurfaceProps = ComponentProps; + +// @public +export type TeachingPopoverSurfaceSlots = { + root: Slot<'dialog'>; +}; + +// @public (undocumented) +export type TeachingPopoverSurfaceState = ComponentState & { + withArrow: boolean | undefined; + arrowRef: React_2.RefObject; + 'data-open': string; +}; + +// @public (undocumented) +export const TeachingPopoverTitle: ForwardRefComponent; + +export { TeachingPopoverTitleProps } + +export { TeachingPopoverTitleSlots } + +export { TeachingPopoverTitleState } + +// @public +export const TeachingPopoverTrigger: React_2.FC; + +// @public +export type TeachingPopoverTriggerChildProps = PopoverTriggerChildProps; + +// @public +export type TeachingPopoverTriggerProps = Omit, 'children'> & { + children: React_2.ReactElement; + disableButtonEnhancement?: boolean; +}; + +// @public +export type TeachingPopoverTriggerState = { + children: React_2.ReactElement | null; +}; + +// @public +export const useTeachingPopover: (props: TeachingPopoverProps) => TeachingPopoverState; + +export { useTeachingPopoverBody } + +export { useTeachingPopoverCarousel } + +export { useTeachingPopoverCarouselCard } + +// @public (undocumented) +export const useTeachingPopoverCarouselContextValues: (state: TeachingPopoverCarouselState) => TeachingPopoverCarouselContextValues; + +// @public (undocumented) +export const useTeachingPopoverCarouselFooter: (props: TeachingPopoverCarouselFooterProps, ref: React_2.Ref) => TeachingPopoverCarouselFooterState; + +export { useTeachingPopoverCarouselFooterButton } + +export { useTeachingPopoverCarouselNav } + +export { useTeachingPopoverCarouselNavButton } + +export { useTeachingPopoverCarouselPageCount } + +// @public +export const useTeachingPopoverContextValues: (state: TeachingPopoverState) => TeachingPopoverContextValues; + +export { useTeachingPopoverFooter } + +export { useTeachingPopoverHeader } + +// @public +export const useTeachingPopoverSurface: (props: TeachingPopoverSurfaceProps, ref: React_2.Ref) => TeachingPopoverSurfaceState; + +export { useTeachingPopoverTitle } + +// @public +export const useTeachingPopoverTrigger: (props: TeachingPopoverTriggerProps) => TeachingPopoverTriggerState; + +// (No @packageDocumentation comment for this package) + +``` 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 0000000000000..212a33c4b9a33 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/toast.api.md @@ -0,0 +1,181 @@ +## 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 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 type { ToastAnnounce } from '@fluentui/react-toast'; +import type { ToastBaseState } 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 type { ToasterProps as ToasterProps_2 } from '@fluentui/react-toast'; +import type { ToasterState as ToasterState_2 } from '@fluentui/react-toast'; +import { ToastFooterProps } from '@fluentui/react-toast'; +import { ToastFooterSlots } from '@fluentui/react-toast'; +import { ToastFooterState } from '@fluentui/react-toast'; +import { ToastId } from '@fluentui/react-toast'; +import { ToastImperativeRef } from '@fluentui/react-toast'; +import { ToastIntent } from '@fluentui/react-toast'; +import { ToastPoliteness } from '@fluentui/react-toast'; +import { ToastPosition } from '@fluentui/react-toast'; +import { ToastBaseProps as ToastProps } from '@fluentui/react-toast'; +import { ToastSlots } from '@fluentui/react-toast'; +import { ToastStatus } from '@fluentui/react-toast'; +import { ToastTitleBaseProps as ToastTitleProps } from '@fluentui/react-toast'; +import { ToastTitleSlots } from '@fluentui/react-toast'; +import { ToastTitleBaseState as ToastTitleState } from '@fluentui/react-toast'; +import { 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'; + +// @public +export const renderToast: (state: ToastState) => JSXElement; + +// @public +export const renderToastBody: (state: ToastBodyState) => JSXElement; + +// @public +export const renderToastContainer: (state: ToastContainerState, contextValues: ToastContainerContextValues) => JSXElement; + +// @public +export const renderToaster: (state: ToasterState) => JSXElement; + +export { renderToastFooter } + +// @public (undocumented) +export const renderToastTitle: (state: ToastTitleState) => JSXElement; + +// @public +export const Toast: ForwardRefComponent; + +// @public +export const ToastBody: ForwardRefComponent; + +export { ToastBodyProps } + +export { ToastBodySlots } + +export { ToastBodyState } + +export { ToastChangeData } + +export { ToastChangeHandler } + +// @public +export const ToastContainer: ForwardRefComponent; + +export { ToastContainerContextValue } + +// @public (undocumented) +export type ToastContainerProps = Omit>, 'content'> & ToastData & { + visible: boolean; + tryRestoreFocus: () => void; + announce?: ToastAnnounce; +}; + +// @public (undocumented) +export type ToastContainerSlots = { + root: NonNullable>; +}; + +// @public (undocumented) +export type ToastContainerState = ComponentState & Pick & Pick & { + running: boolean; + nodeRef: React_2.Ref; +}; + +// @public +export const Toaster: { + (props: ToasterProps): JSXElement; + displayName: string; +}; + +export { ToasterId } + +// @public +export type ToasterProps = Omit; + +// @public +export type ToasterState = Omit; + +// @public +export const ToastFooter: ForwardRefComponent; + +export { ToastFooterProps } + +export { ToastFooterSlots } + +export { ToastFooterState } + +export { ToastId } + +export { ToastImperativeRef } + +export { ToastIntent } + +export { ToastPoliteness } + +export { ToastPosition } + +export { ToastProps } + +export { ToastSlots } + +// @public (undocumented) +export type ToastState = ToastBaseState & { + root: { + 'data-intent'?: string; + }; +}; + +export { ToastStatus } + +// @public +export const ToastTitle: ForwardRefComponent; + +export { ToastTitleProps } + +export { ToastTitleSlots } + +export { ToastTitleState } + +// @public +export const useToast: (props: ToastProps, ref: React_2.Ref) => ToastState; + +export { useToastBody } + +// @public +export const useToastContainer: (props: ToastContainerProps, ref: React_2.Ref) => ToastContainerState; + +export { useToastContainerContext } + +// @public (undocumented) +export const useToastContainerContextValues: (state: ToastContainerState) => ToastContainerContextValues; + +export { useToastController } + +// @public +export const useToaster: (props: ToasterProps) => ToasterState; + +export { useToastFooter } + +export { useToastTitle } + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-headless-components-preview/library/package.json b/packages/react-components/react-headless-components-preview/library/package.json index c7444961c655d..f0e55afb05667 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -38,10 +38,10 @@ "@fluentui/react-link": "^9.8.2", "@fluentui/react-menu": "^9.25.0", "@fluentui/react-message-bar": "^9.7.1", + "@fluentui/react-teaching-popover": "^9.7.0", "@fluentui/react-nav": "^9.4.0", "@fluentui/react-persona": "^9.7.4", "@fluentui/react-popover": "^9.14.3", - "@fluentui/react-portal": "^9.8.13", "@fluentui/react-positioning": "^9.22.2", "@fluentui/react-progress": "^9.5.2", "@fluentui/react-provider": "^9.22.17", @@ -61,6 +61,7 @@ "@fluentui/react-tags": "^9.9.1", "@fluentui/react-textarea": "^9.7.3", "@fluentui/react-toolbar": "^9.8.1", + "@fluentui/react-toast": "^9.7.18", "@fluentui/react-tooltip": "^9.10.2", "@fluentui/react-utilities": "^9.26.4", "@swc/helpers": "^0.5.1" @@ -312,12 +313,24 @@ "import": "./lib/tag-group.js", "require": "./lib-commonjs/tag-group.js" }, + "./teaching-popover": { + "types": "./dist/teaching-popover.d.ts", + "node": "./lib-commonjs/teaching-popover.js", + "import": "./lib/teaching-popover.js", + "require": "./lib-commonjs/teaching-popover.js" + }, "./textarea": { "types": "./dist/textarea.d.ts", "node": "./lib-commonjs/textarea.js", "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/TeachingPopover/TeachingPopover.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.cy.tsx new file mode 100644 index 0000000000000..286f46fcd0f3d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.cy.tsx @@ -0,0 +1,216 @@ +import * as React from 'react'; +import { mount as mountBase } from '@fluentui/scripts-cypress'; + +import { + TeachingPopover, + TeachingPopoverBody, + TeachingPopoverCarousel, + TeachingPopoverCarouselCard, + TeachingPopoverCarouselFooter, + TeachingPopoverCarouselPageCount, + TeachingPopoverSurface, + TeachingPopoverTitle, + TeachingPopoverTrigger, +} from './index'; +import type { TeachingPopoverProps } from './index'; +import type { JSXElement } from '@fluentui/react-utilities'; + +const mount = (element: JSXElement) => mountBase(element); + +const triggerSelector = '[aria-expanded]'; +const surfaceSelector = '[role="group"]'; + +describe('TeachingPopover', () => { + (['uncontrolled', 'controlled'] as const).forEach(scenario => { + const UncontrolledExample = () => ( + + + + + + + Title +
This is a teaching popover
+
+
+
+ ); + + const ControlledExample = () => { + const [open, setOpen] = React.useState(false); + + return ( + setOpen(data.open)}> + + + + + + Title +
This is a teaching popover
+
+
+
+ ); + }; + + describe(scenario, () => { + const Example = scenario === 'controlled' ? ControlledExample : UncontrolledExample; + + beforeEach(() => { + mount(); + }); + + it('opens on trigger click', () => { + cy.get(triggerSelector).realClick(); + cy.get(surfaceSelector).should('be.visible'); + }); + + (['{enter}', 'Space'] as const).forEach(key => { + it(`opens with ${key}`, () => { + cy.get(triggerSelector).focus().realPress(key); + cy.get(surfaceSelector).should('be.visible'); + }); + }); + + it('dismisses on click outside', () => { + cy.get(triggerSelector).realClick(); + cy.get(surfaceSelector).should('be.visible'); + cy.get('body').realClick({ position: 'bottomRight' }); + cy.get(surfaceSelector).should('not.exist'); + }); + + it('dismisses on Escape keydown', () => { + cy.get(triggerSelector).realClick(); + cy.get(surfaceSelector).should('be.visible'); + cy.realPress('Escape'); + cy.get(surfaceSelector).should('not.exist'); + }); + }); + }); + + describe('updating content', () => { + const Example = () => { + const [visible, setVisible] = React.useState(false); + const changeContent = () => setVisible(true); + const onOpenChange: TeachingPopoverProps['onOpenChange'] = (_e, data) => { + if (data.open === false) { + setVisible(false); + } + }; + + return ( + + + + + + {visible ? ( +
The second panel
+ ) : ( +
+ +
+ )} +
+
+ ); + }; + + it('does not close when inner content changes', () => { + mount(); + cy.get(triggerSelector).realClick(); + cy.get(surfaceSelector).within(() => { + cy.contains('Action').realClick(); + }); + cy.get(surfaceSelector).should('be.visible').contains('The second panel'); + }); + }); + + describe('carousel integration', () => { + const PAGES = ['intro', 'features', 'wrap-up'] as const; + + const CarouselExample = () => ( + + + + + + + + + Welcome +
Intro content
+
+
+ + + + Features +
Features content
+
+
+ + + + Wrap up +
Wrap-up content
+
+
+ + + + {(current, total) => ( + + {current} / {total} + + )} + + +
+
+
+ ); + + it('advances pages via the next button and updates the page count', () => { + mount(); + cy.get(triggerSelector).realClick(); + cy.get(surfaceSelector).should('be.visible'); + + cy.get('[data-testid="page-count"]').should('have.text', '1 / 3'); + cy.contains('Welcome').should('be.visible'); + + cy.contains('button', 'Next').realClick(); + cy.contains('Features').should('be.visible'); + cy.get('[data-testid="page-count"]').should('have.text', '2 / 3'); + + cy.contains('button', 'Next').realClick(); + cy.contains('Wrap up').should('be.visible'); + cy.get('[data-testid="page-count"]').should('have.text', '3 / 3'); + }); + + it('goes back via the previous button', () => { + mount(); + cy.get(triggerSelector).realClick(); + cy.contains('button', 'Next').realClick(); + cy.contains('Features').should('be.visible'); + + cy.contains('button', 'Back').realClick(); + cy.contains('Welcome').should('be.visible'); + cy.get('[data-testid="page-count"]').should('have.text', '1 / 3'); + }); + + it('closes the popover when next is pressed on the final page', () => { + mount(); + cy.get(triggerSelector).realClick(); + cy.contains('button', 'Next').realClick(); + cy.contains('button', 'Next').realClick(); + // On the trailing page the next button renders `altText` ("Done") instead of "Next". + cy.contains('button', 'Done').realClick(); + cy.get(surfaceSelector).should('not.exist'); + }); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.test.tsx new file mode 100644 index 0000000000000..8abad2ea4033b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.test.tsx @@ -0,0 +1,117 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { isConformant } from '../../testing/isConformant'; +import { TeachingPopover } from './TeachingPopover'; +import { TeachingPopoverTrigger } from './TeachingPopoverTrigger'; +import { TeachingPopoverSurface } from './TeachingPopoverSurface'; + +describe('TeachingPopover', () => { + isConformant({ + Component: TeachingPopover, + displayName: 'TeachingPopover', + requiredProps: { + defaultOpen: true, + children: [ + + + , + Surface, + ], + }, + disabledTests: [ + 'component-handles-ref', + 'component-has-root-ref', + 'component-handles-classname', + 'component-has-static-classnames-object', + 'make-styles-overrides-win', + 'consistent-callback-args', + ], + }); + + it('renders trigger and surface children', () => { + const { getByText } = render( + + + + + Surface content + , + ); + + expect(getByText('Trigger')).toBeInTheDocument(); + expect(getByText('Surface content')).toBeInTheDocument(); + }); + + it('renders an arrow by default (withArrow=true)', () => { + const { getByRole } = render( + + + + + Surface + , + ); + + expect(getByRole('group', { hidden: true }).querySelector('[data-arrow]')).toBeInTheDocument(); + }); + + it('allows opting out of the arrow with withArrow={false}', () => { + const { getByRole } = render( + + + + + Surface + , + ); + + expect(getByRole('group', { hidden: true }).querySelector('[data-arrow]')).toBeNull(); + }); + + it('does not enable trapFocus by default (surface is role="group", not "dialog")', () => { + const { getByRole, queryByRole } = render( + + + + + Surface + , + ); + + expect(getByRole('group', { hidden: true })).toBeInTheDocument(); + expect(queryByRole('dialog', { hidden: true })).not.toBeInTheDocument(); + }); + + it('forwards trapFocus to the surface when explicitly set', () => { + const { getByRole } = render( + + + + + Surface + , + ); + + expect(getByRole('dialog', { hidden: true })).toBeInTheDocument(); + }); + + it('opens on trigger click and fires onOpenChange', () => { + const onOpenChange = jest.fn(); + const { getByText, queryByText } = render( + + + + + Surface + , + ); + + expect(queryByText('Surface')).not.toBeInTheDocument(); + + userEvent.click(getByText('Trigger')); + + expect(getByText('Surface')).toBeInTheDocument(); + expect(onOpenChange).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ open: true })); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.tsx new file mode 100644 index 0000000000000..fbbd5cb5af8b6 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.tsx @@ -0,0 +1,23 @@ +'use client'; + +import type { JSXElement } from '@fluentui/react-utilities'; +import type { TeachingPopoverProps } from './TeachingPopover.types'; +import { useTeachingPopover } from './useTeachingPopover'; +import { useTeachingPopoverContextValues } from './useTeachingPopoverContextValues'; +import { renderTeachingPopover } from './renderTeachingPopover'; + +/** + * Headless TeachingPopover component. + * + * Wraps the headless `Popover` and additionally bridges the v9 + * `PopoverContext` so sub-components built on `useTeachingPopover*Base_unstable` + * hooks from `@fluentui/react-teaching-popover` resolve their context reads. + */ +export const TeachingPopover = (props: TeachingPopoverProps): JSXElement => { + const state = useTeachingPopover(props); + const contextValues = useTeachingPopoverContextValues(state); + + return renderTeachingPopover(state, contextValues); +}; + +TeachingPopover.displayName = 'TeachingPopover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.types.ts new file mode 100644 index 0000000000000..52ddc2f03464d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.types.ts @@ -0,0 +1,38 @@ +import type { PopoverContextValue as BasePopoverContextValue } from '@fluentui/react-popover'; +import type { PopoverProps, PopoverState, PopoverContextValue } from '../Popover/Popover.types'; + +/** + * TeachingPopover Props + */ +export type TeachingPopoverProps = PopoverProps; + +/** + * TeachingPopover State — identical to the headless Popover state. Styling + * concerns from `@fluentui/react-teaching-popover` (`appearance`, `trapFocus`, + * `inline`) are intentionally omitted; consumers control presentation. + */ +export type TeachingPopoverState = PopoverState; + +/** + * Subset of the `@fluentui/react-popover` `PopoverContextValue` that the + * `@fluentui/react-teaching-popover` base hooks actually read (`toggleOpen`, + * `setOpen`, `triggerRef`). The other fields fall back to + * `popoverContextDefaultValue` from `@fluentui/react-popover`. + */ +export type TeachingPopoverBaseBridgedContextValue = Pick< + BasePopoverContextValue, + | 'open' + | 'setOpen' + | 'toggleOpen' + | 'triggerRef' + | 'contentRef' + | 'arrowRef' + | 'openOnHover' + | 'openOnContext' + | 'withArrow' +>; + +export type TeachingPopoverContextValues = { + popover: PopoverContextValue; + basePopover: TeachingPopoverBaseBridgedContextValue; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverBody/TeachingPopoverBody.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverBody/TeachingPopoverBody.tsx new file mode 100644 index 0000000000000..ff2c17262f98d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverBody/TeachingPopoverBody.tsx @@ -0,0 +1,14 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { TeachingPopoverBodyProps } from './TeachingPopoverBody.types'; +import { useTeachingPopoverBody } from './useTeachingPopoverBody'; +import { renderTeachingPopoverBody } from './renderTeachingPopoverBody'; + +export const TeachingPopoverBody: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useTeachingPopoverBody(props, ref); + return renderTeachingPopoverBody(state); +}); + +TeachingPopoverBody.displayName = 'TeachingPopoverBody'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverBody/TeachingPopoverBody.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverBody/TeachingPopoverBody.types.ts new file mode 100644 index 0000000000000..29dc0c22fe142 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverBody/TeachingPopoverBody.types.ts @@ -0,0 +1,5 @@ +export type { + TeachingPopoverBodyProps, + TeachingPopoverBodySlots, + TeachingPopoverBodyState, +} from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverBody/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverBody/index.ts new file mode 100644 index 0000000000000..56c5d3d564796 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverBody/index.ts @@ -0,0 +1,8 @@ +export { TeachingPopoverBody } from './TeachingPopoverBody'; +export { useTeachingPopoverBody } from './useTeachingPopoverBody'; +export { renderTeachingPopoverBody } from './renderTeachingPopoverBody'; +export type { + TeachingPopoverBodyProps, + TeachingPopoverBodySlots, + TeachingPopoverBodyState, +} from './TeachingPopoverBody.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverBody/renderTeachingPopoverBody.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverBody/renderTeachingPopoverBody.ts new file mode 100644 index 0000000000000..28077e68cbfd4 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverBody/renderTeachingPopoverBody.ts @@ -0,0 +1 @@ +export { renderTeachingPopoverBody_unstable as renderTeachingPopoverBody } from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverBody/useTeachingPopoverBody.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverBody/useTeachingPopoverBody.ts new file mode 100644 index 0000000000000..bdb775e24fbd6 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverBody/useTeachingPopoverBody.ts @@ -0,0 +1 @@ +export { useTeachingPopoverBody_unstable as useTeachingPopoverBody } from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarousel/TeachingPopoverCarousel.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarousel/TeachingPopoverCarousel.tsx new file mode 100644 index 0000000000000..01c1d61731a1a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarousel/TeachingPopoverCarousel.tsx @@ -0,0 +1,17 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { TeachingPopoverCarouselProps } from './TeachingPopoverCarousel.types'; +import { useTeachingPopoverCarousel, useTeachingPopoverCarouselContextValues } from './useTeachingPopoverCarousel'; +import { renderTeachingPopoverCarousel } from './renderTeachingPopoverCarousel'; + +export const TeachingPopoverCarousel: ForwardRefComponent = React.forwardRef( + (props, ref) => { + const state = useTeachingPopoverCarousel(props, ref); + const contextValues = useTeachingPopoverCarouselContextValues(state); + return renderTeachingPopoverCarousel(state, contextValues); + }, +); + +TeachingPopoverCarousel.displayName = 'TeachingPopoverCarousel'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarousel/TeachingPopoverCarousel.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarousel/TeachingPopoverCarousel.types.ts new file mode 100644 index 0000000000000..3f0fbf7f857f5 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarousel/TeachingPopoverCarousel.types.ts @@ -0,0 +1,15 @@ +import type { useTeachingPopoverCarouselContextValues_unstable } from '@fluentui/react-teaching-popover'; + +export type { + TeachingPopoverCarouselBaseProps as TeachingPopoverCarouselProps, + TeachingPopoverCarouselBaseState as TeachingPopoverCarouselState, + TeachingPopoverCarouselSlots, +} from '@fluentui/react-teaching-popover'; + +/** + * Context shared between TeachingPopoverCarousel and its children components. + * + * Derived from the v9 `useTeachingPopoverCarouselContextValues_unstable` return + * type, which is the package's public contract for this shape. + */ +export type TeachingPopoverCarouselContextValues = ReturnType; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarousel/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarousel/index.ts new file mode 100644 index 0000000000000..d9384c9a2ea82 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarousel/index.ts @@ -0,0 +1,9 @@ +export { TeachingPopoverCarousel } from './TeachingPopoverCarousel'; +export { useTeachingPopoverCarousel, useTeachingPopoverCarouselContextValues } from './useTeachingPopoverCarousel'; +export { renderTeachingPopoverCarousel } from './renderTeachingPopoverCarousel'; +export type { + TeachingPopoverCarouselProps, + TeachingPopoverCarouselState, + TeachingPopoverCarouselSlots, + TeachingPopoverCarouselContextValues, +} from './TeachingPopoverCarousel.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarousel/renderTeachingPopoverCarousel.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarousel/renderTeachingPopoverCarousel.ts new file mode 100644 index 0000000000000..e94073e0f7d8b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarousel/renderTeachingPopoverCarousel.ts @@ -0,0 +1,11 @@ +import { renderTeachingPopoverCarousel_unstable } from '@fluentui/react-teaching-popover'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { + TeachingPopoverCarouselContextValues, + TeachingPopoverCarouselState, +} from './TeachingPopoverCarousel.types'; + +export const renderTeachingPopoverCarousel = renderTeachingPopoverCarousel_unstable as ( + state: TeachingPopoverCarouselState, + contextValues: TeachingPopoverCarouselContextValues, +) => JSXElement; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarousel/useTeachingPopoverCarousel.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarousel/useTeachingPopoverCarousel.ts new file mode 100644 index 0000000000000..5f87a4c43747c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarousel/useTeachingPopoverCarousel.ts @@ -0,0 +1,13 @@ +'use client'; + +import { useTeachingPopoverCarouselContextValues_unstable } from '@fluentui/react-teaching-popover'; +import type { + TeachingPopoverCarouselContextValues, + TeachingPopoverCarouselState, +} from './TeachingPopoverCarousel.types'; + +export { useTeachingPopoverCarouselBase_unstable as useTeachingPopoverCarousel } from '@fluentui/react-teaching-popover'; + +export const useTeachingPopoverCarouselContextValues = useTeachingPopoverCarouselContextValues_unstable as ( + state: TeachingPopoverCarouselState, +) => TeachingPopoverCarouselContextValues; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselCard/TeachingPopoverCarouselCard.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselCard/TeachingPopoverCarouselCard.tsx new file mode 100644 index 0000000000000..05187f14c3657 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselCard/TeachingPopoverCarouselCard.tsx @@ -0,0 +1,16 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { TeachingPopoverCarouselCardProps } from './TeachingPopoverCarouselCard.types'; +import { useTeachingPopoverCarouselCard } from './useTeachingPopoverCarouselCard'; +import { renderTeachingPopoverCarouselCard } from './renderTeachingPopoverCarouselCard'; + +export const TeachingPopoverCarouselCard: ForwardRefComponent = React.forwardRef( + (props, ref) => { + const state = useTeachingPopoverCarouselCard(props, ref); + return renderTeachingPopoverCarouselCard(state); + }, +); + +TeachingPopoverCarouselCard.displayName = 'TeachingPopoverCarouselCard'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselCard/TeachingPopoverCarouselCard.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselCard/TeachingPopoverCarouselCard.types.ts new file mode 100644 index 0000000000000..d5b40c259db6d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselCard/TeachingPopoverCarouselCard.types.ts @@ -0,0 +1,5 @@ +export type { + TeachingPopoverCarouselCardProps, + TeachingPopoverCarouselCardSlots, + TeachingPopoverCarouselCardState, +} from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselCard/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselCard/index.ts new file mode 100644 index 0000000000000..a53dead1da05f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselCard/index.ts @@ -0,0 +1,8 @@ +export { TeachingPopoverCarouselCard } from './TeachingPopoverCarouselCard'; +export { useTeachingPopoverCarouselCard } from './useTeachingPopoverCarouselCard'; +export { renderTeachingPopoverCarouselCard } from './renderTeachingPopoverCarouselCard'; +export type { + TeachingPopoverCarouselCardProps, + TeachingPopoverCarouselCardSlots, + TeachingPopoverCarouselCardState, +} from './TeachingPopoverCarouselCard.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselCard/renderTeachingPopoverCarouselCard.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselCard/renderTeachingPopoverCarouselCard.ts new file mode 100644 index 0000000000000..a81ed09667e72 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselCard/renderTeachingPopoverCarouselCard.ts @@ -0,0 +1 @@ +export { renderTeachingPopoverCarouselCard_unstable as renderTeachingPopoverCarouselCard } from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselCard/useTeachingPopoverCarouselCard.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselCard/useTeachingPopoverCarouselCard.ts new file mode 100644 index 0000000000000..6e36fd10ce16b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselCard/useTeachingPopoverCarouselCard.ts @@ -0,0 +1 @@ +export { useTeachingPopoverCarouselCard_unstable as useTeachingPopoverCarouselCard } from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooter/TeachingPopoverCarouselFooter.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooter/TeachingPopoverCarouselFooter.tsx new file mode 100644 index 0000000000000..30ee96c879ab1 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooter/TeachingPopoverCarouselFooter.tsx @@ -0,0 +1,16 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { TeachingPopoverCarouselFooterProps } from './TeachingPopoverCarouselFooter.types'; +import { useTeachingPopoverCarouselFooter } from './useTeachingPopoverCarouselFooter'; +import { renderTeachingPopoverCarouselFooter } from './renderTeachingPopoverCarouselFooter'; + +export const TeachingPopoverCarouselFooter: ForwardRefComponent = React.forwardRef( + (props, ref) => { + const state = useTeachingPopoverCarouselFooter(props, ref); + return renderTeachingPopoverCarouselFooter(state); + }, +); + +TeachingPopoverCarouselFooter.displayName = 'TeachingPopoverCarouselFooter'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooter/TeachingPopoverCarouselFooter.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooter/TeachingPopoverCarouselFooter.types.ts new file mode 100644 index 0000000000000..8189f13fb3938 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooter/TeachingPopoverCarouselFooter.types.ts @@ -0,0 +1,25 @@ +import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import type { TeachingPopoverCarouselFooterButtonProps } from '../TeachingPopoverCarouselFooterButton/TeachingPopoverCarouselFooterButton.types'; + +export type TeachingPopoverCarouselFooterSlots = { + /** + * The element wrapping carousel pages and navigation. + */ + root: NonNullable>; + + /** + * Previous-page button. Defaults to `TeachingPopoverCarouselFooterButton` + * with `navType: 'prev'`; consumers provide `altText` and content. + */ + previous?: Slot; + + /** + * Next/finish-page button. Defaults to `TeachingPopoverCarouselFooterButton` + * with `navType: 'next'`; consumers provide `altText` and content. + */ + next: NonNullable>; +}; + +export type TeachingPopoverCarouselFooterProps = ComponentProps; + +export type TeachingPopoverCarouselFooterState = ComponentState; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooter/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooter/index.ts new file mode 100644 index 0000000000000..3f21fc3b86db9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooter/index.ts @@ -0,0 +1,8 @@ +export { TeachingPopoverCarouselFooter } from './TeachingPopoverCarouselFooter'; +export { useTeachingPopoverCarouselFooter } from './useTeachingPopoverCarouselFooter'; +export { renderTeachingPopoverCarouselFooter } from './renderTeachingPopoverCarouselFooter'; +export type { + TeachingPopoverCarouselFooterProps, + TeachingPopoverCarouselFooterSlots, + TeachingPopoverCarouselFooterState, +} from './TeachingPopoverCarouselFooter.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooter/renderTeachingPopoverCarouselFooter.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooter/renderTeachingPopoverCarouselFooter.tsx new file mode 100644 index 0000000000000..3d4e7850ab72f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooter/renderTeachingPopoverCarouselFooter.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 { + TeachingPopoverCarouselFooterSlots, + TeachingPopoverCarouselFooterState, +} from './TeachingPopoverCarouselFooter.types'; + +export const renderTeachingPopoverCarouselFooter = (state: TeachingPopoverCarouselFooterState): JSXElement => { + assertSlots(state); + + return ( + + {state.previous && } + {state.root.children} + + + ); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooter/useTeachingPopoverCarouselFooter.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooter/useTeachingPopoverCarouselFooter.ts new file mode 100644 index 0000000000000..a900a36e45937 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooter/useTeachingPopoverCarouselFooter.ts @@ -0,0 +1,28 @@ +import type * as React from 'react'; +import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; +import { TeachingPopoverCarouselFooterButton } from '../TeachingPopoverCarouselFooterButton/TeachingPopoverCarouselFooterButton'; +import type { + TeachingPopoverCarouselFooterProps, + TeachingPopoverCarouselFooterState, +} from './TeachingPopoverCarouselFooter.types'; + +export const useTeachingPopoverCarouselFooter = ( + props: TeachingPopoverCarouselFooterProps, + ref: React.Ref, +): TeachingPopoverCarouselFooterState => ({ + components: { + root: 'div', + previous: TeachingPopoverCarouselFooterButton, + next: TeachingPopoverCarouselFooterButton, + }, + root: slot.always(getIntrinsicElementProps('div', { ref, ...props }), { elementType: 'div' }), + previous: slot.optional(props.previous, { + defaultProps: { navType: 'prev' }, + renderByDefault: false, + elementType: TeachingPopoverCarouselFooterButton, + }), + next: slot.always(props.next, { + defaultProps: { navType: 'next' }, + elementType: TeachingPopoverCarouselFooterButton, + }), +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooterButton/TeachingPopoverCarouselFooterButton.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooterButton/TeachingPopoverCarouselFooterButton.tsx new file mode 100644 index 0000000000000..64bf60d65284e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooterButton/TeachingPopoverCarouselFooterButton.tsx @@ -0,0 +1,15 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { TeachingPopoverCarouselFooterButtonProps } from './TeachingPopoverCarouselFooterButton.types'; +import { useTeachingPopoverCarouselFooterButton } from './useTeachingPopoverCarouselFooterButton'; +import { renderTeachingPopoverCarouselFooterButton } from './renderTeachingPopoverCarouselFooterButton'; + +export const TeachingPopoverCarouselFooterButton: ForwardRefComponent = + React.forwardRef((props, ref) => { + const state = useTeachingPopoverCarouselFooterButton(props, ref); + return renderTeachingPopoverCarouselFooterButton(state); + }); + +TeachingPopoverCarouselFooterButton.displayName = 'TeachingPopoverCarouselFooterButton'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooterButton/TeachingPopoverCarouselFooterButton.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooterButton/TeachingPopoverCarouselFooterButton.types.ts new file mode 100644 index 0000000000000..4cbc1e7df0676 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooterButton/TeachingPopoverCarouselFooterButton.types.ts @@ -0,0 +1,5 @@ +export type { + TeachingPopoverCarouselFooterButtonBaseProps as TeachingPopoverCarouselFooterButtonProps, + TeachingPopoverCarouselFooterButtonBaseState as TeachingPopoverCarouselFooterButtonState, + TeachingPopoverCarouselFooterButtonSlots, +} from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooterButton/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooterButton/index.ts new file mode 100644 index 0000000000000..d856cd93ae5fe --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooterButton/index.ts @@ -0,0 +1,8 @@ +export { TeachingPopoverCarouselFooterButton } from './TeachingPopoverCarouselFooterButton'; +export { useTeachingPopoverCarouselFooterButton } from './useTeachingPopoverCarouselFooterButton'; +export { renderTeachingPopoverCarouselFooterButton } from './renderTeachingPopoverCarouselFooterButton'; +export type { + TeachingPopoverCarouselFooterButtonProps, + TeachingPopoverCarouselFooterButtonSlots, + TeachingPopoverCarouselFooterButtonState, +} from './TeachingPopoverCarouselFooterButton.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooterButton/renderTeachingPopoverCarouselFooterButton.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooterButton/renderTeachingPopoverCarouselFooterButton.ts new file mode 100644 index 0000000000000..58b4612770b39 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooterButton/renderTeachingPopoverCarouselFooterButton.ts @@ -0,0 +1,7 @@ +import { renderTeachingPopoverCarouselFooterButton_unstable } from '@fluentui/react-teaching-popover'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { TeachingPopoverCarouselFooterButtonState } from './TeachingPopoverCarouselFooterButton.types'; + +export const renderTeachingPopoverCarouselFooterButton = renderTeachingPopoverCarouselFooterButton_unstable as ( + state: TeachingPopoverCarouselFooterButtonState, +) => JSXElement; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooterButton/useTeachingPopoverCarouselFooterButton.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooterButton/useTeachingPopoverCarouselFooterButton.ts new file mode 100644 index 0000000000000..20b5a5fbc2774 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselFooterButton/useTeachingPopoverCarouselFooterButton.ts @@ -0,0 +1 @@ +export { useTeachingPopoverCarouselFooterButtonBase_unstable as useTeachingPopoverCarouselFooterButton } from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNav/TeachingPopoverCarouselNav.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNav/TeachingPopoverCarouselNav.tsx new file mode 100644 index 0000000000000..1f14f8df92338 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNav/TeachingPopoverCarouselNav.tsx @@ -0,0 +1,16 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { TeachingPopoverCarouselNavProps } from './TeachingPopoverCarouselNav.types'; +import { useTeachingPopoverCarouselNav } from './useTeachingPopoverCarouselNav'; +import { renderTeachingPopoverCarouselNav } from './renderTeachingPopoverCarouselNav'; + +export const TeachingPopoverCarouselNav: ForwardRefComponent = React.forwardRef( + (props, ref) => { + const state = useTeachingPopoverCarouselNav(props, ref); + return renderTeachingPopoverCarouselNav(state); + }, +); + +TeachingPopoverCarouselNav.displayName = 'TeachingPopoverCarouselNav'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNav/TeachingPopoverCarouselNav.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNav/TeachingPopoverCarouselNav.types.ts new file mode 100644 index 0000000000000..d8c5d7b6b4770 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNav/TeachingPopoverCarouselNav.types.ts @@ -0,0 +1,15 @@ +import type { TeachingPopoverCarouselNavBaseState } from '@fluentui/react-teaching-popover'; + +export type { + TeachingPopoverCarouselNavBaseProps as TeachingPopoverCarouselNavProps, + TeachingPopoverCarouselNavBaseState as TeachingPopoverCarouselNavState, + TeachingPopoverCarouselNavSlots, +} from '@fluentui/react-teaching-popover'; + +/** + * Render function for the carousel nav buttons. + * + * Derived from the v9 `TeachingPopoverCarouselNavBaseState.renderNavButton` + * field, which is the package's public contract for this shape. + */ +export type NavButtonRenderFunction = TeachingPopoverCarouselNavBaseState['renderNavButton']; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNav/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNav/index.ts new file mode 100644 index 0000000000000..6c1cef917871f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNav/index.ts @@ -0,0 +1,9 @@ +export { TeachingPopoverCarouselNav } from './TeachingPopoverCarouselNav'; +export { useTeachingPopoverCarouselNav } from './useTeachingPopoverCarouselNav'; +export { renderTeachingPopoverCarouselNav } from './renderTeachingPopoverCarouselNav'; +export type { + NavButtonRenderFunction, + TeachingPopoverCarouselNavProps, + TeachingPopoverCarouselNavSlots, + TeachingPopoverCarouselNavState, +} from './TeachingPopoverCarouselNav.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNav/renderTeachingPopoverCarouselNav.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNav/renderTeachingPopoverCarouselNav.ts new file mode 100644 index 0000000000000..fab62afc1d776 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNav/renderTeachingPopoverCarouselNav.ts @@ -0,0 +1 @@ +export { renderTeachingPopoverCarouselNav_unstable as renderTeachingPopoverCarouselNav } from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNav/useTeachingPopoverCarouselNav.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNav/useTeachingPopoverCarouselNav.ts new file mode 100644 index 0000000000000..b45308f926dbd --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNav/useTeachingPopoverCarouselNav.ts @@ -0,0 +1 @@ +export { useTeachingPopoverCarouselNavBase_unstable as useTeachingPopoverCarouselNav } from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNavButton/TeachingPopoverCarouselNavButton.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNavButton/TeachingPopoverCarouselNavButton.tsx new file mode 100644 index 0000000000000..950ef5cd35f01 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNavButton/TeachingPopoverCarouselNavButton.tsx @@ -0,0 +1,15 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { TeachingPopoverCarouselNavButtonProps } from './TeachingPopoverCarouselNavButton.types'; +import { useTeachingPopoverCarouselNavButton } from './useTeachingPopoverCarouselNavButton'; +import { renderTeachingPopoverCarouselNavButton } from './renderTeachingPopoverCarouselNavButton'; + +export const TeachingPopoverCarouselNavButton: ForwardRefComponent = + React.forwardRef((props, ref) => { + const state = useTeachingPopoverCarouselNavButton(props, ref); + return renderTeachingPopoverCarouselNavButton(state); + }); + +TeachingPopoverCarouselNavButton.displayName = 'TeachingPopoverCarouselNavButton'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNavButton/TeachingPopoverCarouselNavButton.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNavButton/TeachingPopoverCarouselNavButton.types.ts new file mode 100644 index 0000000000000..7dfec37bacd76 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNavButton/TeachingPopoverCarouselNavButton.types.ts @@ -0,0 +1,5 @@ +export type { + TeachingPopoverCarouselNavButtonBaseProps as TeachingPopoverCarouselNavButtonProps, + TeachingPopoverCarouselNavButtonBaseState as TeachingPopoverCarouselNavButtonState, + TeachingPopoverCarouselNavButtonSlots, +} from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNavButton/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNavButton/index.ts new file mode 100644 index 0000000000000..67988db767921 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNavButton/index.ts @@ -0,0 +1,8 @@ +export { TeachingPopoverCarouselNavButton } from './TeachingPopoverCarouselNavButton'; +export { useTeachingPopoverCarouselNavButton } from './useTeachingPopoverCarouselNavButton'; +export { renderTeachingPopoverCarouselNavButton } from './renderTeachingPopoverCarouselNavButton'; +export type { + TeachingPopoverCarouselNavButtonProps, + TeachingPopoverCarouselNavButtonSlots, + TeachingPopoverCarouselNavButtonState, +} from './TeachingPopoverCarouselNavButton.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNavButton/renderTeachingPopoverCarouselNavButton.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNavButton/renderTeachingPopoverCarouselNavButton.ts new file mode 100644 index 0000000000000..5d7a19c1b6b62 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNavButton/renderTeachingPopoverCarouselNavButton.ts @@ -0,0 +1,7 @@ +import { renderTeachingPopoverCarouselNavButton_unstable } from '@fluentui/react-teaching-popover'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { TeachingPopoverCarouselNavButtonState } from './TeachingPopoverCarouselNavButton.types'; + +export const renderTeachingPopoverCarouselNavButton = renderTeachingPopoverCarouselNavButton_unstable as ( + state: TeachingPopoverCarouselNavButtonState, +) => JSXElement; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNavButton/useTeachingPopoverCarouselNavButton.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNavButton/useTeachingPopoverCarouselNavButton.ts new file mode 100644 index 0000000000000..bbeeaee8591be --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselNavButton/useTeachingPopoverCarouselNavButton.ts @@ -0,0 +1 @@ +export { useTeachingPopoverCarouselNavButtonBase_unstable as useTeachingPopoverCarouselNavButton } from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselPageCount/TeachingPopoverCarouselPageCount.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselPageCount/TeachingPopoverCarouselPageCount.tsx new file mode 100644 index 0000000000000..0db9501040dbf --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselPageCount/TeachingPopoverCarouselPageCount.tsx @@ -0,0 +1,15 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { TeachingPopoverCarouselPageCountProps } from './TeachingPopoverCarouselPageCount.types'; +import { useTeachingPopoverCarouselPageCount } from './useTeachingPopoverCarouselPageCount'; +import { renderTeachingPopoverCarouselPageCount } from './renderTeachingPopoverCarouselPageCount'; + +export const TeachingPopoverCarouselPageCount: ForwardRefComponent = + React.forwardRef((props, ref) => { + const state = useTeachingPopoverCarouselPageCount(props, ref); + return renderTeachingPopoverCarouselPageCount(state); + }); + +TeachingPopoverCarouselPageCount.displayName = 'TeachingPopoverCarouselPageCount'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselPageCount/TeachingPopoverCarouselPageCount.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselPageCount/TeachingPopoverCarouselPageCount.types.ts new file mode 100644 index 0000000000000..66b1eb6baf46f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselPageCount/TeachingPopoverCarouselPageCount.types.ts @@ -0,0 +1,6 @@ +export type { + TeachingPopoverCarouselPageCountProps, + TeachingPopoverCarouselPageCountRenderFunction, + TeachingPopoverCarouselPageCountSlots, + TeachingPopoverCarouselPageCountState, +} from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselPageCount/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselPageCount/index.ts new file mode 100644 index 0000000000000..0dcdbf4256f6e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselPageCount/index.ts @@ -0,0 +1,9 @@ +export { TeachingPopoverCarouselPageCount } from './TeachingPopoverCarouselPageCount'; +export { useTeachingPopoverCarouselPageCount } from './useTeachingPopoverCarouselPageCount'; +export { renderTeachingPopoverCarouselPageCount } from './renderTeachingPopoverCarouselPageCount'; +export type { + TeachingPopoverCarouselPageCountProps, + TeachingPopoverCarouselPageCountRenderFunction, + TeachingPopoverCarouselPageCountSlots, + TeachingPopoverCarouselPageCountState, +} from './TeachingPopoverCarouselPageCount.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselPageCount/renderTeachingPopoverCarouselPageCount.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselPageCount/renderTeachingPopoverCarouselPageCount.ts new file mode 100644 index 0000000000000..5798ec16684b3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselPageCount/renderTeachingPopoverCarouselPageCount.ts @@ -0,0 +1 @@ +export { renderTeachingPopoverCarouselPageCount_unstable as renderTeachingPopoverCarouselPageCount } from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselPageCount/useTeachingPopoverCarouselPageCount.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselPageCount/useTeachingPopoverCarouselPageCount.ts new file mode 100644 index 0000000000000..a0a18015d5f71 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverCarouselPageCount/useTeachingPopoverCarouselPageCount.ts @@ -0,0 +1 @@ +export { useTeachingPopoverCarouselPageCount_unstable as useTeachingPopoverCarouselPageCount } from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverFooter/TeachingPopoverFooter.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverFooter/TeachingPopoverFooter.tsx new file mode 100644 index 0000000000000..120f7c363d83a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverFooter/TeachingPopoverFooter.tsx @@ -0,0 +1,14 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { TeachingPopoverFooterProps } from './TeachingPopoverFooter.types'; +import { useTeachingPopoverFooter } from './useTeachingPopoverFooter'; +import { renderTeachingPopoverFooter } from './renderTeachingPopoverFooter'; + +export const TeachingPopoverFooter: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useTeachingPopoverFooter(props, ref); + return renderTeachingPopoverFooter(state); +}); + +TeachingPopoverFooter.displayName = 'TeachingPopoverFooter'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverFooter/TeachingPopoverFooter.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverFooter/TeachingPopoverFooter.types.ts new file mode 100644 index 0000000000000..944720a4bc92b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverFooter/TeachingPopoverFooter.types.ts @@ -0,0 +1,4 @@ +export type { + TeachingPopoverFooterBaseProps as TeachingPopoverFooterProps, + TeachingPopoverFooterBaseState as TeachingPopoverFooterState, +} from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverFooter/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverFooter/index.ts new file mode 100644 index 0000000000000..c707b46e7fb6d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverFooter/index.ts @@ -0,0 +1,4 @@ +export { TeachingPopoverFooter } from './TeachingPopoverFooter'; +export { useTeachingPopoverFooter } from './useTeachingPopoverFooter'; +export { renderTeachingPopoverFooter } from './renderTeachingPopoverFooter'; +export type { TeachingPopoverFooterProps, TeachingPopoverFooterState } from './TeachingPopoverFooter.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverFooter/renderTeachingPopoverFooter.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverFooter/renderTeachingPopoverFooter.tsx new file mode 100644 index 0000000000000..09e91fd559a94 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverFooter/renderTeachingPopoverFooter.tsx @@ -0,0 +1,16 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ +import { assertSlots } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { TeachingPopoverFooterState } from './TeachingPopoverFooter.types'; + +/** + * The headless footer leaves button composition to consumers — unlike the + * v9 render which emits dedicated `primary`/`secondary` Button slots that + * the headless base hook intentionally omits. + */ +export const renderTeachingPopoverFooter = (state: TeachingPopoverFooterState): JSXElement => { + assertSlots<{ root: NonNullable }>(state); + + return ; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverFooter/useTeachingPopoverFooter.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverFooter/useTeachingPopoverFooter.ts new file mode 100644 index 0000000000000..b76c06142bfbb --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverFooter/useTeachingPopoverFooter.ts @@ -0,0 +1 @@ +export { useTeachingPopoverFooterBase_unstable as useTeachingPopoverFooter } from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverHeader/TeachingPopoverHeader.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverHeader/TeachingPopoverHeader.tsx new file mode 100644 index 0000000000000..22a5e686afe94 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverHeader/TeachingPopoverHeader.tsx @@ -0,0 +1,14 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { TeachingPopoverHeaderProps } from './TeachingPopoverHeader.types'; +import { useTeachingPopoverHeader } from './useTeachingPopoverHeader'; +import { renderTeachingPopoverHeader } from './renderTeachingPopoverHeader'; + +export const TeachingPopoverHeader: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useTeachingPopoverHeader(props, ref); + return renderTeachingPopoverHeader(state); +}); + +TeachingPopoverHeader.displayName = 'TeachingPopoverHeader'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverHeader/TeachingPopoverHeader.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverHeader/TeachingPopoverHeader.types.ts new file mode 100644 index 0000000000000..0b607c7af214b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverHeader/TeachingPopoverHeader.types.ts @@ -0,0 +1,5 @@ +export type { + TeachingPopoverHeaderBaseProps as TeachingPopoverHeaderProps, + TeachingPopoverHeaderBaseState as TeachingPopoverHeaderState, + TeachingPopoverHeaderSlots, +} from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverHeader/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverHeader/index.ts new file mode 100644 index 0000000000000..cc67d538310a3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverHeader/index.ts @@ -0,0 +1,8 @@ +export { TeachingPopoverHeader } from './TeachingPopoverHeader'; +export { useTeachingPopoverHeader } from './useTeachingPopoverHeader'; +export { renderTeachingPopoverHeader } from './renderTeachingPopoverHeader'; +export type { + TeachingPopoverHeaderProps, + TeachingPopoverHeaderSlots, + TeachingPopoverHeaderState, +} from './TeachingPopoverHeader.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverHeader/renderTeachingPopoverHeader.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverHeader/renderTeachingPopoverHeader.ts new file mode 100644 index 0000000000000..578cab2870773 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverHeader/renderTeachingPopoverHeader.ts @@ -0,0 +1,7 @@ +import { renderTeachingPopoverHeader_unstable } from '@fluentui/react-teaching-popover'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { TeachingPopoverHeaderState } from './TeachingPopoverHeader.types'; + +export const renderTeachingPopoverHeader = renderTeachingPopoverHeader_unstable as ( + state: TeachingPopoverHeaderState, +) => JSXElement; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverHeader/useTeachingPopoverHeader.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverHeader/useTeachingPopoverHeader.ts new file mode 100644 index 0000000000000..82ee8f1e2abcf --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverHeader/useTeachingPopoverHeader.ts @@ -0,0 +1 @@ +export { useTeachingPopoverHeaderBase_unstable as useTeachingPopoverHeader } from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverSurface/TeachingPopoverSurface.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverSurface/TeachingPopoverSurface.tsx new file mode 100644 index 0000000000000..cb1c2d807fbd3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverSurface/TeachingPopoverSurface.tsx @@ -0,0 +1 @@ +export { PopoverSurface as TeachingPopoverSurface } from '../../Popover/PopoverSurface/PopoverSurface'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverSurface/TeachingPopoverSurface.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverSurface/TeachingPopoverSurface.types.ts new file mode 100644 index 0000000000000..9c95aae7ba3e1 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverSurface/TeachingPopoverSurface.types.ts @@ -0,0 +1,5 @@ +export type { + PopoverSurfaceSlots as TeachingPopoverSurfaceSlots, + PopoverSurfaceProps as TeachingPopoverSurfaceProps, + PopoverSurfaceState as TeachingPopoverSurfaceState, +} from '../../Popover/PopoverSurface/PopoverSurface.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverSurface/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverSurface/index.ts new file mode 100644 index 0000000000000..57cf99f6e0edb --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverSurface/index.ts @@ -0,0 +1,8 @@ +export { TeachingPopoverSurface } from './TeachingPopoverSurface'; +export { useTeachingPopoverSurface } from './useTeachingPopoverSurface'; +export { renderTeachingPopoverSurface } from './renderTeachingPopoverSurface'; +export type { + TeachingPopoverSurfaceSlots, + TeachingPopoverSurfaceProps, + TeachingPopoverSurfaceState, +} from './TeachingPopoverSurface.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverSurface/renderTeachingPopoverSurface.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverSurface/renderTeachingPopoverSurface.tsx new file mode 100644 index 0000000000000..a9a47ead60481 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverSurface/renderTeachingPopoverSurface.tsx @@ -0,0 +1 @@ +export { renderPopoverSurface as renderTeachingPopoverSurface } from '../../Popover/PopoverSurface/renderPopoverSurface'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverSurface/useTeachingPopoverSurface.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverSurface/useTeachingPopoverSurface.ts new file mode 100644 index 0000000000000..0ae40ec8f123a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverSurface/useTeachingPopoverSurface.ts @@ -0,0 +1 @@ +export { usePopoverSurface as useTeachingPopoverSurface } from '../../Popover/PopoverSurface/usePopoverSurface'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTitle/TeachingPopoverTitle.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTitle/TeachingPopoverTitle.tsx new file mode 100644 index 0000000000000..8ae7a2cece3cb --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTitle/TeachingPopoverTitle.tsx @@ -0,0 +1,14 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { TeachingPopoverTitleProps } from './TeachingPopoverTitle.types'; +import { useTeachingPopoverTitle } from './useTeachingPopoverTitle'; +import { renderTeachingPopoverTitle } from './renderTeachingPopoverTitle'; + +export const TeachingPopoverTitle: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useTeachingPopoverTitle(props, ref); + return renderTeachingPopoverTitle(state); +}); + +TeachingPopoverTitle.displayName = 'TeachingPopoverTitle'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTitle/TeachingPopoverTitle.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTitle/TeachingPopoverTitle.types.ts new file mode 100644 index 0000000000000..46b9be36ba013 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTitle/TeachingPopoverTitle.types.ts @@ -0,0 +1,5 @@ +export type { + TeachingPopoverTitleBaseProps as TeachingPopoverTitleProps, + TeachingPopoverTitleBaseState as TeachingPopoverTitleState, + TeachingPopoverTitleSlots, +} from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTitle/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTitle/index.ts new file mode 100644 index 0000000000000..ba438d142db77 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTitle/index.ts @@ -0,0 +1,8 @@ +export { TeachingPopoverTitle } from './TeachingPopoverTitle'; +export { useTeachingPopoverTitle } from './useTeachingPopoverTitle'; +export { renderTeachingPopoverTitle } from './renderTeachingPopoverTitle'; +export type { + TeachingPopoverTitleProps, + TeachingPopoverTitleSlots, + TeachingPopoverTitleState, +} from './TeachingPopoverTitle.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTitle/renderTeachingPopoverTitle.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTitle/renderTeachingPopoverTitle.ts new file mode 100644 index 0000000000000..6256ac5979845 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTitle/renderTeachingPopoverTitle.ts @@ -0,0 +1,7 @@ +import { renderTeachingPopoverTitle_unstable } from '@fluentui/react-teaching-popover'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { TeachingPopoverTitleState } from './TeachingPopoverTitle.types'; + +export const renderTeachingPopoverTitle = renderTeachingPopoverTitle_unstable as ( + state: TeachingPopoverTitleState, +) => JSXElement; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTitle/useTeachingPopoverTitle.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTitle/useTeachingPopoverTitle.ts new file mode 100644 index 0000000000000..c0da4bf53107d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTitle/useTeachingPopoverTitle.ts @@ -0,0 +1 @@ +export { useTeachingPopoverTitleBase_unstable as useTeachingPopoverTitle } from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTrigger/TeachingPopoverTrigger.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTrigger/TeachingPopoverTrigger.tsx new file mode 100644 index 0000000000000..a1846c9e6f3cf --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTrigger/TeachingPopoverTrigger.tsx @@ -0,0 +1 @@ +export { PopoverTrigger as TeachingPopoverTrigger } from '../../Popover/PopoverTrigger/PopoverTrigger'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTrigger/TeachingPopoverTrigger.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTrigger/TeachingPopoverTrigger.types.ts new file mode 100644 index 0000000000000..4fc687e3f0ed7 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTrigger/TeachingPopoverTrigger.types.ts @@ -0,0 +1,5 @@ +export type { + PopoverTriggerProps as TeachingPopoverTriggerProps, + PopoverTriggerState as TeachingPopoverTriggerState, + PopoverTriggerChildProps as TeachingPopoverTriggerChildProps, +} from '../../Popover/PopoverTrigger/PopoverTrigger.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTrigger/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTrigger/index.ts new file mode 100644 index 0000000000000..f55474197a146 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTrigger/index.ts @@ -0,0 +1,8 @@ +export { TeachingPopoverTrigger } from './TeachingPopoverTrigger'; +export { useTeachingPopoverTrigger } from './useTeachingPopoverTrigger'; +export { renderTeachingPopoverTrigger } from './renderTeachingPopoverTrigger'; +export type { + TeachingPopoverTriggerProps, + TeachingPopoverTriggerState, + TeachingPopoverTriggerChildProps, +} from './TeachingPopoverTrigger.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTrigger/renderTeachingPopoverTrigger.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTrigger/renderTeachingPopoverTrigger.ts new file mode 100644 index 0000000000000..f9b2198f6968c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTrigger/renderTeachingPopoverTrigger.ts @@ -0,0 +1 @@ +export { renderPopoverTrigger as renderTeachingPopoverTrigger } from '../../Popover/PopoverTrigger/renderPopoverTrigger'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTrigger/useTeachingPopoverTrigger.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTrigger/useTeachingPopoverTrigger.ts new file mode 100644 index 0000000000000..3cc001b10e140 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopoverTrigger/useTeachingPopoverTrigger.ts @@ -0,0 +1 @@ +export { usePopoverTrigger as useTeachingPopoverTrigger } from '../../Popover/PopoverTrigger/usePopoverTrigger'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/index.ts new file mode 100644 index 0000000000000..59ef4828fbd7a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/index.ts @@ -0,0 +1,137 @@ +export { TeachingPopover } from './TeachingPopover'; +export { useTeachingPopover } from './useTeachingPopover'; +export { useTeachingPopoverContextValues } from './useTeachingPopoverContextValues'; +export { renderTeachingPopover } from './renderTeachingPopover'; +export type { + TeachingPopoverProps, + TeachingPopoverState, + TeachingPopoverContextValues, + TeachingPopoverBaseBridgedContextValue, +} from './TeachingPopover.types'; + +export { + TeachingPopoverTrigger, + useTeachingPopoverTrigger, + renderTeachingPopoverTrigger, +} from './TeachingPopoverTrigger'; +export type { + TeachingPopoverTriggerProps, + TeachingPopoverTriggerState, + TeachingPopoverTriggerChildProps, +} from './TeachingPopoverTrigger'; + +export { + TeachingPopoverSurface, + useTeachingPopoverSurface, + renderTeachingPopoverSurface, +} from './TeachingPopoverSurface'; +export type { + TeachingPopoverSurfaceProps, + TeachingPopoverSurfaceSlots, + TeachingPopoverSurfaceState, +} from './TeachingPopoverSurface'; + +export { TeachingPopoverBody, useTeachingPopoverBody, renderTeachingPopoverBody } from './TeachingPopoverBody'; +export type { + TeachingPopoverBodyProps, + TeachingPopoverBodySlots, + TeachingPopoverBodyState, +} from './TeachingPopoverBody'; + +export { TeachingPopoverHeader, useTeachingPopoverHeader, renderTeachingPopoverHeader } from './TeachingPopoverHeader'; +export type { + TeachingPopoverHeaderProps, + TeachingPopoverHeaderSlots, + TeachingPopoverHeaderState, +} from './TeachingPopoverHeader'; + +export { TeachingPopoverTitle, useTeachingPopoverTitle, renderTeachingPopoverTitle } from './TeachingPopoverTitle'; +export type { + TeachingPopoverTitleProps, + TeachingPopoverTitleSlots, + TeachingPopoverTitleState, +} from './TeachingPopoverTitle'; + +export { TeachingPopoverFooter, useTeachingPopoverFooter, renderTeachingPopoverFooter } from './TeachingPopoverFooter'; +export type { TeachingPopoverFooterProps, TeachingPopoverFooterState } from './TeachingPopoverFooter'; + +export { + TeachingPopoverCarousel, + useTeachingPopoverCarousel, + useTeachingPopoverCarouselContextValues, + renderTeachingPopoverCarousel, +} from './TeachingPopoverCarousel'; +export type { + TeachingPopoverCarouselProps, + TeachingPopoverCarouselSlots, + TeachingPopoverCarouselState, + TeachingPopoverCarouselContextValues, +} from './TeachingPopoverCarousel'; + +export { + TeachingPopoverCarouselCard, + useTeachingPopoverCarouselCard, + renderTeachingPopoverCarouselCard, +} from './TeachingPopoverCarouselCard'; +export type { + TeachingPopoverCarouselCardProps, + TeachingPopoverCarouselCardSlots, + TeachingPopoverCarouselCardState, +} from './TeachingPopoverCarouselCard'; + +export { + TeachingPopoverCarouselFooter, + useTeachingPopoverCarouselFooter, + renderTeachingPopoverCarouselFooter, +} from './TeachingPopoverCarouselFooter'; +export type { + TeachingPopoverCarouselFooterProps, + TeachingPopoverCarouselFooterSlots, + TeachingPopoverCarouselFooterState, +} from './TeachingPopoverCarouselFooter'; + +export { + TeachingPopoverCarouselFooterButton, + useTeachingPopoverCarouselFooterButton, + renderTeachingPopoverCarouselFooterButton, +} from './TeachingPopoverCarouselFooterButton'; +export type { + TeachingPopoverCarouselFooterButtonProps, + TeachingPopoverCarouselFooterButtonSlots, + TeachingPopoverCarouselFooterButtonState, +} from './TeachingPopoverCarouselFooterButton'; + +export { + TeachingPopoverCarouselNav, + useTeachingPopoverCarouselNav, + renderTeachingPopoverCarouselNav, +} from './TeachingPopoverCarouselNav'; +export type { + NavButtonRenderFunction, + TeachingPopoverCarouselNavProps, + TeachingPopoverCarouselNavSlots, + TeachingPopoverCarouselNavState, +} from './TeachingPopoverCarouselNav'; + +export { + TeachingPopoverCarouselNavButton, + useTeachingPopoverCarouselNavButton, + renderTeachingPopoverCarouselNavButton, +} from './TeachingPopoverCarouselNavButton'; +export type { + TeachingPopoverCarouselNavButtonProps, + TeachingPopoverCarouselNavButtonSlots, + TeachingPopoverCarouselNavButtonState, +} from './TeachingPopoverCarouselNavButton'; + +export { + TeachingPopoverCarouselPageCount, + useTeachingPopoverCarouselPageCount, + renderTeachingPopoverCarouselPageCount, +} from './TeachingPopoverCarouselPageCount'; +export type { + TeachingPopoverCarouselPageCountProps, + TeachingPopoverCarouselPageCountRenderFunction, + TeachingPopoverCarouselPageCountSlots, + TeachingPopoverCarouselPageCountState, +} from './TeachingPopoverCarouselPageCount'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/renderTeachingPopover.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/renderTeachingPopover.tsx new file mode 100644 index 0000000000000..d45b36edd82c0 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/renderTeachingPopover.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { + PopoverProvider as BasePopoverProvider, + type PopoverContextValue as BasePopoverContextValue, +} from '@fluentui/react-popover'; +import { PopoverProvider } from '../Popover/popoverContext'; +import type { TeachingPopoverContextValues, TeachingPopoverState } from './TeachingPopover.types'; + +/** + * Renders TeachingPopover by providing both the headless `PopoverContext` + * (consumed by headless sub-components) and the `@fluentui/react-popover` + * `PopoverContext` (consumed by `@fluentui/react-teaching-popover` base + * hooks). The bridged value is cast to `BasePopoverContextValue` — the + * omitted fields (`size`, `inline`, etc.) are styling concerns that no base + * hook reads. + */ +export const renderTeachingPopover = ( + state: TeachingPopoverState, + contextValues: TeachingPopoverContextValues, +): React.ReactElement => ( + + + {state.popoverTrigger} + {state.open ? state.popoverSurface : null} + + +); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/useTeachingPopover.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/useTeachingPopover.ts new file mode 100644 index 0000000000000..831923bdde6ae --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/useTeachingPopover.ts @@ -0,0 +1,14 @@ +'use client'; + +import { usePopover } from '../Popover/usePopover'; +import type { TeachingPopoverProps, TeachingPopoverState } from './TeachingPopover.types'; + +/** + * Returns the state for a TeachingPopover component. + * + * Built on top of the headless `Popover` and defaults `withArrow` to `true`, + * matching the v9 TeachingPopover convention. + */ +export const useTeachingPopover = (props: TeachingPopoverProps): TeachingPopoverState => { + return usePopover({ withArrow: true, ...props }); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/useTeachingPopoverContextValues.ts b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/useTeachingPopoverContextValues.ts new file mode 100644 index 0000000000000..d85302a297013 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/useTeachingPopoverContextValues.ts @@ -0,0 +1,55 @@ +'use client'; + +import * as React from 'react'; +import { usePopoverContextValues } from '../Popover/usePopover'; +import type { + TeachingPopoverContextValues, + TeachingPopoverState, + TeachingPopoverBaseBridgedContextValue, +} from './TeachingPopover.types'; + +type BaseSetOpen = TeachingPopoverBaseBridgedContextValue['setOpen']; +type BaseToggleOpen = TeachingPopoverBaseBridgedContextValue['toggleOpen']; + +/** + * Builds both the headless `PopoverContext` value and a + * `@fluentui/react-popover`-compatible `PopoverContext` value. The bridge + * lets sub-components consume base hooks from + * `@fluentui/react-teaching-popover` (e.g. `useTeachingPopoverHeaderBase_unstable`), + * which read `toggleOpen` / `setOpen` / `triggerRef` from that context. + * + * The `@fluentui/react-popover` `OpenPopoverEvents` union is wider than the + * headless one (it accepts `FocusEvent`). Base hooks never fire focus-driven + * dismisses, so casting `state.setOpen` / `state.toggleOpen` to those + * signatures is safe — no extra event types reach them in practice. + */ +export const useTeachingPopoverContextValues = (state: TeachingPopoverState): TeachingPopoverContextValues => { + const { popover } = usePopoverContextValues(state); + + const basePopover = React.useMemo( + () => ({ + open: state.open, + setOpen: state.setOpen as unknown as BaseSetOpen, + toggleOpen: state.toggleOpen as unknown as BaseToggleOpen, + triggerRef: state.triggerRef, + contentRef: state.contentRef, + arrowRef: state.arrowRef, + openOnHover: state.openOnHover, + openOnContext: state.openOnContext, + withArrow: state.withArrow, + }), + [ + state.open, + state.setOpen, + state.toggleOpen, + state.triggerRef, + state.contentRef, + state.arrowRef, + state.openOnHover, + state.openOnContext, + state.withArrow, + ], + ); + + return { popover, basePopover }; +}; 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 0000000000000..97051edcb289f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/AriaLive/AriaLive.tsx @@ -0,0 +1,85 @@ +'use client'; + +import * as React from 'react'; +import { createPriorityQueue, useEventCallback, useTimeout } from '@fluentui/react-utilities'; +import type { ToastAnnounce, ToastAnnounceOptions, ToastLiveMessage } from '@fluentui/react-toast'; + +/** Duration the message stays in DOM so screen readers register the change. */ +const MESSAGE_DURATION = 500; + +const visuallyHiddenStyle: React.CSSProperties = { + position: 'absolute', + width: '1px', + height: '1px', + margin: '-1px', + padding: 0, + overflow: 'hidden', + clip: 'rect(0px, 0px, 0px, 0px)', +}; + +export type AriaLiveProps = { + announceRef: React.Ref; +}; + +/** + * 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: ToastAnnounceOptions) => { + const { politeness } = options; + if (message === currentMessage?.message) { + return; + } + const liveMessage: ToastLiveMessage = { message, politeness, createdAt: order.current++ }; + if (!currentMessage) { + setCurrentMessage(liveMessage); + } else { + messageQueue.enqueue(liveMessage); + } + }); + + 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 0000000000000..d5d42dd25568d --- /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 0000000000000..87ee5d55f1edb --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.cy.tsx @@ -0,0 +1,344 @@ +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'); + }); +}); 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 0000000000000..da620c438fca1 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.test.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { isConformant } from '../../testing/isConformant'; +import { Toast } from './Toast'; +import { ToastContainer } from './ToastContainer'; +import type { ToastContainerProps, ToastImperativeRef } from './'; + +const createToastContainerWrapper = + (props: Partial) => + ({ children }: React.PropsWithChildren) => { + const imperativeRef = React.useRef({ + focus: jest.fn(), + play: jest.fn(), + pause: jest.fn(), + }); + + return ( + + {children} + + ); + }; + +describe('Toast', () => { + isConformant({ + Component: Toast, + displayName: 'Toast', + }); + + it('renders children', () => { + const { getByTestId } = render(Default Toast, { + wrapper: createToastContainerWrapper({}), + }); + + const toast = getByTestId('toast'); + + expect(toast).toHaveTextContent('Default Toast'); + expect(toast).toHaveAttribute('data-intent', 'info'); + }); + + it('renders children with error intent', () => { + const { getByTestId } = render(Error Toast, { + wrapper: createToastContainerWrapper({ intent: 'error' }), + }); + + const toast = getByTestId('toast'); + + expect(toast).toHaveTextContent('Error Toast'); + expect(toast).toHaveAttribute('data-intent', 'error'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.tsx new file mode 100644 index 0000000000000..65f0c67d4580f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.tsx @@ -0,0 +1,18 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ToastProps } from './Toast.types'; +import { useToast } from './useToast'; +import { renderToast } from './renderToast'; + +/** + * A Toast component displays temporary, non-intrusive notifications to users. + * It's ideal for displaying brief messages, alerts, or feedback that auto-dismiss after a set duration. + */ +export const Toast: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useToast(props, ref); + return renderToast(state); +}); + +Toast.displayName = 'Toast'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.types.ts new file mode 100644 index 0000000000000..494f2cb8168e3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.types.ts @@ -0,0 +1,12 @@ +import type { ToastBaseState } from '@fluentui/react-toast'; +export type { ToastSlots, ToastBaseProps as ToastProps } from '@fluentui/react-toast'; + +export type ToastState = ToastBaseState & { + root: { + /** + * Indicates the semantic intent or status of the toast notification, determining visual styling and messaging context. + * Common values include: 'success', 'error', 'warning', 'info'. + */ + 'data-intent'?: string; + }; +}; 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 0000000000000..5550804834a6c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/ToastBody.tsx @@ -0,0 +1,17 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ToastBodyProps } from './ToastBody.types'; +import { useToastBody } from './useToastBody'; +import { renderToastBody } from './renderToastBody'; + +/** + * Represents the body of a toast, which typically contains the main content of the toast message. + */ +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 0000000000000..13ba2c9c2702c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/ToastBody.types.ts @@ -0,0 +1,5 @@ +export type { + ToastBodyBaseProps as ToastBodyProps, + ToastBodyBaseState as ToastBodyState, + ToastBodySlots, +} from '@fluentui/react-toast'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/index.ts new file mode 100644 index 0000000000000..5e0c19a130409 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/index.ts @@ -0,0 +1,4 @@ +export { ToastBody } from './ToastBody'; +export { renderToastBody } from './renderToastBody'; +export { useToastBody } from './useToastBody'; +export type { ToastBodyProps, ToastBodyState, ToastBodySlots } from './ToastBody.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/renderToastBody.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/renderToastBody.ts new file mode 100644 index 0000000000000..065b1048a57e3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/renderToastBody.ts @@ -0,0 +1,9 @@ +import { renderToastBody_unstable } from '@fluentui/react-toast'; +import type { JSXElement } from '@fluentui/react-utilities'; + +import type { ToastBodyState } from './ToastBody.types'; + +/** + * Renders the final JSX of the ToastBody component. + */ +export const renderToastBody = renderToastBody_unstable as (state: ToastBodyState) => JSXElement; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/useToastBody.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/useToastBody.ts new file mode 100644 index 0000000000000..1c3a72ede7823 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastBody/useToastBody.ts @@ -0,0 +1 @@ +export { useToastBodyBase_unstable as useToastBody } from '@fluentui/react-toast'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/ToastContainer.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/ToastContainer.test.tsx new file mode 100644 index 0000000000000..8c7b3e36b594f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/ToastContainer.test.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { resetIdsForTests } from '@fluentui/react-utilities'; +import { isConformant } from '../../../testing/isConformant'; +import type { ToastContainerProps } from './ToastContainer.types'; +import { ToastContainer } from './ToastContainer'; + +const defaultToastContainerProps: ToastContainerProps = { + announce: () => null, + close: () => null, + data: {}, + pauseOnHover: false, + pauseOnWindowBlur: false, + politeness: 'polite', + remove: () => null, + timeout: -1, + intent: undefined, + updateId: 0, + visible: true, + imperativeRef: { current: null }, + tryRestoreFocus: () => null, + order: 0, + content: '', + onStatusChange: () => null, + position: 'bottom-end', + toastId: 'toast-id', + priority: 0, + toasterId: 'toaster-id', +}; + +describe('ToastContainer', () => { + beforeEach(() => { + jest.useRealTimers(); + resetIdsForTests(); + }); + + isConformant({ + Component: ToastContainer, + displayName: 'ToastContainer', + requiredProps: defaultToastContainerProps, + disabledTests: [ + // Callback argument signature includes toast metadata from ToastData. + 'consistent-callback-args', + // Headless ToastContainer has no static classnames object. + 'component-has-static-classnames-object', + 'make-styles-overrides-win', + // ToastContainer is exported from `toast.ts` rather than top-level `toast-container.ts`. + 'has-top-level-file-extra', + 'export-map-entry-exists', + ], + }); + + it('renders listitem semantics and generated accessible ids', () => { + const { getByRole } = render( + Default ToastContainer, + ); + + const toast = getByRole('listitem'); + + expect(toast).toHaveTextContent('Default ToastContainer'); + expect(toast).toHaveAttribute('aria-labelledby'); + expect(toast).toHaveAttribute('aria-describedby'); + }); + + it('announces on mount with default politeness', () => { + const announce = jest.fn(); + + render( + + ToastContainer + , + ); + + expect(announce).toHaveBeenCalledTimes(1); + expect(announce).toHaveBeenCalledWith('ToastContainer', { politeness: 'polite' }); + }); + + it('respects user root props from data.root', () => { + const className = 'custom-toast-root'; + const { container } = render( + + ToastContainer + , + ); + + expect(container.querySelector(`.${className}`)).not.toBeNull(); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/ToastContainer.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/ToastContainer.tsx new file mode 100644 index 0000000000000..bdb1ca171bdda --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/ToastContainer.tsx @@ -0,0 +1,21 @@ +'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'; + +/** + * Wraps a Toast and provides context for its content and behavior. + * + * The ToastContainer should be rendered inside a Toaster, which manages the stacking and positioning of multiple ToastContainers. + */ +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 0000000000000..29776156f735d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/ToastContainer.types.ts @@ -0,0 +1,32 @@ +import type * as React from 'react'; +import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import type { ToastAnnounce, ToastData } from '@fluentui/react-toast'; +import type { ToastContainerContextValue } from '@fluentui/react-toast'; + +export type { ToastContainerContextValue }; + +export type ToastContainerContextValues = { + toast: ToastContainerContextValue; +}; + +export type ToastContainerSlots = { + root: NonNullable>; +}; + +export type ToastContainerProps = Omit>, 'content'> & + ToastData & { + visible: boolean; + tryRestoreFocus: () => void; + /** + * Announcer used to narrate this toast's text content to screen readers. + * Supplied by the parent `Toaster`; consumers do not need to pass this directly. + */ + announce?: ToastAnnounce; + }; + +export type ToastContainerState = ComponentState & + Pick & + Pick & { + running: boolean; + nodeRef: React.Ref; + }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/index.ts new file mode 100644 index 0000000000000..98cc812b13640 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/index.ts @@ -0,0 +1,11 @@ +export { ToastContainer } from './ToastContainer'; +export { renderToastContainer } from './renderToastContainer'; +export { useToastContainer } from './useToastContainer'; +export { useToastContainerContextValues } from './useToastContainerContextValues'; +export type { + ToastContainerContextValues, + ToastContainerProps, + ToastContainerSlots, + ToastContainerState, + ToastContainerContextValue, +} from './ToastContainer.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/renderToastContainer.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/renderToastContainer.tsx new file mode 100644 index 0000000000000..0c26e932c37a4 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/renderToastContainer.tsx @@ -0,0 +1,23 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ + +import { assertSlots } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import { ToastContainerContextProvider } from '@fluentui/react-toast'; +import type { ToastContainerContextValues, ToastContainerSlots, ToastContainerState } from './ToastContainer.types'; + +/** + * Renders the final JSX of the ToastContainer component. + */ +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/usePausableTimer.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/usePausableTimer.ts new file mode 100644 index 0000000000000..dd232498c0d07 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/usePausableTimer.ts @@ -0,0 +1,69 @@ +'use client'; + +import * as React from 'react'; +import { useFluent_unstable } from '@fluentui/react-shared-contexts'; +import { useEventCallback } from '@fluentui/react-utilities'; + +export type UsePausableTimerOptions = { + /** + * Total timer duration in milliseconds. A negative value disables the timer. + */ + timeout: number; + /** + * Whether the timer is currently counting down. Toggling preserves elapsed time. + */ + running: boolean; + /** + * Called when the timer naturally elapses while running. + */ + onTimeout: () => void; + /** + * Changing this value resets the timer, discarding elapsed time. + */ + resetKey?: unknown; +}; + +/** + * A pausable countdown timer backed by the Web Animations API. + * + * The native `Animation.pause()` / `Animation.play()` semantics preserve elapsed + * time across pause/resume — equivalent to a CSS animation's `animation-play-state`, + * but without any DOM node or stylesheet. This matches the original `react-toast` + * `` behavior, where hovering doesn't restart the countdown. + */ +export const usePausableTimer = ({ timeout, running, onTimeout, resetKey }: UsePausableTimerOptions): void => { + const { targetDocument } = useFluent_unstable(); + const animationRef = React.useRef(null); + const handleTimeout = useEventCallback(onTimeout); + + React.useEffect(() => { + if (timeout < 0 || !targetDocument?.defaultView?.Animation) { + return; + } + + const animation = new targetDocument.defaultView.Animation( + new targetDocument.defaultView.KeyframeEffect(null, null, timeout), + targetDocument.timeline, + ); + animation.onfinish = handleTimeout; + animationRef.current = animation; + + return () => { + animation.onfinish = null; + animation.cancel(); + animationRef.current = null; + }; + }, [timeout, targetDocument, handleTimeout, resetKey]); + + React.useEffect(() => { + const animation = animationRef.current; + if (!animation) { + return; + } + if (running) { + animation.play(); + } else { + animation.pause(); + } + }, [running, resetKey]); +}; 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 0000000000000..e058b6cdabb33 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/useToastContainer.ts @@ -0,0 +1,199 @@ +'use client'; + +import * as React from 'react'; +import { getIntrinsicElementProps, slot, useEventCallback, useId, useMergedRefs } from '@fluentui/react-utilities'; +import { useFluent_unstable } from '@fluentui/react-shared-contexts'; +import { Delete } from '@fluentui/keyboard-keys'; +import type { ToastPoliteness, ToastStatus } from '@fluentui/react-toast'; +import type { ToastContainerProps, ToastContainerState } from './ToastContainer.types'; +import { usePausableTimer } from './usePausableTimer'; + +const intentPolitenessMap: Record, ToastPoliteness> = { + success: 'assertive', + warning: 'assertive', + error: 'assertive', + info: 'polite', +}; + +/** + * The `useToastContainer` hook processes the props sent to `ToastContainer` and returns state and slot props. + */ +export const useToastContainer = (props: ToastContainerProps, ref: React.Ref): ToastContainerState => { + const { + visible, + children, + close: closeProp, + remove, + updateId, + onStatusChange, + data, + timeout = -1, + intent = 'info', + politeness, + pauseOnHover, + pauseOnWindowBlur, + imperativeRef, + tryRestoreFocus, + announce, + content: _content, + ...rest + } = props; + + const titleId = useId('toast-title'); + const bodyId = useId('toast-body'); + const toastRef = React.useRef(null); + const { targetDocument } = useFluent_unstable(); + const [running, setRunning] = React.useState(false); + const imperativePauseRef = React.useRef(false); + const focusedToastBeforeClose = React.useRef(false); + + const close = useEventCallback(() => { + const activeElement = targetDocument?.activeElement; + if (activeElement && toastRef.current?.contains(activeElement)) { + focusedToastBeforeClose.current = true; + } + + closeProp(); + }); + + const reportStatus = useEventCallback((status: ToastStatus) => onStatusChange?.(null, { status, ...props })); + const pause = useEventCallback(() => setRunning(false)); + const play = useEventCallback(() => { + if (imperativePauseRef.current) { + return; + } + + if (timeout < 0) { + setRunning(true); + return; + } + + const activeElement = targetDocument?.activeElement; + const containsActive = !!(activeElement && toastRef.current?.contains(activeElement)); + if (!containsActive) { + setRunning(true); + } + }); + + React.useImperativeHandle(imperativeRef, () => ({ + focus: () => { + toastRef.current?.focus(); + }, + play: () => { + imperativePauseRef.current = false; + play(); + }, + pause: () => { + imperativePauseRef.current = true; + pause(); + }, + })); + + React.useEffect(() => { + return () => reportStatus('unmounted'); + }, [reportStatus]); + + React.useEffect(() => { + if (!targetDocument || !pauseOnWindowBlur) { + return; + } + + targetDocument.defaultView?.addEventListener('focus', play); + targetDocument.defaultView?.addEventListener('blur', pause); + return () => { + targetDocument.defaultView?.removeEventListener('focus', play); + targetDocument.defaultView?.removeEventListener('blur', pause); + }; + }, [targetDocument, pause, play, pauseOnWindowBlur]); + + React.useEffect(() => { + if (!visible) { + return; + } + + play(); + reportStatus('visible'); + }, [visible, play, reportStatus, updateId]); + + usePausableTimer({ timeout, running, onTimeout: close, resetKey: updateId }); + + React.useEffect(() => { + if (!visible) { + reportStatus('dismissed'); + remove(); + } + }, [visible, remove, reportStatus]); + + React.useEffect(() => { + return () => { + if (focusedToastBeforeClose.current) { + focusedToastBeforeClose.current = false; + tryRestoreFocus(); + } + }; + }, [tryRestoreFocus]); + + React.useEffect(() => { + if (!visible || !announce) { + return; + } + const resolvedPoliteness = politeness ?? intentPolitenessMap[intent]; + announce(toastRef.current?.textContent ?? '', { politeness: resolvedPoliteness }); + }, [announce, politeness, intent, visible, updateId]); + + const userRootSlot = (data as { root?: React.HTMLAttributes } | undefined)?.root; + + const onMouseEnter = useEventCallback((e: React.MouseEvent) => { + if (pauseOnHover) { + pause(); + } + userRootSlot?.onMouseEnter?.(e); + }); + + const onMouseLeave = useEventCallback((e: React.MouseEvent) => { + if (pauseOnHover) { + play(); + } + userRootSlot?.onMouseLeave?.(e); + }); + + const onKeyDown = useEventCallback((e: React.KeyboardEvent) => { + if (e.key === Delete) { + e.preventDefault(); + close(); + } + + userRootSlot?.onKeyDown?.(e); + }); + + return { + components: { + root: 'div', + }, + root: slot.always( + getIntrinsicElementProps('div', { + ref: useMergedRefs(ref, toastRef), + children, + tabIndex: 0, + role: 'listitem', + 'aria-labelledby': titleId, + 'aria-describedby': bodyId, + ...rest, + ...userRootSlot, + onMouseEnter, + onMouseLeave, + onKeyDown, + }), + { elementType: 'div' }, + ), + running, + visible, + remove, + close, + updateId, + nodeRef: toastRef, + intent, + titleId, + bodyId, + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/useToastContainerContextValues.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/useToastContainerContextValues.ts new file mode 100644 index 0000000000000..20d8b83ee81e8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/useToastContainerContextValues.ts @@ -0,0 +1,20 @@ +'use client'; + +import * as React from 'react'; +import type { ToastContainerContextValues, ToastContainerState } from './ToastContainer.types'; + +export const useToastContainerContextValues = (state: ToastContainerState): ToastContainerContextValues => { + const { close, intent, titleId, bodyId } = state; + + const toast = React.useMemo( + () => ({ + close, + intent, + titleId, + bodyId, + }), + [close, intent, titleId, bodyId], + ); + + return { toast }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/ToastFooter.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/ToastFooter.tsx new file mode 100644 index 0000000000000..32f390cb226f0 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/ToastFooter.tsx @@ -0,0 +1,16 @@ +'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'; + +/** + * ToastFooter contains action buttons related to the toast message. + */ +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 0000000000000..2f37d5f8ffdc1 --- /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 0000000000000..8fd334f9f9644 --- /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 0000000000000..e85df81c6a19b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/renderToastFooter.ts @@ -0,0 +1 @@ +export { renderToastFooter_unstable as renderToastFooter } from '@fluentui/react-toast'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/useToastFooter.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/useToastFooter.ts new file mode 100644 index 0000000000000..c00656749180c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastFooter/useToastFooter.ts @@ -0,0 +1 @@ +export { useToastFooter_unstable as useToastFooter } from '@fluentui/react-toast'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/ToastTitle.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/ToastTitle.tsx new file mode 100644 index 0000000000000..2858ce58e896b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/ToastTitle.tsx @@ -0,0 +1,17 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ToastTitleProps } from './ToastTitle.types'; +import { useToastTitle } from './useToastTitle'; +import { renderToastTitle } from './renderToastTitle'; + +/** + * Represents the title of a toast, which is a brief summary or headline for the toast message. + */ +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 0000000000000..61d0ac9d9f39f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/ToastTitle.types.ts @@ -0,0 +1,5 @@ +export type { + ToastTitleBaseProps as ToastTitleProps, + ToastTitleBaseState as ToastTitleState, + ToastTitleSlots, +} from '@fluentui/react-toast'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/index.ts new file mode 100644 index 0000000000000..222539f40b00f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/index.ts @@ -0,0 +1,4 @@ +export { ToastTitle } from './ToastTitle'; +export { renderToastTitle } from './renderToastTitle'; +export { useToastTitle } from './useToastTitle'; +export type { ToastTitleProps, ToastTitleState, ToastTitleSlots } from './ToastTitle.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/renderToastTitle.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/renderToastTitle.ts new file mode 100644 index 0000000000000..cf8ed3720650c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/renderToastTitle.ts @@ -0,0 +1,6 @@ +import { renderToastTitle_unstable } from '@fluentui/react-toast'; +import type { JSXElement } from '@fluentui/react-utilities'; + +import type { ToastTitleState } from './ToastTitle.types'; + +export const renderToastTitle = renderToastTitle_unstable as (state: ToastTitleState) => JSXElement; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/useToastTitle.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/useToastTitle.ts new file mode 100644 index 0000000000000..28da64324363c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastTitle/useToastTitle.ts @@ -0,0 +1 @@ +export { useToastTitleBase_unstable as useToastTitle } from '@fluentui/react-toast'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.test.tsx new file mode 100644 index 0000000000000..111180ba384e8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.test.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { isConformant } from '../../../testing/isConformant'; +import { Toaster } from './Toaster'; + +describe('Toaster', () => { + isConformant({ + Component: Toaster, + displayName: 'Toaster', + disabledTests: [ + // Toaster is a wrapper that does not expose a single root element. + 'component-has-root-ref', + 'component-handles-ref', + 'component-handles-classname', + 'component-has-static-classnames-object', + 'make-styles-overrides-win', + // Toaster is exported from `toast.ts` rather than top-level `toaster.ts`. + 'has-top-level-file-extra', + 'export-map-entry-exists', + ], + }); + + it('renders aria-live regions by default', () => { + const { container } = render(); + + expect(container.querySelector('[aria-live="assertive"]')).not.toBeNull(); + expect(container.querySelector('[aria-live="polite"]')).not.toBeNull(); + }); + + it('does not render position containers when there are no toasts', () => { + const { container } = render(); + + expect(container.querySelector('[data-toaster-position]')).toBeNull(); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.tsx new file mode 100644 index 0000000000000..0a1e99b53f987 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.tsx @@ -0,0 +1,20 @@ +'use client'; + +import type { JSXElement } from '@fluentui/react-utilities'; +import type { ToasterProps } from './Toaster.types'; +import { useToaster } from './useToaster'; +import { renderToaster } from './renderToaster'; + +/** + * Toaster — subscribes to the event-driven toast state machine and renders + * position-based slot containers. Each container is promoted to the browser + * top layer via the native Popover API. + * + * Pair with useToastController from @fluentui/react-toast to dispatch and dismiss toasts imperatively. + */ +export const Toaster = (props: ToasterProps): JSXElement => { + const state = useToaster(props); + return renderToaster(state); +}; + +Toaster.displayName = 'Toaster'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.types.ts new file mode 100644 index 0000000000000..9823f9c4fa6c2 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.types.ts @@ -0,0 +1,29 @@ +import type { Slot } from '@fluentui/react-utilities'; +import type { + ToasterSlots, + ToasterProps as ToasterBaseProps, + ToasterState as ToasterBaseState, +} from '@fluentui/react-toast'; +export type { ToasterSlots } from '@fluentui/react-toast'; + +export type ToasterSlotsInternal = ToasterSlots & { + bottomEnd?: Slot<'div'>; + bottomStart?: Slot<'div'>; + topEnd?: Slot<'div'>; + topStart?: Slot<'div'>; + top?: Slot<'div'>; + bottom?: Slot<'div'>; +}; + +/** + * Toaster Props + * + * Headless Toaster always uses the native Popover API to render in the browser + * top layer — there is no `mountNode` prop and no `inline` opt-out. + */ +export type ToasterProps = Omit; + +/** + * State used in rendering Toaster + */ +export type ToasterState = Omit; 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 0000000000000..bd6856eb7f460 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/index.ts @@ -0,0 +1,4 @@ +export { Toaster } from './Toaster'; +export { renderToaster } from './renderToaster'; +export { useToaster } from './useToaster'; +export type { ToasterSlots, ToasterProps, ToasterState } from './Toaster.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/renderToaster.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/renderToaster.tsx new file mode 100644 index 0000000000000..6973fc5f05735 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/renderToaster.tsx @@ -0,0 +1,42 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ + +import { assertSlots } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +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. Containers use + * the native Popover API to render in the browser top layer. + */ +export const renderToaster = (state: ToasterState): JSXElement => { + const { announceRef, renderAriaLive } = state; + + assertSlots(state); + + const hasToasts = + !!state.bottomStart || !!state.bottomEnd || !!state.topStart || !!state.topEnd || !!state.top || !!state.bottom; + + const ariaLive = renderAriaLive ? : null; + const positionSlots = ( + <> + {state.bottom ? : null} + {state.bottomStart ? : null} + {state.bottomEnd ? : null} + {state.topStart ? : null} + {state.topEnd ? : null} + {state.top ? : null} + + ); + + return ( + <> + {ariaLive} + {hasToasts ? positionSlots : null} + + ); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/useToaster.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/useToaster.tsx new file mode 100644 index 0000000000000..798a49bdbbf6e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/useToaster.tsx @@ -0,0 +1,131 @@ +'use client'; + +import * as React from 'react'; +import { useToaster as useToasterState, useToastAnnounce } from '@fluentui/react-toast'; +import type { ToastAnnounce, ToastPosition } from '@fluentui/react-toast'; +import type { ToasterProps, ToasterState } from './Toaster.types'; +import type { ExtractSlotProps, Slot } from '@fluentui/react-utilities'; +import { + getIntrinsicElementProps, + slot, + useEventCallback, + useIsomorphicLayoutEffect, + 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'; + +const SUPPORTS_POPOVER_OPEN_SELECTOR = + typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('selector(:popover-open)'); + +/** + * Create the state required to render the Toaster. + */ +export const useToaster = (props: ToasterProps): ToasterState => { + const { toasterId, offset, shortcuts, announce: announceProp, ...rest } = props; + + const { toastsToRender, isToastVisible, tryRestoreFocus, closeAllToasts } = useToasterState({ + toasterId, + offset, + shortcuts, + pauseOnHover: true, + }); + + 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); + const popoverRef = React.useRef(null); + const positionHasToasts = toastsToRender.has(toastPosition); + + // Each rendered position container is its own native popover. We open it on + // mount so the position is promoted to the browser top layer. When the + // position has no more toasts, the slot unmounts and the browser removes + // it from the top layer automatically — no explicit hidePopover needed. + useIsomorphicLayoutEffect(() => { + if (!positionHasToasts) { + return; + } + const el = popoverRef.current; + if (!el || typeof el.showPopover !== 'function') { + return; + } + const isOpen = SUPPORTS_POPOVER_OPEN_SELECTOR && el.matches(':popover-open'); + if (!isOpen) { + el.showPopover(); + } + }, [positionHasToasts]); + + return slot.optional>>(positionHasToasts ? {} : null, { + defaultProps: { + ref: useMergedRefs(toasterRef, popoverRef), + children: toastsToRender.get(toastPosition)?.map(toast => ( + + {toast.content as React.ReactNode} + + )), + onKeyDown, + popover: 'manual' as const, + 'data-toaster-position': toastPosition, + role: 'list', + ...rootProps, + } 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, + 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, + }; +}; 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 0000000000000..766a5d6fbbc64 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/index.ts @@ -0,0 +1,46 @@ +// ─── Compound Toast content ────────────────────────────────────────────────── +export { Toast } from './Toast'; +export { renderToast } from './renderToast'; +export { useToast } from './useToast'; +export type { ToastProps, ToastState, ToastSlots } from './Toast.types'; + +// ─── Sub-components ─────────────────────────────────────────────────────────── +export { ToastTitle, renderToastTitle, useToastTitle } from './ToastTitle'; +export type { ToastTitleProps, ToastTitleState, ToastTitleSlots } from './ToastTitle'; + +export { ToastBody, renderToastBody, useToastBody } from './ToastBody'; +export type { ToastBodyProps, ToastBodyState, ToastBodySlots } from './ToastBody'; + +export { ToastFooter, renderToastFooter, useToastFooter } from './ToastFooter'; +export type { ToastFooterProps, ToastFooterSlots, ToastFooterState } from './ToastFooter'; + +// ─── Toaster DX (state-machine-driven) ─────────────────────────────────────── +export { Toaster, renderToaster, useToaster } from './Toaster'; +export type { ToasterProps, ToasterState } from './Toaster'; + +export { + ToastContainer, + renderToastContainer, + useToastContainer, + useToastContainerContextValues, +} from './ToastContainer'; +export type { + ToastContainerProps, + ToastContainerSlots, + ToastContainerState, + ToastContainerContextValue, +} from './ToastContainer'; + +// ─── Re-exported from @fluentui/react-toast for import convenience ──────────── +export { useToastController, useToastContainerContext } from '@fluentui/react-toast'; +export type { + ToastId, + ToasterId, + ToastIntent, + ToastStatus, + ToastPosition, + ToastPoliteness, + 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 0000000000000..ad045ee391772 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/renderToast.tsx @@ -0,0 +1,15 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ + +import { assertSlots } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { ToastState, ToastSlots } from './Toast.types'; + +/** + * Render the final JSX of Toast + */ +export const renderToast = (state: ToastState): JSXElement => { + assertSlots(state); + + return ; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/useToast.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/useToast.ts new file mode 100644 index 0000000000000..c87e552232286 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/useToast.ts @@ -0,0 +1,18 @@ +'use client'; + +import type * as React from 'react'; +import { useToastBase_unstable } from '@fluentui/react-toast'; + +import type { ToastProps, ToastState } from './Toast.types'; + +/** + * The `useToast` hook processes the props sent to `Toast` and returns state and slot props. + */ +export const useToast = (props: ToastProps, ref: React.Ref): ToastState => { + const state: ToastState = useToastBase_unstable(props, ref); + + // eslint-disable-next-line react-hooks/immutability + state.root['data-intent'] = state.intent; + + return state; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/teaching-popover.ts b/packages/react-components/react-headless-components-preview/library/src/teaching-popover.ts new file mode 100644 index 0000000000000..88c13e8c377aa --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/teaching-popover.ts @@ -0,0 +1,93 @@ +export { + TeachingPopover, + useTeachingPopover, + useTeachingPopoverContextValues, + renderTeachingPopover, + TeachingPopoverTrigger, + useTeachingPopoverTrigger, + renderTeachingPopoverTrigger, + TeachingPopoverSurface, + useTeachingPopoverSurface, + renderTeachingPopoverSurface, + TeachingPopoverBody, + useTeachingPopoverBody, + renderTeachingPopoverBody, + TeachingPopoverHeader, + useTeachingPopoverHeader, + renderTeachingPopoverHeader, + TeachingPopoverTitle, + useTeachingPopoverTitle, + renderTeachingPopoverTitle, + TeachingPopoverFooter, + useTeachingPopoverFooter, + renderTeachingPopoverFooter, + TeachingPopoverCarousel, + useTeachingPopoverCarousel, + useTeachingPopoverCarouselContextValues, + renderTeachingPopoverCarousel, + TeachingPopoverCarouselCard, + useTeachingPopoverCarouselCard, + renderTeachingPopoverCarouselCard, + TeachingPopoverCarouselFooter, + useTeachingPopoverCarouselFooter, + renderTeachingPopoverCarouselFooter, + TeachingPopoverCarouselFooterButton, + useTeachingPopoverCarouselFooterButton, + renderTeachingPopoverCarouselFooterButton, + TeachingPopoverCarouselNav, + useTeachingPopoverCarouselNav, + renderTeachingPopoverCarouselNav, + TeachingPopoverCarouselNavButton, + useTeachingPopoverCarouselNavButton, + renderTeachingPopoverCarouselNavButton, + TeachingPopoverCarouselPageCount, + useTeachingPopoverCarouselPageCount, + renderTeachingPopoverCarouselPageCount, +} from './components/TeachingPopover'; +export type { + TeachingPopoverProps, + TeachingPopoverState, + TeachingPopoverContextValues, + TeachingPopoverBaseBridgedContextValue, + TeachingPopoverTriggerProps, + TeachingPopoverTriggerState, + TeachingPopoverTriggerChildProps, + TeachingPopoverSurfaceProps, + TeachingPopoverSurfaceSlots, + TeachingPopoverSurfaceState, + TeachingPopoverBodyProps, + TeachingPopoverBodySlots, + TeachingPopoverBodyState, + TeachingPopoverHeaderProps, + TeachingPopoverHeaderSlots, + TeachingPopoverHeaderState, + TeachingPopoverTitleProps, + TeachingPopoverTitleSlots, + TeachingPopoverTitleState, + TeachingPopoverFooterProps, + TeachingPopoverFooterState, + TeachingPopoverCarouselProps, + TeachingPopoverCarouselSlots, + TeachingPopoverCarouselState, + TeachingPopoverCarouselContextValues, + TeachingPopoverCarouselCardProps, + TeachingPopoverCarouselCardSlots, + TeachingPopoverCarouselCardState, + TeachingPopoverCarouselFooterProps, + TeachingPopoverCarouselFooterSlots, + TeachingPopoverCarouselFooterState, + TeachingPopoverCarouselFooterButtonProps, + TeachingPopoverCarouselFooterButtonSlots, + TeachingPopoverCarouselFooterButtonState, + NavButtonRenderFunction, + TeachingPopoverCarouselNavProps, + TeachingPopoverCarouselNavSlots, + TeachingPopoverCarouselNavState, + TeachingPopoverCarouselNavButtonProps, + TeachingPopoverCarouselNavButtonSlots, + TeachingPopoverCarouselNavButtonState, + TeachingPopoverCarouselPageCountProps, + TeachingPopoverCarouselPageCountRenderFunction, + TeachingPopoverCarouselPageCountSlots, + TeachingPopoverCarouselPageCountState, +} from './components/TeachingPopover'; 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 0000000000000..e2ad8fcf3e0d1 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/toast.ts @@ -0,0 +1,51 @@ +export { Toast, renderToast, useToast } from './components/Toast'; +export type { ToastProps, ToastState, ToastSlots, ToastIntent } from './components/Toast'; + +export { + ToastTitle, + renderToastTitle, + useToastTitle, + ToastBody, + renderToastBody, + useToastBody, + ToastFooter, + renderToastFooter, + useToastFooter, + Toaster, + renderToaster, + useToaster, + ToastContainer, + renderToastContainer, + useToastContainer, + useToastContainerContextValues, +} from './components/Toast'; +export type { + ToastTitleProps, + ToastTitleState, + ToastTitleSlots, + ToastBodyProps, + ToastBodyState, + ToastBodySlots, + ToastFooterProps, + ToastFooterSlots, + ToastFooterState, + ToasterProps, + ToasterState, + ToastContainerProps, + ToastContainerSlots, + ToastContainerState, + ToastContainerContextValue, +} from './components/Toast'; + +// ─── Re-exported from @fluentui/react-toast ────────────────────────────────── +export { useToastController, useToastContainerContext } from './components/Toast'; +export type { + ToastId, + ToasterId, + ToastStatus, + ToastPosition, + ToastPoliteness, + ToastImperativeRef, + ToastChangeHandler, + ToastChangeData, +} from './components/Toast'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/TeachingPopover/TeachingPopoverDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/TeachingPopover/TeachingPopoverDefault.stories.tsx new file mode 100644 index 0000000000000..c4d74d757fc0a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TeachingPopover/TeachingPopoverDefault.stories.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { DismissRegular, ImageRegular, LightbulbRegular } from '@fluentui/react-icons'; +import { + TeachingPopover, + TeachingPopoverBody, + TeachingPopoverFooter, + TeachingPopoverHeader, + TeachingPopoverSurface, + TeachingPopoverTitle, + TeachingPopoverTrigger, +} from '@fluentui/react-headless-components-preview/teaching-popover'; + +import styles from './teaching-popover.module.css'; +import popoverStyles from '../Popover/popover.module.css'; + +export const Default = (): React.ReactNode => ( + + + + + + }} + dismissButton={{ className: styles.dismissButton, children: }} + > + Tips + + }}> + Teaching Bubble Title +

This is a teaching popover body

+
+ + + + +
+
+); diff --git a/packages/react-components/react-headless-components-preview/stories/src/TeachingPopover/TeachingPopoverDescription.md b/packages/react-components/react-headless-components-preview/stories/src/TeachingPopover/TeachingPopoverDescription.md new file mode 100644 index 0000000000000..eecc374abe09b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TeachingPopover/TeachingPopoverDescription.md @@ -0,0 +1,3 @@ +The headless `TeachingPopover` is built on top of the headless `Popover`. It adds a structured header / title / body / footer composition and an optional paged carousel — without styling. Bring your own CSS. + +`TeachingPopover` re-uses the `@fluentui/react-teaching-popover` base hooks for its sub-components (`Header`, `Title`, `Footer`, `Carousel*`) and bridges the `@fluentui/react-popover` `PopoverContext` internally so dismiss buttons, finish handlers, and the carousel state machine all work transparently. diff --git a/packages/react-components/react-headless-components-preview/stories/src/TeachingPopover/TeachingPopoverWithCarousel.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/TeachingPopover/TeachingPopoverWithCarousel.stories.tsx new file mode 100644 index 0000000000000..e875cf6b2d5cc --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TeachingPopover/TeachingPopoverWithCarousel.stories.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; +import { DismissRegular } from '@fluentui/react-icons'; +import { + TeachingPopover, + TeachingPopoverBody, + TeachingPopoverCarousel, + TeachingPopoverCarouselCard, + TeachingPopoverCarouselFooter, + TeachingPopoverCarouselNav, + TeachingPopoverCarouselNavButton, + TeachingPopoverCarouselPageCount, + TeachingPopoverHeader, + TeachingPopoverSurface, + TeachingPopoverTitle, + TeachingPopoverTrigger, +} from '@fluentui/react-headless-components-preview/teaching-popover'; + +import styles from './teaching-popover.module.css'; +import popoverStyles from '../Popover/popover.module.css'; + +const PAGES = ['intro', 'features', 'wrap-up'] as const; + +const dismissButtonSlot = { + className: styles.dismissButton, + children: , +}; + +export const WithCarousel = (): React.ReactNode => ( + + + + + + `Slide ${i + 1} of ${PAGES.length}`}> +
+ + 👋 }} + dismissButton={dismissButtonSlot} + /> + + Welcome +

Let's take a quick tour of the new features.

+
+
+ + + ✨ }} + dismissButton={dismissButtonSlot} + /> + + Better workflows +

Save time with shortcuts you can configure to taste.

+
+
+ + + 🎉 }} + dismissButton={dismissButtonSlot} + /> + + You're ready +

That's the highlights — explore at your own pace.

+
+
+ + + + {(value: string) => ( + + )} + + + {(current, total) => `${current} / ${total}`} + + +
+
+
+
+); diff --git a/packages/react-components/react-headless-components-preview/stories/src/TeachingPopover/TeachingPopoverWithCarouselFooterSlots.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/TeachingPopover/TeachingPopoverWithCarouselFooterSlots.stories.tsx new file mode 100644 index 0000000000000..c5cd7ded49e15 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TeachingPopover/TeachingPopoverWithCarouselFooterSlots.stories.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { + TeachingPopover, + TeachingPopoverBody, + TeachingPopoverCarousel, + TeachingPopoverCarouselCard, + TeachingPopoverCarouselFooter, + TeachingPopoverSurface, + TeachingPopoverTitle, + TeachingPopoverTrigger, +} from '@fluentui/react-headless-components-preview/teaching-popover'; + +import styles from './teaching-popover.module.css'; +import popoverStyles from '../Popover/popover.module.css'; + +const PAGES = ['intro', 'features', 'wrap-up'] as const; + +export const WithCarouselFooterSlots = (): React.ReactNode => ( + + + + + + `Slide ${i + 1} of ${PAGES.length}`}> +
+ + + Welcome +

Buttons below come from `previous` / `next` slots.

+
+
+ + + + Slot defaults +

+ The hook supplies `navType: 'prev' / 'next'`; consumers only pass `altText` and + content. +

+
+
+ + + + That's it +

Slot API keeps the markup terse for simple carousels.

+
+
+ + +
+
+
+
+); diff --git a/packages/react-components/react-headless-components-preview/stories/src/TeachingPopover/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/TeachingPopover/index.stories.tsx new file mode 100644 index 0000000000000..8c12d7c526fd3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TeachingPopover/index.stories.tsx @@ -0,0 +1,18 @@ +import { TeachingPopover } from '@fluentui/react-headless-components-preview/teaching-popover'; + +import descriptionMd from './TeachingPopoverDescription.md'; + +export { Default } from './TeachingPopoverDefault.stories'; +export { WithCarousel } from './TeachingPopoverWithCarousel.stories'; + +export default { + title: 'Components/TeachingPopover', + component: TeachingPopover, + parameters: { + docs: { + description: { + component: descriptionMd, + }, + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/TeachingPopover/teaching-popover.module.css b/packages/react-components/react-headless-components-preview/stories/src/TeachingPopover/teaching-popover.module.css new file mode 100644 index 0000000000000..e8bacfc14a3a1 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TeachingPopover/teaching-popover.module.css @@ -0,0 +1,236 @@ +.surface { + background: var(--bg-elev); + border-radius: var(--radius-lg); + padding: var(--space-5); + min-width: 320px; + max-width: 360px; +} + +.header { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 13px; + font-weight: 500; + color: var(--text-muted); + margin: 0 0 var(--space-3); +} + +.headerIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + font-size: 16px; + color: var(--text-muted); +} + +/* Dismiss button — boxed subtle button, mirrors v9 TeachingPopoverHeader__dismissButton. */ +.dismissButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + margin-inline-start: auto; + border: var(--stroke-thin) solid var(--border-strong); + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + font-size: 14px; + line-height: 1; + cursor: pointer; + transition: background-color 80ms ease, border-color 80ms ease, color 80ms ease; +} + +.dismissButton:hover { + background: var(--surface-muted); + border-color: var(--border-stronger); + color: var(--text); +} + +.dismissButton:active { + background: var(--surface-sunken); +} + +.dismissButton:focus-visible { + outline: var(--stroke-thick) solid var(--accent); + outline-offset: 1px; +} + +.body { + display: flex; + flex-direction: column; + margin: 0 0 var(--space-4); +} + +/* Media placeholder — 288×176 (v9 'medium' aspect ratio) gray box. */ +.media { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + aspect-ratio: 288 / 176; + background: var(--surface-muted); + border-radius: var(--radius-sm); + margin-bottom: var(--space-3); + color: var(--text-faint); + font-size: 64px; +} + +.title { + font-size: 16px; + font-weight: 600; + color: var(--text); + margin: 0 0 var(--space-1); +} + +.bodyText { + font-size: 14px; + color: var(--text); + line-height: 1.45; + margin: 0; +} + +.footer { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + margin-top: 0; +} + +/* + Action button — mirrors v9 Button medium / secondary appearance: + 32px tall, 1px neutral border, 4px radius, ~96px min-width to match the v9 + TeachingPopoverCarouselFooterButton minimum. +*/ +.actionButton { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + min-width: 96px; + padding: 0 var(--space-3); + border: var(--stroke-thin) solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-elev); + color: var(--text); + font-family: inherit; + font-size: 13px; + font-weight: 600; + line-height: 1; + cursor: pointer; + transition: background-color 80ms ease, border-color 80ms ease, color 80ms ease; +} + +.actionButton:hover { + background: var(--surface-muted); + border-color: var(--border-stronger); +} + +.actionButton:active { + background: var(--surface-sunken); +} + +.actionButton:focus-visible { + outline: var(--stroke-thick) solid var(--accent); + outline-offset: 2px; +} + +.actionButton:disabled { + background: var(--surface-muted); + border-color: var(--border); + color: var(--text-faint); + cursor: not-allowed; +} + +/* Primary variant — mirrors v9 Button primary appearance. */ +.actionButtonPrimary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-contrast); +} + +.actionButtonPrimary:hover { + background: var(--accent-strong); + border-color: var(--accent-strong); +} + +.actionButtonPrimary:active { + background: var(--accent-strong); + border-color: var(--accent-strong); +} + +.actionButtonPrimary:disabled { + background: var(--accent-soft); + border-color: var(--accent-soft); + color: var(--accent); +} + +.carousel { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.carouselCard[hidden] { + display: none; +} + +.carouselFooter { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + margin-top: var(--space-3); +} + +.carouselNav { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} + +/* + Mirrors the v9 TeachingPopoverCarouselNavButton shape: an 8×8 circle when + unselected (30% accent), and a 16×8 rounded-rectangle "pill" when selected + (full accent). Width and border-radius animate so paging feels continuous. +*/ +.carouselNavButton { + display: flex; + box-sizing: border-box; + width: 8px; + height: 8px; + padding: 0; + border: 0; + border-radius: 50%; + background: color-mix(in srgb, var(--accent) 30%, transparent); + cursor: pointer; + transition: width 120ms ease, border-radius 120ms ease, background-color 120ms ease; +} + +@supports not (color: color-mix(in lch, white, black)) { + .carouselNavButton { + background: var(--accent); + opacity: 0.3; + } +} + +.carouselNavButton[aria-selected='true'] { + width: 16px; + border-radius: 4px; + background: var(--accent); + opacity: 1; +} + +.carouselNavButton:focus-visible { + outline: var(--stroke-thick) solid var(--accent); + outline-offset: 2px; +} + +.pageCount { + font-size: 12px; + color: var(--text-muted); +} 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 0000000000000..35b6d997860cf --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastCustomTimeout.stories.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { + Toast, + Toaster, + ToastTitle, + useToastController, + useToastContainerContext, +} from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +const DismissButton = ({ children }: { children: React.ReactNode }) => { + const { close } = useToastContainerContext(); + return ( + + ); +}; + +export const CustomTimeout = (): React.ReactNode => { + const toasterId = 'custom-timeout-toaster'; + 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 0000000000000..64f62c1db39df --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDefault.stories.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { + Toaster, + ToastTitle, + ToastBody, + ToastFooter, + useToastController, + Toast, +} from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +export const Default = (): React.ReactNode => { + const toasterId = 'default-toaster'; + const { dispatchToast } = useToastController(toasterId); + + const notify = () => + dispatchToast( + + + Undo + + } + > + Email sent + + + This is a toast body + + + + + + , + { intent: 'success', timeout: 10_000 }, + ); + + return ( + <> + + + + ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDescription.md new file mode 100644 index 0000000000000..48f6a9ed419f4 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDescription.md @@ -0,0 +1,35 @@ +A Toast displays temporary content to the user. Toasts are rendered as a separate surface that can be dismissed by +user action or an application timeout. Toasts are typically used in the following situations: + +- Update the user on the status of a task +- Display the progress of a task +- Notify the user to take an action +- Notify the user of an application update +- Warn the user of an error + +The Fluent UI Toast component uses an **imperative** API. Once a Toaster has been rendered, you can use the +`useToastController` hook to get access to imperative methods to dispatch a Toast. The Toast component itself +is simply a layout component. + +> ⚠️ In order for notifications that use toast to be fully accessible, developers should make the notifications +> available on a permanent surface too. One of the ways to do this in an application is to implement a notification +> centre. + +For live region debugging help, check our [Debugging Notifications](./?path=/docs/concepts-developer-accessibility-notification-debugging--docs) docs page. + +## Best practices + +### Do + +- Configure defaults on the Toaster +- Use the toast for non-critical messages +- Let the user view the toast content in the application after the toast dismissed +- Create a keyboard shortcut to move focus to actionable toasts +- Use `politeness` setting to differentiate urgent and non-urgent messages + +### Don't + +- Render too many toasts at once +- Use different positions for toasts +- Use more than one Toaster in an application +- Make every toast have `assertive` politeness diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDismissAll.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDismissAll.stories.tsx new file mode 100644 index 0000000000000..ace096e5a1bbf --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDismissAll.stories.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { Toast, Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +export const DismissAll = (): React.ReactNode => { + const toasterId = 'dismiss-all-toaster'; + 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 0000000000000..6555c6ace6446 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDismissToast.stories.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { Toast, Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +export const DismissToast = (): React.ReactNode => { + const toasterId = 'dismiss-toast-toaster'; + 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 0000000000000..77183e2d95795 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastDismissToastWithAction.stories.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { + Toast, + Toaster, + ToastTitle, + useToastController, + useToastContainerContext, +} from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +/** + * A dismiss button that reads `close` from `ToastContainerContext`. + * This is the headless equivalent of the styled layer's `ToastTrigger`. + */ +const DismissButton = ({ children }: { children: React.ReactNode }) => { + const { close } = useToastContainerContext(); + return ( + + ); +}; + +export const DismissToastWithAction = (): React.ReactNode => { + const toasterId = 'dismiss-toast-with-action-toaster'; + const { dispatchToast } = useToastController(toasterId); + + const notify = () => + dispatchToast( + + Dismiss}> + Dismiss me + + , + { intent: 'success' }, + ); + + return ( + <> + + + + ); +}; + +DismissToastWithAction.parameters = { + docs: { + description: { + story: [ + 'Use `useToastContainerContext()` to access `close` inside the dispatched content.', + 'Calling it closes the toast — this is the headless equivalent of the styled', + "layer's `ToastTrigger` component.", + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastIntent.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastIntent.stories.tsx new file mode 100644 index 0000000000000..9a6eb08e5e99e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastIntent.stories.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; +import { Toast, Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import type { ToastIntent } from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +const intentIcon: Record = { + success: '✓', + info: 'i', + warning: '⚠', + error: '✕', +}; + +const getIconBadgeClass = (intent: ToastIntent): string => { + switch (intent) { + case 'success': + return `${styles.iconBadge} ${styles.iconBadgeSuccess}`; + case 'info': + return `${styles.iconBadge} ${styles.iconBadgeInfo}`; + case 'warning': + return `${styles.iconBadge} ${styles.iconBadgeWarning}`; + case 'error': + return `${styles.iconBadge} ${styles.iconBadgeError}`; + default: + return styles.iconBadge; + } +}; + +const getIntentClass = (intent: ToastIntent): string => { + switch (intent) { + case 'success': + return `${styles.toast} ${styles.intentSuccess}`; + case 'info': + return `${styles.toast} ${styles.intentInfo}`; + case 'warning': + return `${styles.toast} ${styles.intentWarning}`; + case 'error': + return `${styles.toast} ${styles.intentError}`; + default: + return styles.toast; + } +}; + +export const Intent = (): React.ReactNode => { + const toasterId = 'intent-toaster'; + const { dispatchToast } = useToastController(toasterId); + const [intent, setIntent] = React.useState('success'); + + const notify = () => + dispatchToast( + + + Toast intent: {intent} + + , + { intent }, + ); + + return ( + <> + +
+
+ Intent +
+ {(['success', 'info', 'warning', 'error'] as ToastIntent[]).map(i => ( + + ))} +
+
+ +
+ + ); +}; + +Intent.parameters = { + docs: { + description: { + story: [ + 'The four standard intents — `success`, `info`, `warning`, `error` — are passed as a', + '`dispatchToast` option. The `intent` value is forwarded to `ToastContext` so that', + '`ToastTitle` can conditionally render the `media` slot. Fill that slot with any icon', + 'component you like; here we use a plain coloured ``.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastLifecycle.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastLifecycle.stories.tsx new file mode 100644 index 0000000000000..951eed46b347a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastLifecycle.stories.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import { + Toast, + Toaster, + ToastTitle, + ToastBody, + ToastFooter, + useToastController, +} from '@fluentui/react-headless-components-preview/toast'; +import type { ToastStatus } from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +export const ToastLifecycle = (): React.ReactNode => { + const toasterId = 'toast-lifecycle-toaster'; + const { dispatchToast } = useToastController(toasterId); + const [statusLog, setStatusLog] = React.useState<[number, ToastStatus][]>([]); + const [dismissed, setDismissed] = React.useState(true); + + const notify = () => { + dispatchToast( + + + Undo + + } + > + Email sent + + Subtitle} className={styles.bodyText}> + This is a toast body + + + + + + , + { + timeout: 1000, + intent: 'success', + onStatusChange: (_, { status: toastStatus }) => { + setDismissed(toastStatus === 'unmounted'); + setStatusLog(prev => [[Date.now(), toastStatus], ...prev]); + }, + }, + ); + }; + + return ( + <> + +
+
+ + +
+
+
Status log
+
+ {statusLog.map(([time, status], i) => { + const date = new Date(time); + return ( +
+ {date.toLocaleTimeString()} {status} +
+ ); + })} +
+
+
+ + ); +}; + +ToastLifecycle.parameters = { + docs: { + description: { + story: [ + 'The `onStatusChange` callback reports each lifecycle transition of a toast.', + 'Possible statuses: `queued`, `visible`, `dismissed`, `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 0000000000000..eb7e5d9d9d1e5 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastMultipleToasters.stories.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { Toast, Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +export const MultipleToasters = (): React.ReactNode => { + const firstId = 'first-toaster'; + const secondId = 'second-toaster'; + 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 0000000000000..518deb0358531 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastPauseAndPlay.stories.tsx @@ -0,0 +1,63 @@ +import * as React from 'react'; +import { Toast, Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +export const PauseAndPlay = (): React.ReactNode => { + const toasterId = 'pause-play-toaster'; + 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 0000000000000..5f5ca2ad4bb1f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastPauseOnHover.stories.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { Toast, Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +export const PauseOnHover = (): React.ReactNode => { + const toasterId = 'pause-on-hover-toaster'; + 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 0000000000000..118c3452efbb9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastPauseOnWindowBlur.stories.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { Toast, Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +export const PauseOnWindowBlur = (): React.ReactNode => { + const toasterId = 'pause-on-window-blur-toaster'; + 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/ToastPositions.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastPositions.stories.tsx new file mode 100644 index 0000000000000..b7efa7ab328e1 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastPositions.stories.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import type { ToastPosition } from '@fluentui/react-headless-components-preview/toast'; +import { Toaster, useToastController, ToastTitle, Toast } from '@fluentui/react-headless-components-preview/toast'; + +import toastStyles from './toast.module.css'; + +export const Positions = (): React.ReactElement => { + const toasterId = 'toaster-positions'; + const { dispatchToast } = useToastController(toasterId); + const [position, setPosition] = React.useState('top'); + const positions: ToastPosition[] = ['bottom', 'bottom-start', 'bottom-end', 'top', 'top-start', 'top-end']; + + const notify = () => + dispatchToast( + + This toast is {position} + , + { position, intent: 'success' }, + ); + + return ( + <> + +
+
+ Select a position +
+ {positions.map(value => ( + + ))} +
+
+ +
+ + ); +}; + +Positions.parameters = { + docs: { + description: { + story: [ + 'Toasts can be dispatched to all supported page positions, including the top and bottom center placements.', + 'We do not recommend to use more than one', + 'position for toasts in an application because that could be disorienting for users. Pick', + 'one desired position and configure it in the `Toaster`.', + ].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 0000000000000..2e063382ad6c8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastProgressToast.stories.tsx @@ -0,0 +1,96 @@ +import * as React from 'react'; +import { + Toast, + Toaster, + ToastTitle, + ToastBody, + ToastFooter, + useToastController, + useToastContainerContext, +} from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +const intervalDelay = 100; +const intervalIncrement = 5; + +const DismissButton = ({ children }: { children: React.ReactNode }) => { + const { close } = useToastContainerContext(); + return ( + + ); +}; + +const DownloadProgressBar: React.FC<{ onDownloadEnd: () => void }> = ({ onDownloadEnd }) => { + const [value, setValue] = React.useState(100); + + React.useEffect(() => { + if (value > 0) { + const id = setTimeout(() => setValue(v => Math.max(v - intervalIncrement, 0)), intervalDelay); + return () => clearTimeout(id); + } + if (value === 0) { + onDownloadEnd(); + } + }, [value, onDownloadEnd]); + + return ; +}; + +export const ProgressToast = (): React.ReactNode => { + const toasterId = 'progress-toaster'; + const toastId = `progress-${toasterId}`; + const [unmounted, setUnmounted] = React.useState(true); + const { dispatchToast, dismissToast } = useToastController(toasterId); + + const dismiss = React.useCallback(() => dismissToast(toastId), [dismissToast, toastId]); + + const notify = () => + dispatchToast( + + Dismiss}> + Downloading file + + +

This may take a while

+ +
+ + + + +
, + { + intent: 'success', + timeout: -1, + toastId, + onStatusChange: (_, { status }) => setUnmounted(status === 'unmounted'), + }, + ); + + return ( + <> + + + + ); +}; + +ProgressToast.parameters = { + docs: { + description: { + story: [ + 'Toasts can host arbitrary content — here a CSS progress bar is rendered inside a toast.', + 'The toast uses `timeout: -1` so it never auto-dismisses; the progress bar calls', + '`dismissToast` imperatively once it completes.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastUpdateToast.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastUpdateToast.stories.tsx new file mode 100644 index 0000000000000..eea2db26f0a4e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/ToastUpdateToast.stories.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { Toast, Toaster, ToastTitle, useToastController } from '@fluentui/react-headless-components-preview/toast'; +import styles from './toast.module.css'; + +export const UpdateToast = (): React.ReactNode => { + const toasterId = 'update-toast-toaster'; + 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 0000000000000..f9150431b6336 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/index.stories.tsx @@ -0,0 +1,36 @@ +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 { Positions } from './ToastPositions.stories'; +export { DismissToast } from './ToastDismissToast.stories'; +export { DismissToastWithAction } from './ToastDismissToastWithAction.stories'; +export { UpdateToast } from './ToastUpdateToast.stories'; +export { DismissAll } from './ToastDismissAll.stories'; +export { CustomTimeout } from './ToastCustomTimeout.stories'; +export { PauseOnHover } from './ToastPauseOnHover.stories'; +export { PauseOnWindowBlur } from './ToastPauseOnWindowBlur.stories'; +export { PauseAndPlay } from './ToastPauseAndPlay.stories'; +export { ToastLifecycle } from './ToastLifecycle.stories'; +export { MultipleToasters } from './ToastMultipleToasters.stories'; +export { ProgressToast } from './ToastProgressToast.stories'; + +export default { + title: 'Components/Toast', + component: Toast, + subcomponents: { + ToastTitle, + ToastBody, + ToastFooter, + Toaster, + }, + parameters: { + docs: { + description: { + component: descriptionMd, + }, + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/toast.module.css b/packages/react-components/react-headless-components-preview/stories/src/Toast/toast.module.css new file mode 100644 index 0000000000000..fa7925eda8b3f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/toast.module.css @@ -0,0 +1,569 @@ +.toaster { + border: none; + background: transparent; + display: flex; + padding: 0; + gap: var(--space-2); +} + +.toaster[data-toaster-position='top-start'] { + flex-direction: column; + margin: 16px auto auto 16px; + --toast-enter-animation: slideInFromLeft; +} + +.toaster[data-toaster-position='top'] { + flex-direction: column; + margin: 16px auto auto; + align-items: center; + --toast-enter-animation: slideInFromTop; +} + +.toaster[data-toaster-position='top-end'] { + flex-direction: column; + margin: 16px 16px auto auto; + --toast-enter-animation: slideInFromRight; +} + +.toaster[data-toaster-position='bottom-start'] { + flex-direction: column-reverse; + margin: auto auto 16px 16px; + --toast-enter-animation: slideInFromLeft; +} + +.toaster[data-toaster-position='bottom'] { + flex-direction: column-reverse; + margin: auto auto 16px; + align-items: center; + --toast-enter-animation: slideInFromBottom; +} + +.toaster[data-toaster-position='bottom-end'] { + flex-direction: column-reverse; + margin: auto 16px 16px auto; + --toast-enter-animation: slideInFromRight; +} + +/* Toast card wrapper — white elevated surface with subtle border and shadow */ +.toast { + display: grid; + grid-template-columns: auto 1fr auto; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-4); + margin: unset; + margin-block-start: auto; + margin-inline-start: auto; + padding: 12px; + min-width: 280px; + max-width: 400px; + transition: box-shadow var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard); + animation: var(--toast-enter-animation, slideInFromRight) var(--duration-medium) var(--ease-emphasized); +} + +.toast:hover { + box-shadow: var(--shadow-5); +} + +/* Intent accent — left border color per intent */ +.intentSuccess { + border-left: 4px solid var(--success); +} + +.intentInfo { + border-left: 4px solid var(--info); +} + +.intentWarning { + border-left: 4px solid var(--warning); +} + +.intentError { + border-left: 4px solid var(--danger); +} + +/* Toast title — heading with optional action slot */ +.title { + display: flex; + grid-column-end: 3; + font-size: 14px; + font-weight: 600; + line-height: 20px; + color: var(--text); + word-break: break-word; +} + +/* Toast subtitle — smaller, muted text */ +.subtitle { + display: block; + grid-column-start: 2; + grid-column-end: 3; + padding-top: 4px; + font-size: 12px; + color: var(--text-soft); + line-height: 1.5; +} + +/* Toast body — main content area */ +.body, +.bodyText { + grid-column-start: 2; + grid-column-end: 3; + padding-top: 6px; + font-size: 14px; + line-height: 20px; + font-weight: 400; + color: var(--text-muted); + word-break: break-word; +} + +.body { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +/* Toast body text — regular body content */ +.bodyText { + margin: 0; +} + +/* Toast footer — action button container */ +.footer { + grid-column-start: 2; + grid-column-end: 3; + display: flex; + align-items: center; + gap: var(--space-3); + padding-top: 16px; + flex-wrap: wrap; +} + +/* Icon badge — circular badge for intent icon */ +.iconBadge { + display: flex; + align-items: center; + justify-content: center; + grid-column-end: 2; + margin-inline-end: var(--space-2); + width: 24px; + height: 24px; + border-radius: var(--radius-pill); + font-size: 12px; + font-weight: 700; + color: var(--text-on-accent); + flex-shrink: 0; + transition: transform var(--duration-fast) var(--ease-standard); +} + +.iconBadge:hover { + transform: scale(1.08); +} + +.iconBadgeSuccess { + background: var(--success); +} + +.iconBadgeInfo { + background: var(--info); +} + +.iconBadgeWarning { + background: var(--warning); +} + +.iconBadgeError { + background: var(--danger); +} + +/* Action button — link-style button for toast actions */ +.actionBtn { + display: flex; + align-items: flex-start; + grid-column-end: -1; + appearance: none; + background: transparent; + border: 0; + padding: var(--space-1) var(--space-2); + margin: 0 calc(var(--space-1) * -1); + font-size: 12px; + font-weight: 600; + color: var(--accent); + cursor: pointer; + text-decoration: none; + transition: all var(--duration-fast) var(--ease-standard); + font-family: inherit; + border-radius: var(--radius-xs); +} + +.actionBtn:hover { + color: var(--accent-strong); + text-decoration: underline; + background: var(--accent-soft); +} + +.actionBtn:active { + color: var(--accent-strong); + transform: scale(0.98); +} + +.actionBtn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.actionBtn:disabled { + opacity: 0.5; + cursor: not-allowed; + text-decoration: none; +} + +/* Dismiss button — text link for dismissing toasts */ +.dismissBtn { + appearance: none; + background: transparent; + border: 0; + padding: var(--space-1) 0; + margin: 0 calc(var(--space-1) * -1); + font-size: 12px; + font-weight: 600; + color: var(--accent); + cursor: pointer; + text-decoration: none; + transition: all var(--duration-fast) var(--ease-standard); + font-family: inherit; + border-radius: var(--radius-xs); +} + +.dismissBtn:hover { + color: var(--accent-strong); + text-decoration: underline; + background: var(--accent-soft); +} + +.dismissBtn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Primary button — solid color action button */ +.btnPrimary { + display: inline-flex; + align-items: center; + justify-content: center; + appearance: none; + background: var(--accent); + color: var(--accent-contrast); + border: 1px solid transparent; + border-radius: var(--radius-pill); + padding: 0 var(--space-3); + height: 32px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all var(--duration-fast) var(--ease-standard); + font-family: inherit; +} + +.btnPrimary:hover { + background: var(--accent-strong); + transform: translateY(-1px); +} + +.btnPrimary:active { + transform: translateY(0); +} + +.btnPrimary:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); +} + +.btnPrimary:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +/* Secondary button — light background */ +.btnSecondary { + display: inline-flex; + align-items: center; + justify-content: center; + appearance: none; + background: var(--surface-muted); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-pill); + padding: 0 var(--space-3); + height: 32px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all var(--duration-fast) var(--ease-standard); + font-family: inherit; +} + +.btnSecondary:hover { + background: var(--surface-sunken); + border-color: var(--border-strong); + transform: translateY(-1px); +} + +.btnSecondary:active { + transform: translateY(0); +} + +.btnSecondary:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); +} + +.btnSecondary:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +/* Trigger button — for triggering toasts */ +.triggerBtn { + display: inline-flex; + align-items: center; + justify-content: center; + appearance: none; + background: var(--bg-elev); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0 var(--space-3); + height: 32px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all var(--duration-fast) var(--ease-standard); + font-family: inherit; +} + +.triggerBtn:hover { + background: var(--surface-muted); + border-color: var(--border-strong); + transform: translateY(-1px); +} + +.triggerBtn:active { + transform: translateY(0); +} + +.triggerBtn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); +} + +.triggerBtn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +/* Controls wrapper — vertical flex for controls in demo */ +.controls { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-3); + width: 100%; +} + +/* Fieldset — for grouping related controls */ +.fieldset { + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--space-3); + font-size: 12px; +} + +/* Legend — label for fieldset */ +.legend { + padding-inline: var(--space-2); + color: var(--text-muted); + font-size: 12px; + font-weight: 500; +} + +/* Radio/checkbox options container */ +.options { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +/* Option label */ +.optionLabel { + display: flex; + align-items: center; + gap: var(--space-2); + cursor: pointer; + font-size: 12px; + color: var(--text); +} + +.optionLabel input { + cursor: pointer; +} + +/* Progress bar styling */ +.progressBar { + width: 100%; + height: 4px; + margin-top: var(--space-1); +} + +.progressBar::-webkit-progress-bar { + background: var(--surface-muted); + border-radius: var(--radius-pill); + height: 4px; +} + +.progressBar::-webkit-progress-value { + background: var(--accent); + border-radius: var(--radius-pill); + transition: width var(--duration-medium) linear; +} + +.progressBar::-moz-progress-bar { + background: var(--accent); + border-radius: var(--radius-pill); + transition: width var(--duration-medium) linear; +} + +/* Toast slide-in animation */ +@keyframes slideInFromRight { + from { + opacity: 0; + transform: translateX(24px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes slideInFromLeft { + from { + opacity: 0; + transform: translateX(-24px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes slideInFromTop { + from { + opacity: 0; + transform: translateY(-24px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideInFromBottom { + from { + opacity: 0; + transform: translateY(24px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Toast fade-out animation */ +@keyframes fadeOut { + to { + opacity: 0; + transform: translateX(24px); + } +} + +/* Demo container — wrapper for demo content */ +.demo { + display: flex; + flex-direction: column; + gap: var(--space-4); + width: 100%; + max-width: 100%; +} + +/* Demo row — horizontal flex for related items */ +.demoRow { + display: flex; + flex-direction: row; + gap: var(--space-3); + align-items: center; + flex-wrap: wrap; +} + +/* Timeout input wrapper — vertical flex for label and input */ +.timeoutInput { + display: flex; + flex-direction: column; + gap: var(--space-1); + font-size: 12px; + color: var(--text); +} + +.timeoutInput input { + width: 128px; + border-radius: var(--radius-md); + border: 1px solid var(--border); + padding: 0 var(--space-2); + font-size: 12px; +} + +.timeoutInputHint { + font-size: 11px; + color: var(--text-faint); +} + +/* Log container — flex wrapper for lifecycle status log */ +.logContainer { + display: flex; + gap: var(--space-4); +} + +.logSection { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.logViewer { + display: flex; + flex-direction: column; + gap: var(--space-1); + min-width: 200px; +} + +.logHeader { + background: var(--text); + color: var(--bg); + font-size: 10px; + font-weight: bold; + padding: var(--space-2); + width: fit-content; + border-radius: var(--radius-xs); +} + +.logContent { + overflow-y: auto; + border: 2px solid var(--text); + padding: var(--space-3); + height: 200px; + font-size: 11px; + font-family: var(--font-mono); +}