diff --git a/change/@fluentui-react-toast-base-hooks.json b/change/@fluentui-react-toast-base-hooks.json new file mode 100644 index 0000000000000..6bc15ab22f877 --- /dev/null +++ b/change/@fluentui-react-toast-base-hooks.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add useToastBase_unstable, useToastTitleBase_unstable, and useToastBodyBase_unstable hooks", + "packageName": "@fluentui/react-toast", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-toast/library/etc/react-toast.api.md b/packages/react-components/react-toast/library/etc/react-toast.api.md index f791dbd4d1e2e..f460c374c5158 100644 --- a/packages/react-components/react-toast/library/etc/react-toast.api.md +++ b/packages/react-components/react-toast/library/etc/react-toast.api.md @@ -17,12 +17,39 @@ import type { Slot } from '@fluentui/react-utilities'; import type { SlotClassNames } from '@fluentui/react-utilities'; import type { TriggerProps } from '@fluentui/react-utilities'; +// @public (undocumented) +export type Announce = (message: string, options: AnnounceOptions) => void; + +// @public (undocumented) +export type AnnounceOptions = { + politeness: AriaLivePoliteness; +}; + +// @public (undocumented) +export type AriaLivePoliteness = 'polite' | 'assertive'; + +// @public (undocumented) +export interface DispatchToastOptions extends Partial> { + // (undocumented) + root?: Slot<'div'>; +} + +// @public (undocumented) +export type LiveMessage = { + message: string; + createdAt: number; + politeness: AriaLivePoliteness; +}; + // @public export const renderToast_unstable: (state: ToastState, contextValues: ToastContextValues) => JSXElement; // @public export const renderToastBody_unstable: (state: ToastBodyState) => JSXElement; +// @public +export const renderToastContainer_unstable: (state: ToastContainerState, contextValues: ToastContainerContextValues) => JSXElement; + // @public export const renderToaster_unstable: (state: ToasterState) => JSXElement; @@ -38,9 +65,21 @@ export const renderToastTrigger_unstable: (state: ToastTriggerState) => JSXEleme // @public export const Toast: ForwardRefComponent; +// @public +export type ToastBaseProps = Omit; + +// @public +export type ToastBaseState = Omit; + // @public export const ToastBody: ForwardRefComponent; +// @public +export type ToastBodyBaseProps = ToastBodyProps; + +// @public +export type ToastBodyBaseState = Omit; + // @public (undocumented) export const toastBodyClassNames: SlotClassNames; @@ -58,12 +97,46 @@ export type ToastBodyState = ComponentState & { backgroundAppearance: BackgroundAppearanceContextValue; }; +// @public (undocumented) +export interface ToastChangeData extends ToastOptions, Pick { + // (undocumented) + status: ToastStatus; +} + +// @public (undocumented) +export type ToastChangeHandler = (event: null, data: ToastChangeData) => void; + // @public (undocumented) export const toastClassNames: SlotClassNames; // @public (undocumented) export const toastContainerClassNames: SlotClassNames; +// @public (undocumented) +export const ToastContainerContextProvider: React_2.Provider; + +// @public (undocumented) +export type ToastContainerContextValue = { + close: () => void; + intent: ToastIntent | undefined; + bodyId: string; + titleId: string; +}; + +// @public +export type ToastContainerProps = Omit>, 'content'> & ToastData & { + visible: boolean; + announce: Announce; + intent: ToastIntent | undefined; + tryRestoreFocus: () => void; +}; + +// @public (undocumented) +export type ToastContainerSlots = { + root: NonNullable>; + timer: NonNullable>; +}; + // @public export type ToastContainerState = ComponentState & Pick & Pick & { transitionTimeout: number; @@ -76,18 +149,49 @@ export type ToastContainerState = ComponentState & Pick void; }; +// @public (undocumented) +export interface ToastData extends ToastOptions { + close: () => void; + // (undocumented) + imperativeRef: React_2.RefObject; + order: number; + remove: () => void; + updateId: number; +} + // @public export const Toaster: React_2.FC; // @public (undocumented) export const toasterClassNames: SlotClassNames; +// @public (undocumented) +export type ToasterId = string; + +// @public (undocumented) +export interface ToasterOptions extends Pick { + // (undocumented) + limit?: number; + // (undocumented) + offset?: ToastOffset; + // (undocumented) + shortcuts?: ToasterShortcuts; + // (undocumented) + toasterId?: ToasterId; +} + // @public export type ToasterProps = Omit, 'children'> & Partial & Pick & { announce?: Announce; inline?: boolean; }; +// @public (undocumented) +export interface ToasterShortcuts { + // (undocumented) + focus: (e: KeyboardEvent) => boolean; +} + // @public (undocumented) export type ToasterSlots = { root: Slot<'div'>; @@ -120,12 +224,35 @@ export type ToastFooterState = ComponentState; // @public (undocumented) export type ToastId = string; +// @public (undocumented) +export type ToastImperativeRef = { + focus: () => void; + play: () => void; + pause: () => void; +}; + // @public (undocumented) export type ToastIntent = 'info' | 'success' | 'error' | 'warning'; // @public (undocumented) export type ToastOffset = Partial> | ToastOffsetObject; +// @public (undocumented) +export interface ToastOptions { + content: unknown; + data: TData; + intent?: ToastIntent; + onStatusChange: ToastChangeHandler | undefined; + pauseOnHover: boolean; + pauseOnWindowBlur: boolean; + politeness?: ToastPoliteness; + position: ToastPosition; + priority: number; + timeout: number; + toasterId: ToasterId | undefined; + toastId: ToastId; +} + // @public (undocumented) export type ToastPoliteness = 'assertive' | 'polite'; @@ -154,6 +281,12 @@ export type ToastStatus = 'queued' | 'visible' | 'dismissed' | 'unmounted'; // @public export const ToastTitle: ForwardRefComponent; +// @public +export type ToastTitleBaseProps = ToastTitleProps; + +// @public +export type ToastTitleBaseState = Omit; + // @public (undocumented) export const toastTitleClassNames: SlotClassNames; @@ -188,15 +321,48 @@ export type ToastTriggerState = { children: React_2.ReactElement | null; }; +// @public (undocumented) +export interface UpdateToastEventDetail extends Partial, CommonToastDetail { + // (undocumented) + toastId: ToastId; +} + +// @public (undocumented) +export interface UpdateToastOptions extends UpdateToastEventDetail { + // (undocumented) + root?: Slot<'div'>; +} + // @public export const useToast_unstable: (props: ToastProps, ref: React_2.Ref) => ToastState; +// @public +export function useToastAnnounce(announce: Announce): { + announceToast: Announce; + toasterRef: React_2.RefCallback; +}; + +// @public +export const useToastBase_unstable: (props: ToastBaseProps, ref: React_2.Ref) => ToastBaseState; + // @public export const useToastBody_unstable: (props: ToastBodyProps, ref: React_2.Ref) => ToastBodyState; +// @public +export const useToastBodyBase_unstable: (props: ToastBodyBaseProps, ref: React_2.Ref) => ToastBodyBaseState; + // @public export const useToastBodyStyles_unstable: (state: ToastBodyState) => ToastBodyState; +// @public +export const useToastContainer_unstable: (props: ToastContainerProps, ref: React_2.Ref) => ToastContainerState; + +// @public (undocumented) +export const useToastContainerContext: () => ToastContainerContextValue; + +// @public (undocumented) +export function useToastContainerContextValues_unstable(state: ToastContainerState): ToastContainerContextValues; + // @public (undocumented) export function useToastController(toasterId?: ToasterId): { dispatchToast: (content: React_2.ReactNode, options?: DispatchToastOptions) => void; @@ -207,6 +373,16 @@ export function useToastController(toasterId?: ToasterId): { playToast: (toastId: ToastId) => void; }; +// @public (undocumented) +export function useToaster(options?: Partial): { + isToastVisible: (toastId: ToastId) => boolean; + toastsToRender: Map; + pauseAllToasts: () => void; + playAllToasts: () => void; + tryRestoreFocus: () => void; + closeAllToasts: () => void; +}; + // @public export const useToaster_unstable: (props: ToasterProps) => ToasterState; @@ -225,6 +401,9 @@ export const useToastStyles_unstable: (state: ToastState) => ToastState; // @public export const useToastTitle_unstable: (props: ToastTitleProps, ref: React_2.Ref) => ToastTitleState; +// @public +export const useToastTitleBase_unstable: (props: ToastTitleBaseProps, ref: React_2.Ref) => ToastTitleBaseState; + // @public export const useToastTitleStyles_unstable: (state: ToastTitleState) => ToastTitleState; diff --git a/packages/react-components/react-toast/library/src/Toast.ts b/packages/react-components/react-toast/library/src/Toast.ts index 13002c4a3b42e..f4bbcc0f1991e 100644 --- a/packages/react-components/react-toast/library/src/Toast.ts +++ b/packages/react-components/react-toast/library/src/Toast.ts @@ -1,8 +1,16 @@ -export type { ToastContextValues, ToastProps, ToastSlots, ToastState } from './components/Toast/index'; +export type { + ToastBaseProps, + ToastBaseState, + ToastContextValues, + ToastProps, + ToastSlots, + ToastState, +} from './components/Toast/index'; export { Toast, renderToast_unstable, toastClassNames, useToastStyles_unstable, + useToastBase_unstable, useToast_unstable, } from './components/Toast/index'; diff --git a/packages/react-components/react-toast/library/src/ToastBody.ts b/packages/react-components/react-toast/library/src/ToastBody.ts index 5ca80a167f342..7e8fd78fecfe0 100644 --- a/packages/react-components/react-toast/library/src/ToastBody.ts +++ b/packages/react-components/react-toast/library/src/ToastBody.ts @@ -1,8 +1,15 @@ -export type { ToastBodyProps, ToastBodySlots, ToastBodyState } from './components/ToastBody/index'; +export type { + ToastBodyBaseProps, + ToastBodyBaseState, + ToastBodyProps, + ToastBodySlots, + ToastBodyState, +} from './components/ToastBody/index'; export { ToastBody, renderToastBody_unstable, toastBodyClassNames, useToastBodyStyles_unstable, + useToastBodyBase_unstable, useToastBody_unstable, } from './components/ToastBody/index'; diff --git a/packages/react-components/react-toast/library/src/ToastTitle.ts b/packages/react-components/react-toast/library/src/ToastTitle.ts index 6765d6a8bc380..2fade006d768e 100644 --- a/packages/react-components/react-toast/library/src/ToastTitle.ts +++ b/packages/react-components/react-toast/library/src/ToastTitle.ts @@ -1,8 +1,15 @@ -export type { ToastTitleProps, ToastTitleSlots, ToastTitleState } from './components/ToastTitle/index'; +export type { + ToastTitleBaseProps, + ToastTitleBaseState, + ToastTitleProps, + ToastTitleSlots, + ToastTitleState, +} from './components/ToastTitle/index'; export { ToastTitle, renderToastTitle_unstable, toastTitleClassNames, useToastTitleStyles_unstable, + useToastTitleBase_unstable, useToastTitle_unstable, } from './components/ToastTitle/index'; diff --git a/packages/react-components/react-toast/library/src/Toaster.ts b/packages/react-components/react-toast/library/src/Toaster.ts index 340bda3b7a4aa..10baaa4b48303 100644 --- a/packages/react-components/react-toast/library/src/Toaster.ts +++ b/packages/react-components/react-toast/library/src/Toaster.ts @@ -5,4 +5,5 @@ export { toasterClassNames, useToasterStyles_unstable, useToaster_unstable, + useToastAnnounce, } from './components/Toaster/index'; diff --git a/packages/react-components/react-toast/library/src/components/Toast/Toast.types.ts b/packages/react-components/react-toast/library/src/components/Toast/Toast.types.ts index a68ab5e0b230b..ab39f712ebf37 100644 --- a/packages/react-components/react-toast/library/src/components/Toast/Toast.types.ts +++ b/packages/react-components/react-toast/library/src/components/Toast/Toast.types.ts @@ -17,6 +17,11 @@ export type ToastProps = ComponentProps & { appearance?: BackgroundAppearanceContextValue; }; +/** + * Toast Props without design-only props. + */ +export type ToastBaseProps = Omit; + /** * State used in rendering Toast */ @@ -24,3 +29,8 @@ export type ToastState = ComponentState & { backgroundAppearance: BackgroundAppearanceContextValue; intent?: ToastIntent | undefined; }; + +/** + * State used in rendering Toast, without design-only state. + */ +export type ToastBaseState = Omit; diff --git a/packages/react-components/react-toast/library/src/components/Toast/index.ts b/packages/react-components/react-toast/library/src/components/Toast/index.ts index 1e9d2f2c99231..d1d5f897e33d0 100644 --- a/packages/react-components/react-toast/library/src/components/Toast/index.ts +++ b/packages/react-components/react-toast/library/src/components/Toast/index.ts @@ -1,5 +1,12 @@ export { Toast } from './Toast'; -export type { ToastContextValues, ToastProps, ToastSlots, ToastState } from './Toast.types'; +export type { + ToastBaseProps, + ToastBaseState, + ToastContextValues, + ToastProps, + ToastSlots, + ToastState, +} from './Toast.types'; export { renderToast_unstable } from './renderToast'; -export { useToast_unstable } from './useToast'; +export { useToastBase_unstable, useToast_unstable } from './useToast'; export { toastClassNames, useToastStyles_unstable } from './useToastStyles.styles'; diff --git a/packages/react-components/react-toast/library/src/components/Toast/useToast.test.ts b/packages/react-components/react-toast/library/src/components/Toast/useToast.test.ts new file mode 100644 index 0000000000000..971644c5b37f7 --- /dev/null +++ b/packages/react-components/react-toast/library/src/components/Toast/useToast.test.ts @@ -0,0 +1,88 @@ +import { renderHook } from '@testing-library/react-hooks'; +import * as React from 'react'; +import { useToast_unstable, useToastBase_unstable } from './useToast'; +import { ToastContainerContextProvider } from '../../contexts/toastContainerContext'; +import type { ToastContainerContextValue } from '../../contexts/toastContainerContext'; + +const defaultContextValue: ToastContainerContextValue = { + close: () => null, + intent: undefined, + bodyId: 'body-id', + titleId: 'title-id', +}; + +function makeWrapper(contextValue: Partial = {}) { + const value = { ...defaultContextValue, ...contextValue }; + return ({ children }: { children: React.ReactNode }) => + React.createElement(ToastContainerContextProvider, { value }, children); +} + +describe('useToast_unstable', () => { + it('returns components shape { root: div }', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToast_unstable({}, ref), { wrapper: makeWrapper() }); + // eslint-disable-next-line @typescript-eslint/no-deprecated + expect(result.current.components).toEqual({ root: 'div' }); + }); + + it('always returns a root slot', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToast_unstable({}, ref), { wrapper: makeWrapper() }); + expect(result.current.root).toBeDefined(); + }); + + it('sets backgroundAppearance to undefined when appearance prop is omitted', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToast_unstable({}, ref), { wrapper: makeWrapper() }); + expect(result.current.backgroundAppearance).toBeUndefined(); + }); + + it('sets backgroundAppearance to "inverted" when appearance="inverted"', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToast_unstable({ appearance: 'inverted' }, ref), { + wrapper: makeWrapper(), + }); + expect(result.current.backgroundAppearance).toBe('inverted'); + }); + + it('sets backgroundAppearance to "brand" when appearance="brand"', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToast_unstable({ appearance: 'brand' }, ref), { + wrapper: makeWrapper(), + }); + expect(result.current.backgroundAppearance).toBe('brand'); + }); + + it('reads intent from ToastContainerContext', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToast_unstable({}, ref), { + wrapper: makeWrapper({ intent: 'success' }), + }); + expect(result.current.intent).toBe('success'); + }); + + it('intent is undefined when context does not provide one', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToast_unstable({}, ref), { + wrapper: makeWrapper({ intent: undefined }), + }); + expect(result.current.intent).toBeUndefined(); + }); + + it('spreads extra div props onto the root slot', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToast_unstable({ className: 'custom-class', 'aria-label': 'toast' }, ref), { + wrapper: makeWrapper(), + }); + expect(result.current.root.className).toBe('custom-class'); + expect(result.current.root['aria-label']).toBe('toast'); + }); +}); + +describe('useToastBase_unstable', () => { + it('does not include backgroundAppearance in returned state (design-only state lives in useToast_unstable)', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastBase_unstable({}, ref), { wrapper: makeWrapper() }); + expect((result.current as { backgroundAppearance?: unknown }).backgroundAppearance).toBeUndefined(); + }); +}); diff --git a/packages/react-components/react-toast/library/src/components/Toast/useToast.ts b/packages/react-components/react-toast/library/src/components/Toast/useToast.ts index d65fafb1808cf..4966606bd96cb 100644 --- a/packages/react-components/react-toast/library/src/components/Toast/useToast.ts +++ b/packages/react-components/react-toast/library/src/components/Toast/useToast.ts @@ -2,19 +2,16 @@ import type * as React from 'react'; import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; -import type { ToastProps, ToastState } from './Toast.types'; +import type { ToastBaseProps, ToastBaseState, ToastProps, ToastState } from './Toast.types'; import { useToastContainerContext } from '../../contexts/toastContainerContext'; /** - * Create the state required to render Toast. - * - * The returned state can be modified with hooks such as useToastStyles_unstable, - * before being passed to renderToast_unstable. + * Create the base state required to render Toast, without design-only props. * - * @param props - props from this instance of Toast + * @param props - props from this instance of Toast (without appearance) * @param ref - reference to root HTMLElement of Toast */ -export const useToast_unstable = (props: ToastProps, ref: React.Ref): ToastState => { +export const useToastBase_unstable = (props: ToastBaseProps, ref: React.Ref): ToastBaseState => { const { intent } = useToastContainerContext(); return { @@ -31,7 +28,23 @@ export const useToast_unstable = (props: ToastProps, ref: React.Ref }), { elementType: 'div' }, ), - backgroundAppearance: props.appearance, intent, }; }; + +/** + * Create the state required to render Toast. + * + * The returned state can be modified with hooks such as useToastStyles_unstable, + * before being passed to renderToast_unstable. + * + * @param props - props from this instance of Toast + * @param ref - reference to root HTMLElement of Toast + */ +export const useToast_unstable = (props: ToastProps, ref: React.Ref): ToastState => { + const state = useToastBase_unstable(props, ref); + return { + ...state, + backgroundAppearance: props.appearance, + }; +}; diff --git a/packages/react-components/react-toast/library/src/components/ToastBody/ToastBody.types.ts b/packages/react-components/react-toast/library/src/components/ToastBody/ToastBody.types.ts index ab7d81036c115..2db4b7c2ae03d 100644 --- a/packages/react-components/react-toast/library/src/components/ToastBody/ToastBody.types.ts +++ b/packages/react-components/react-toast/library/src/components/ToastBody/ToastBody.types.ts @@ -11,9 +11,19 @@ export type ToastBodySlots = { */ export type ToastBodyProps = ComponentProps & {}; +/** + * ToastBody Props without design-only props. + */ +export type ToastBodyBaseProps = ToastBodyProps; + /** * State used in rendering ToastBody */ export type ToastBodyState = ComponentState & { backgroundAppearance: BackgroundAppearanceContextValue; }; + +/** + * State used in rendering ToastBody, without design-only state. + */ +export type ToastBodyBaseState = Omit; diff --git a/packages/react-components/react-toast/library/src/components/ToastBody/index.ts b/packages/react-components/react-toast/library/src/components/ToastBody/index.ts index 2903dea7132ad..9de88b2dcff83 100644 --- a/packages/react-components/react-toast/library/src/components/ToastBody/index.ts +++ b/packages/react-components/react-toast/library/src/components/ToastBody/index.ts @@ -1,5 +1,11 @@ export { ToastBody } from './ToastBody'; -export type { ToastBodyProps, ToastBodySlots, ToastBodyState } from './ToastBody.types'; +export type { + ToastBodyBaseProps, + ToastBodyBaseState, + ToastBodyProps, + ToastBodySlots, + ToastBodyState, +} from './ToastBody.types'; export { renderToastBody_unstable } from './renderToastBody'; -export { useToastBody_unstable } from './useToastBody'; +export { useToastBodyBase_unstable, useToastBody_unstable } from './useToastBody'; export { toastBodyClassNames, useToastBodyStyles_unstable } from './useToastBodyStyles.styles'; diff --git a/packages/react-components/react-toast/library/src/components/ToastBody/useToastBody.test.ts b/packages/react-components/react-toast/library/src/components/ToastBody/useToastBody.test.ts new file mode 100644 index 0000000000000..e2f26222d8fcd --- /dev/null +++ b/packages/react-components/react-toast/library/src/components/ToastBody/useToastBody.test.ts @@ -0,0 +1,116 @@ +import { renderHook } from '@testing-library/react-hooks'; +import * as React from 'react'; +import { useToastBody_unstable, useToastBodyBase_unstable } from './useToastBody'; +import { ToastContainerContextProvider } from '../../contexts/toastContainerContext'; +import { BackgroundAppearanceProvider } from '@fluentui/react-shared-contexts'; +import type { ToastContainerContextValue } from '../../contexts/toastContainerContext'; +import type { BackgroundAppearanceContextValue } from '@fluentui/react-shared-contexts'; + +const defaultContextValue: ToastContainerContextValue = { + close: () => null, + intent: undefined, + bodyId: 'test-body-id', + titleId: 'test-title-id', +}; + +function makeWrapper( + options: { + context?: Partial; + backgroundAppearance?: BackgroundAppearanceContextValue; + } = {}, +) { + const contextValue = { ...defaultContextValue, ...options.context }; + return ({ children }: { children: React.ReactNode }) => + React.createElement( + BackgroundAppearanceProvider, + { value: options.backgroundAppearance }, + React.createElement(ToastContainerContextProvider, { value: contextValue }, children), + ); +} + +describe('useToastBody_unstable', () => { + it('returns components shape { root: div, subtitle: div }', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastBody_unstable({}, ref), { wrapper: makeWrapper() }); + // eslint-disable-next-line @typescript-eslint/no-deprecated + expect(result.current.components).toEqual({ root: 'div', subtitle: 'div' }); + }); + + it('always returns a root slot', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastBody_unstable({}, ref), { wrapper: makeWrapper() }); + expect(result.current.root).toBeDefined(); + }); + + it('applies bodyId from context to root.id', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastBody_unstable({}, ref), { + wrapper: makeWrapper({ context: { bodyId: 'my-body-id' } }), + }); + expect(result.current.root.id).toBe('my-body-id'); + }); + + it('returns undefined subtitle when subtitle prop is not provided', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastBody_unstable({}, ref), { wrapper: makeWrapper() }); + expect(result.current.subtitle).toBeUndefined(); + }); + + it('returns a subtitle slot when subtitle prop is provided', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastBody_unstable({ subtitle: 'sub text' }, ref), { + wrapper: makeWrapper(), + }); + expect(result.current.subtitle).toBeDefined(); + }); + + it('reads backgroundAppearance from BackgroundAppearanceContext — undefined by default', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastBody_unstable({}, ref), { wrapper: makeWrapper() }); + expect(result.current.backgroundAppearance).toBeUndefined(); + }); + + it('reads backgroundAppearance="inverted" from BackgroundAppearanceContext', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastBody_unstable({}, ref), { + wrapper: makeWrapper({ backgroundAppearance: 'inverted' }), + }); + expect(result.current.backgroundAppearance).toBe('inverted'); + }); + + it('reads backgroundAppearance="brand" from BackgroundAppearanceContext', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastBody_unstable({}, ref), { + wrapper: makeWrapper({ backgroundAppearance: 'brand' }), + }); + expect(result.current.backgroundAppearance).toBe('brand'); + }); + + it('does not derive backgroundAppearance from props (it has no appearance prop)', () => { + const ref = React.createRef(); + // ToastBody has no appearance prop; backgroundAppearance must always come from context + const { result } = renderHook(() => useToastBody_unstable({}, ref), { + wrapper: makeWrapper({ backgroundAppearance: 'brand' }), + }); + expect(result.current.backgroundAppearance).toBe('brand'); + }); + + it('spreads extra div props onto the root slot', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastBody_unstable({ className: 'body-class', 'aria-label': 'body' }, ref), { + wrapper: makeWrapper(), + }); + expect(result.current.root.className).toBe('body-class'); + expect(result.current.root['aria-label']).toBe('body'); + }); +}); + +describe('useToastBodyBase_unstable', () => { + it('does not read backgroundAppearance from context (design-only state lives in useToastBody_unstable)', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastBodyBase_unstable({}, ref), { + wrapper: makeWrapper({ backgroundAppearance: 'brand' }), + }); + expect((result.current as { backgroundAppearance?: unknown }).backgroundAppearance).toBeUndefined(); + }); +}); diff --git a/packages/react-components/react-toast/library/src/components/ToastBody/useToastBody.ts b/packages/react-components/react-toast/library/src/components/ToastBody/useToastBody.ts index bc53de89a00ff..c347ba2a729a4 100644 --- a/packages/react-components/react-toast/library/src/components/ToastBody/useToastBody.ts +++ b/packages/react-components/react-toast/library/src/components/ToastBody/useToastBody.ts @@ -2,21 +2,20 @@ import type * as React from 'react'; import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; -import type { ToastBodyProps, ToastBodyState } from './ToastBody.types'; +import type { ToastBodyBaseProps, ToastBodyBaseState, ToastBodyProps, ToastBodyState } from './ToastBody.types'; import { useToastContainerContext } from '../../contexts/toastContainerContext'; import { useBackgroundAppearance } from '@fluentui/react-shared-contexts'; /** - * Create the state required to render ToastBody. - * - * The returned state can be modified with hooks such as useToastBodyStyles_unstable, - * before being passed to renderToastBody_unstable. + * Create the base state required to render ToastBody, without design-only props. * * @param props - props from this instance of ToastBody * @param ref - reference to root HTMLElement of ToastBody */ -export const useToastBody_unstable = (props: ToastBodyProps, ref: React.Ref): ToastBodyState => { - const backgroundAppearance = useBackgroundAppearance(); +export const useToastBodyBase_unstable = ( + props: ToastBodyBaseProps, + ref: React.Ref, +): ToastBodyBaseState => { const { bodyId } = useToastContainerContext(); return { components: { @@ -35,6 +34,22 @@ export const useToastBody_unstable = (props: ToastBodyProps, ref: React.Ref): ToastBodyState => { + const backgroundAppearance = useBackgroundAppearance(); + return { + ...useToastBodyBase_unstable(props, ref), backgroundAppearance, }; }; diff --git a/packages/react-components/react-toast/library/src/components/ToastTitle/ToastTitle.types.ts b/packages/react-components/react-toast/library/src/components/ToastTitle/ToastTitle.types.ts index 04f8f030350f0..e05ace8c23ab0 100644 --- a/packages/react-components/react-toast/library/src/components/ToastTitle/ToastTitle.types.ts +++ b/packages/react-components/react-toast/library/src/components/ToastTitle/ToastTitle.types.ts @@ -13,6 +13,11 @@ export type ToastTitleSlots = { */ export type ToastTitleProps = ComponentProps & {}; +/** + * ToastTitle Props without design-only props. + */ +export type ToastTitleBaseProps = ToastTitleProps; + /** * State used in rendering ToastTitle */ @@ -20,3 +25,8 @@ export type ToastTitleState = ComponentState & Pick & { backgroundAppearance: BackgroundAppearanceContextValue; }; + +/** + * State used in rendering ToastTitle, without design-only state. + */ +export type ToastTitleBaseState = Omit; diff --git a/packages/react-components/react-toast/library/src/components/ToastTitle/index.ts b/packages/react-components/react-toast/library/src/components/ToastTitle/index.ts index 6d187294dcfd2..5ed85bfa1be39 100644 --- a/packages/react-components/react-toast/library/src/components/ToastTitle/index.ts +++ b/packages/react-components/react-toast/library/src/components/ToastTitle/index.ts @@ -1,5 +1,11 @@ export { ToastTitle } from './ToastTitle'; -export type { ToastTitleProps, ToastTitleSlots, ToastTitleState } from './ToastTitle.types'; +export type { + ToastTitleBaseProps, + ToastTitleBaseState, + ToastTitleProps, + ToastTitleSlots, + ToastTitleState, +} from './ToastTitle.types'; export { renderToastTitle_unstable } from './renderToastTitle'; -export { useToastTitle_unstable } from './useToastTitle'; +export { useToastTitleBase_unstable, useToastTitle_unstable } from './useToastTitle'; export { toastTitleClassNames, useToastTitleStyles_unstable } from './useToastTitleStyles.styles'; diff --git a/packages/react-components/react-toast/library/src/components/ToastTitle/useToastTitle.test.tsx b/packages/react-components/react-toast/library/src/components/ToastTitle/useToastTitle.test.tsx new file mode 100644 index 0000000000000..932587d124109 --- /dev/null +++ b/packages/react-components/react-toast/library/src/components/ToastTitle/useToastTitle.test.tsx @@ -0,0 +1,197 @@ +import * as React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { CheckmarkCircleFilled, DiamondDismissFilled, InfoFilled, WarningFilled } from '@fluentui/react-icons'; +import { useToastTitle_unstable, useToastTitleBase_unstable } from './useToastTitle'; +import { ToastContainerContextProvider } from '../../contexts/toastContainerContext'; +import { BackgroundAppearanceProvider } from '@fluentui/react-shared-contexts'; +import type { ToastContainerContextValue } from '../../contexts/toastContainerContext'; +import type { BackgroundAppearanceContextValue } from '@fluentui/react-shared-contexts'; +import type { ToastIntent } from '../../state/types'; + +const defaultContextValue: ToastContainerContextValue = { + close: () => null, + intent: undefined, + bodyId: 'test-body-id', + titleId: 'test-title-id', +}; + +function makeWrapper( + options: { + context?: Partial; + backgroundAppearance?: BackgroundAppearanceContextValue; + } = {}, +) { + const contextValue = { ...defaultContextValue, ...options.context }; + return ({ children }: { children: React.ReactNode }) => + React.createElement( + BackgroundAppearanceProvider, + { value: options.backgroundAppearance }, + React.createElement(ToastContainerContextProvider, { value: contextValue }, children), + ); +} + +describe('useToastTitle_unstable', () => { + describe('components and slots', () => { + it('returns components shape { root: div, media: div, action: div }', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastTitle_unstable({}, ref), { wrapper: makeWrapper() }); + // eslint-disable-next-line @typescript-eslint/no-deprecated + expect(result.current.components).toEqual({ root: 'div', media: 'div', action: 'div' }); + }); + + it('always returns a root slot', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastTitle_unstable({}, ref), { wrapper: makeWrapper() }); + expect(result.current.root).toBeDefined(); + }); + + it('returns undefined action when action prop is not provided', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastTitle_unstable({}, ref), { wrapper: makeWrapper() }); + expect(result.current.action).toBeUndefined(); + }); + + it('returns an action slot when action prop is provided', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastTitle_unstable({ action: 'Dismiss' }, ref), { + wrapper: makeWrapper(), + }); + expect(result.current.action).toBeDefined(); + }); + }); + + describe('root slot', () => { + it('applies titleId from context to root.id', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastTitle_unstable({}, ref), { + wrapper: makeWrapper({ context: { titleId: 'my-title-id' } }), + }); + expect(result.current.root.id).toBe('my-title-id'); + }); + + it('spreads extra div props onto the root slot', () => { + const ref = React.createRef(); + const { result } = renderHook( + () => useToastTitle_unstable({ className: 'title-class', 'aria-label': 'title' }, ref), + { wrapper: makeWrapper() }, + ); + expect(result.current.root.className).toBe('title-class'); + expect(result.current.root['aria-label']).toBe('title'); + }); + }); + + describe('backgroundAppearance', () => { + it('is undefined by default', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastTitle_unstable({}, ref), { wrapper: makeWrapper() }); + expect(result.current.backgroundAppearance).toBeUndefined(); + }); + + it('reads "inverted" from BackgroundAppearanceContext', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastTitle_unstable({}, ref), { + wrapper: makeWrapper({ backgroundAppearance: 'inverted' }), + }); + expect(result.current.backgroundAppearance).toBe('inverted'); + }); + + it('reads "brand" from BackgroundAppearanceContext', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastTitle_unstable({}, ref), { + wrapper: makeWrapper({ backgroundAppearance: 'brand' }), + }); + expect(result.current.backgroundAppearance).toBe('brand'); + }); + }); + + describe('intent', () => { + it('reads intent from ToastContainerContext', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastTitle_unstable({}, ref), { + wrapper: makeWrapper({ context: { intent: 'success' } }), + }); + expect(result.current.intent).toBe('success'); + }); + + it('intent is undefined when context does not provide one', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastTitle_unstable({}, ref), { + wrapper: makeWrapper({ context: { intent: undefined } }), + }); + expect(result.current.intent).toBeUndefined(); + }); + }); + + describe('media slot — default icon injection by intent', () => { + it('media is undefined when no intent and no media prop', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastTitle_unstable({}, ref), { + wrapper: makeWrapper({ context: { intent: undefined } }), + }); + expect(result.current.media).toBeUndefined(); + }); + + it.each([ + ['success', CheckmarkCircleFilled], + ['error', DiamondDismissFilled], + ['warning', WarningFilled], + ['info', InfoFilled], + ] as [ToastIntent, React.ElementType][])('injects default icon for intent="%s"', (intent, ExpectedIcon) => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastTitle_unstable({}, ref), { + wrapper: makeWrapper({ context: { intent } }), + }); + + expect(result.current.media).toBeDefined(); + const children = result.current.media?.children as React.ReactElement | undefined; + expect(children).toBeDefined(); + expect((children as React.ReactElement).type).toBe(ExpectedIcon); + }); + + it('renders media slot (without default icon) when intent is set but media has explicit children', () => { + const ref = React.createRef(); + const customIcon = React.createElement('span', { 'data-testid': 'custom-icon' }); + const { result } = renderHook(() => useToastTitle_unstable({ media: { children: customIcon } }, ref), { + wrapper: makeWrapper({ context: { intent: 'success' } }), + }); + + expect(result.current.media).toBeDefined(); + const children = result.current.media?.children as React.ReactElement; + // User's children must take precedence over the default icon + expect(children).toBe(customIcon); + expect(children.type).not.toBe(CheckmarkCircleFilled); + }); + + it('media is defined (renderByDefault) when intent is set even without media prop', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastTitle_unstable({}, ref), { + wrapper: makeWrapper({ context: { intent: 'info' } }), + }); + // renderByDefault: !!intent → media must exist + expect(result.current.media).toBeDefined(); + }); + + it('media children are still the default icon when media prop is provided without children and intent is set', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastTitle_unstable({ media: {} }, ref), { + wrapper: makeWrapper({ context: { intent: 'warning' } }), + }); + + expect(result.current.media).toBeDefined(); + const children = result.current.media?.children as React.ReactElement | undefined; + expect(children).toBeDefined(); + expect((children as React.ReactElement).type).toBe(WarningFilled); + }); + }); +}); + +describe('useToastTitleBase_unstable', () => { + it('does not inject the default media icon (design-only behavior lives in useToastTitle_unstable)', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useToastTitleBase_unstable({}, ref), { + wrapper: makeWrapper({ context: { intent: 'success' } }), + }); + expect(result.current.media).toBeDefined(); + expect(result.current.media?.children).toBeUndefined(); + }); +}); diff --git a/packages/react-components/react-toast/library/src/components/ToastTitle/useToastTitle.tsx b/packages/react-components/react-toast/library/src/components/ToastTitle/useToastTitle.tsx index a0416ad8dc53f..5be1d79ad4ee4 100644 --- a/packages/react-components/react-toast/library/src/components/ToastTitle/useToastTitle.tsx +++ b/packages/react-components/react-toast/library/src/components/ToastTitle/useToastTitle.tsx @@ -6,9 +6,44 @@ import { CheckmarkCircleFilled, DiamondDismissFilled, InfoFilled, WarningFilled import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; import { useBackgroundAppearance } from '@fluentui/react-shared-contexts'; -import type { ToastTitleProps, ToastTitleState } from './ToastTitle.types'; +import type { ToastTitleBaseProps, ToastTitleBaseState, ToastTitleProps, ToastTitleState } from './ToastTitle.types'; import { useToastContainerContext } from '../../contexts/toastContainerContext'; +/** + * Create the base state required to render ToastTitle, without design-only props. + * + * @param props - props from this instance of ToastTitle + * @param ref - reference to root HTMLElement of ToastTitle + */ +export const useToastTitleBase_unstable = ( + props: ToastTitleBaseProps, + ref: React.Ref, +): ToastTitleBaseState => { + const { intent, titleId } = useToastContainerContext(); + + return { + action: slot.optional(props.action, { elementType: 'div' }), + components: { root: 'div', media: 'div', action: 'div' }, + media: slot.optional(props.media, { + renderByDefault: !!intent, + elementType: 'div', + }), + root: slot.always( + getIntrinsicElementProps('div', { + // FIXME: + // `ref` is wrongly assigned to be `HTMLElement` instead of `HTMLDivElement` + // but since it would be a breaking change to fix it, we are casting ref to it's proper type + ref: ref as React.Ref, + children: props.children, + id: titleId, + ...props, + }), + { elementType: 'div' }, + ), + intent, + }; +}; + /** * Create the state required to render ToastTitle. * @@ -19,12 +54,14 @@ import { useToastContainerContext } from '../../contexts/toastContainerContext'; * @param ref - reference to root HTMLElement of ToastTitle */ export const useToastTitle_unstable = (props: ToastTitleProps, ref: React.Ref): ToastTitleState => { - const { intent, titleId } = useToastContainerContext(); + 'use no memo'; + const backgroundAppearance = useBackgroundAppearance(); + const baseState = useToastTitleBase_unstable(props, ref); /** Determine the role and media to render based on the intent */ let defaultIcon; - switch (intent) { + switch (baseState.intent) { case 'success': defaultIcon = ; break; @@ -39,27 +76,13 @@ export const useToastTitle_unstable = (props: ToastTitleProps, ref: React.Ref, - children: props.children, - id: titleId, - ...props, - }), - { elementType: 'div' }, - ), - intent, + ...baseState, backgroundAppearance, }; }; diff --git a/packages/react-components/react-toast/library/src/components/Toaster/index.ts b/packages/react-components/react-toast/library/src/components/Toaster/index.ts index 17a0deb7f8eb8..10c4dc591ec1d 100644 --- a/packages/react-components/react-toast/library/src/components/Toaster/index.ts +++ b/packages/react-components/react-toast/library/src/components/Toaster/index.ts @@ -3,3 +3,4 @@ export type { ToasterProps, ToasterSlots, ToasterSlotsInternal, ToasterState } f export { renderToaster_unstable } from './renderToaster'; export { useToaster_unstable } from './useToaster'; export { toasterClassNames, useToasterStyles_unstable } from './useToasterStyles.styles'; +export { useToastAnnounce } from './useToastAnnounce'; diff --git a/packages/react-components/react-toast/library/src/components/Toaster/useToastAnnounce.ts b/packages/react-components/react-toast/library/src/components/Toaster/useToastAnnounce.ts index 055462d835ddb..d82266e068160 100644 --- a/packages/react-components/react-toast/library/src/components/Toaster/useToastAnnounce.ts +++ b/packages/react-components/react-toast/library/src/components/Toaster/useToastAnnounce.ts @@ -8,7 +8,7 @@ import { isHTMLElement } from '@fluentui/react-utilities'; * Wraps an aria live announcement function. * Aria live announcements can be detrimental once the user is already navigating * multiple toasts. Once the user is focused inside the toaster, the announecments should be disabled. - * @param announce + * @param announce - The aria live announcement function to wrap * @returns A function to announce a toast and a ref to attach to the toaster element */ export function useToastAnnounce(announce: Announce): { diff --git a/packages/react-components/react-toast/library/src/index.ts b/packages/react-components/react-toast/library/src/index.ts index 3e57242a6cefe..1385315b55dcd 100644 --- a/packages/react-components/react-toast/library/src/index.ts +++ b/packages/react-components/react-toast/library/src/index.ts @@ -1,5 +1,24 @@ -export { useToastController } from './state'; -export type { ToastPosition, ToastId, ToastOffset, ToastPoliteness, ToastStatus, ToastIntent } from './state'; +export { useToastController, useToaster } from './state'; +export type { Announce, AnnounceOptions, AriaLivePoliteness, LiveMessage } from './AriaLive'; +export type { + ToastPosition, + ToastId, + ToastOffset, + ToastPoliteness, + ToastStatus, + ToastIntent, + ToasterId, + ToasterOptions, + ToasterShortcuts, + ToastImperativeRef, + Toast as ToastData, + ToastChangeData, + ToastChangeHandler, + ToastOptions, + DispatchToastOptions, + UpdateToastOptions, + UpdateToastEventDetail, +} from './state'; export { ToastTrigger, useToastTrigger_unstable, renderToastTrigger_unstable } from './ToastTrigger'; export type { ToastTriggerChildProps, ToastTriggerProps, ToastTriggerState } from './ToastTrigger'; @@ -9,28 +28,50 @@ export { useToasterStyles_unstable, renderToaster_unstable, toasterClassNames, + useToastAnnounce, } from './Toaster'; export type { ToasterProps, ToasterState, ToasterSlots } from './Toaster'; -export { Toast, useToastStyles_unstable, useToast_unstable, renderToast_unstable, toastClassNames } from './Toast'; -export type { ToastProps, ToastState, ToastSlots } from './Toast'; +export { + Toast, + useToastStyles_unstable, + useToastBase_unstable, + useToast_unstable, + renderToast_unstable, + toastClassNames, +} from './Toast'; +export type { ToastBaseProps, ToastBaseState, ToastProps, ToastState, ToastSlots } from './Toast'; export { ToastTitle, useToastTitleStyles_unstable, + useToastTitleBase_unstable, useToastTitle_unstable, renderToastTitle_unstable, toastTitleClassNames, } from './ToastTitle'; -export type { ToastTitleProps, ToastTitleState, ToastTitleSlots } from './ToastTitle'; +export type { + ToastTitleBaseProps, + ToastTitleBaseState, + ToastTitleProps, + ToastTitleState, + ToastTitleSlots, +} from './ToastTitle'; export { ToastBody, useToastBodyStyles_unstable, + useToastBodyBase_unstable, useToastBody_unstable, renderToastBody_unstable, toastBodyClassNames, } from './ToastBody'; -export type { ToastBodyProps, ToastBodyState, ToastBodySlots } from './ToastBody'; +export type { + ToastBodyBaseProps, + ToastBodyBaseState, + ToastBodyProps, + ToastBodyState, + ToastBodySlots, +} from './ToastBody'; export { ToastFooter, @@ -41,5 +82,12 @@ export { } from './ToastFooter'; export type { ToastFooterProps, ToastFooterState, ToastFooterSlots } from './ToastFooter'; +export type { ToastContainerContextValue } from './contexts/toastContainerContext'; +export { ToastContainerContextProvider, useToastContainerContext } from './contexts/toastContainerContext'; export { toastContainerClassNames } from './ToastContainer'; -export type { ToastContainerState } from './ToastContainer'; +export type { ToastContainerSlots, ToastContainerProps, ToastContainerState } from './ToastContainer'; +export { + useToastContainer_unstable, + renderToastContainer_unstable, + useToastContainerContextValues_unstable, +} from './ToastContainer'; diff --git a/packages/react-components/react-toast/library/src/state/types.ts b/packages/react-components/react-toast/library/src/state/types.ts index a9776248fa963..d05e27ea53726 100644 --- a/packages/react-components/react-toast/library/src/state/types.ts +++ b/packages/react-components/react-toast/library/src/state/types.ts @@ -152,14 +152,12 @@ export type ToastListenerMap = { [EVENTS.pause]: EventListener; }; -type RootSlot = Slot<'div'>; - export interface DispatchToastOptions extends Partial> { - root?: RootSlot; + root?: Slot<'div'>; } export interface UpdateToastOptions extends UpdateToastEventDetail { - root?: RootSlot; + root?: Slot<'div'>; } export type ToastImperativeRef = {