diff --git a/change/@fluentui-react-motions-preview-e1a364d2-4a00-430c-b330-736eaf432fd7.json b/change/@fluentui-react-motions-preview-e1a364d2-4a00-430c-b330-736eaf432fd7.json new file mode 100644 index 00000000000000..fcb1bdb6c025f4 --- /dev/null +++ b/change/@fluentui-react-motions-preview-e1a364d2-4a00-430c-b330-736eaf432fd7.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "chore: export types for component props", + "packageName": "@fluentui/react-motions-preview", + "email": "olfedias@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-toast-2054e639-6e4f-4fcc-a62c-e0b0da7d9e4d.json b/change/@fluentui-react-toast-2054e639-6e4f-4fcc-a62c-e0b0da7d9e4d.json new file mode 100644 index 00000000000000..751961c19c2ab3 --- /dev/null +++ b/change/@fluentui-react-toast-2054e639-6e4f-4fcc-a62c-e0b0da7d9e4d.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "chore: migrate Toast to new motion library", + "packageName": "@fluentui/react-toast", + "email": "olfedias@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-motions-preview/etc/react-motions-preview.api.md b/packages/react-components/react-motions-preview/etc/react-motions-preview.api.md index 03fb3c7155fb7f..4cc2aa9327dd21 100644 --- a/packages/react-components/react-motions-preview/etc/react-motions-preview.api.md +++ b/packages/react-components/react-motions-preview/etc/react-motions-preview.api.md @@ -45,6 +45,12 @@ export const durations: { readonly durationUltraSlow: 500; }; +// @public (undocumented) +export type MotionComponentProps = { + children: React_2.ReactElement; + imperativeRef?: React_2.Ref; +}; + // @public (undocumented) export type MotionImperativeRef = { setPlaybackRate: (rate: number) => void; @@ -72,6 +78,18 @@ export const motionTokens: { durationUltraSlow: 500; }; +// @public (undocumented) +export type PresenceComponentProps = { + appear?: boolean; + children: React_2.ReactElement; + imperativeRef?: React_2.Ref; + onMotionFinish?: (ev: null, data: { + direction: 'enter' | 'exit'; + }) => void; + visible?: boolean; + unmountOnExit?: boolean; +}; + // @public (undocumented) export class PresenceGroup extends React_2.Component { constructor(props: PresenceGroupProps, context: unknown); diff --git a/packages/react-components/react-motions-preview/src/factories/createMotionComponent.ts b/packages/react-components/react-motions-preview/src/factories/createMotionComponent.ts index 942db7a179a1c9..afecc2fdb77fe4 100644 --- a/packages/react-components/react-motions-preview/src/factories/createMotionComponent.ts +++ b/packages/react-components/react-motions-preview/src/factories/createMotionComponent.ts @@ -7,7 +7,7 @@ import { animateAtoms } from '../utils/animateAtoms'; import { getChildElement } from '../utils/getChildElement'; import type { AtomMotion, AtomMotionFn, MotionImperativeRef } from '../types'; -type MotionComponentProps = { +export type MotionComponentProps = { children: React.ReactElement; /** Provides imperative controls for the animation. */ diff --git a/packages/react-components/react-motions-preview/src/index.ts b/packages/react-components/react-motions-preview/src/index.ts index 173ceb51d0d35b..629c5770f8ad18 100644 --- a/packages/react-components/react-motions-preview/src/index.ts +++ b/packages/react-components/react-motions-preview/src/index.ts @@ -1,7 +1,7 @@ export { motionTokens, durations, curves } from './motions/motionTokens'; -export { createMotionComponent } from './factories/createMotionComponent'; -export { createPresenceComponent } from './factories/createPresenceComponent'; +export { createMotionComponent, type MotionComponentProps } from './factories/createMotionComponent'; +export { createPresenceComponent, type PresenceComponentProps } from './factories/createPresenceComponent'; export { PresenceGroup } from './components/PresenceGroup'; diff --git a/packages/react-components/react-toast/package.json b/packages/react-components/react-toast/package.json index 2bb8d87699cee1..bc424f7fd667f8 100644 --- a/packages/react-components/react-toast/package.json +++ b/packages/react-components/react-toast/package.json @@ -35,7 +35,7 @@ "@fluentui/scripts-tasks": "*" }, "dependencies": { - "react-transition-group": "^4.4.1", + "@fluentui/react-motions-preview": "^0.3.2", "@fluentui/keyboard-keys": "^9.0.7", "@fluentui/react-aria": "^9.11.4", "@fluentui/react-icons": "^2.0.239", diff --git a/packages/react-components/react-toast/src/components/ToastContainer/ToastContainer.test.tsx b/packages/react-components/react-toast/src/components/ToastContainer/ToastContainer.test.tsx index 6112cb095c931c..e4aedb273b381e 100644 --- a/packages/react-components/react-toast/src/components/ToastContainer/ToastContainer.test.tsx +++ b/packages/react-components/react-toast/src/components/ToastContainer/ToastContainer.test.tsx @@ -5,6 +5,7 @@ import { isConformant } from '../../testing/isConformant'; import { ToastContainerProps } from './ToastContainer.types'; import { toastContainerClassNames } from './useToastContainerStyles.styles'; import { resetIdsForTests } from '@fluentui/react-utilities'; +import { type PresenceComponentProps } from '@fluentui/react-motions-preview'; const defaultToastContainerProps: ToastContainerProps = { announce: () => null, @@ -32,6 +33,27 @@ const defaultToastContainerProps: ToastContainerProps = { const runningTimerSelector = '[data-timer-status="running"]'; const pausedTimerSelector = '[data-timer-status="paused"]'; +const FAKE_MOTION_DURATION = 500; + +jest.mock('./ToastContainerMotion', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + ToastContainerMotion: (props: PresenceComponentProps) => { + const { children, onMotionFinish, visible } = props; + + React.useEffect(() => { + const timeout = setTimeout(() => { + onMotionFinish?.(null, { direction: visible ? 'enter' : 'exit' }); + }, FAKE_MOTION_DURATION); + + return () => { + clearTimeout(timeout); + }; + }, [onMotionFinish, visible]); + + return <>{children}; + }, +})); + describe('ToastContainer', () => { beforeEach(() => { jest.useRealTimers(); @@ -43,7 +65,11 @@ describe('ToastContainer', () => { displayName: 'ToastContainer', requiredProps: defaultToastContainerProps, isInternal: true, - disabledTests: ['consistent-callback-args'], + disabledTests: [ + 'consistent-callback-args', + // There are conflicts between ToastContainerMotion mock and React + 'make-styles-overrides-win', + ], }); it('renders a default state', () => { @@ -104,31 +130,32 @@ describe('ToastContainer', () => { }); it('should start timer after toast on animationend', () => { + jest.useFakeTimers(); + const toastProps: ToastContainerProps = { ...defaultToastContainerProps, timeout: 1 }; const { container } = render(ToastContainer); const toastElement = container.querySelector(`.${toastContainerClassNames.root}`); expect(toastElement).not.toBeNull(); act(() => { - if (toastElement) { - fireEvent.animationEnd(toastElement); - } + jest.advanceTimersToNextTimer(FAKE_MOTION_DURATION); }); expect(container.querySelector(runningTimerSelector)).not.toBeNull(); }); it('should close toast ontimeout', () => { + jest.useFakeTimers(); + const close = jest.fn(); - const toastProps: ToastContainerProps = { ...defaultToastContainerProps, timeout: 1, close }; + const toastProps: ToastContainerProps = { ...defaultToastContainerProps, timeout: 500, close }; const { container } = render(ToastContainer); const toastElement = container.querySelector(`.${toastContainerClassNames.root}`); expect(toastElement).not.toBeNull(); + act(() => { - if (toastElement) { - fireEvent.animationEnd(toastElement); - } + jest.advanceTimersByTime(FAKE_MOTION_DURATION); }); const timer = container.querySelector(runningTimerSelector); @@ -144,15 +171,16 @@ describe('ToastContainer', () => { }); it('should pause on hover', () => { + jest.useFakeTimers(); + const toastProps: ToastContainerProps = { ...defaultToastContainerProps, timeout: 1, pauseOnHover: true }; const { container } = render(ToastContainer); const toastElement = container.querySelector(`.${toastContainerClassNames.root}`); expect(toastElement).not.toBeNull(); + act(() => { - if (toastElement) { - fireEvent.animationEnd(toastElement); - } + jest.advanceTimersToNextTimer(FAKE_MOTION_DURATION); }); expect(container.querySelector(runningTimerSelector)).not.toBeNull(); @@ -175,15 +203,16 @@ describe('ToastContainer', () => { }); it('should pause on window blur', () => { + jest.useFakeTimers(); + const toastProps: ToastContainerProps = { ...defaultToastContainerProps, timeout: 1, pauseOnWindowBlur: true }; const { container } = render(ToastContainer); const toastElement = container.querySelector(`.${toastContainerClassNames.root}`); expect(toastElement).not.toBeNull(); + act(() => { - if (toastElement) { - fireEvent.animationEnd(toastElement); - } + jest.advanceTimersByTime(FAKE_MOTION_DURATION); }); expect(container.querySelector(runningTimerSelector)).not.toBeNull(); diff --git a/packages/react-components/react-toast/src/components/ToastContainer/ToastContainer.types.ts b/packages/react-components/react-toast/src/components/ToastContainer/ToastContainer.types.ts index 0607ac297eb023..eb6b0f46485b43 100644 --- a/packages/react-components/react-toast/src/components/ToastContainer/ToastContainer.types.ts +++ b/packages/react-components/react-toast/src/components/ToastContainer/ToastContainer.types.ts @@ -31,9 +31,20 @@ export type ToastContainerProps = Omit & Pick & Pick & { + /** + * @deprecated Will be always "0". + */ transitionTimeout: number; timerTimeout: number; running: boolean; + /** + * @deprecated Will be always no-op. + */ onTransitionEntering: () => void; + /** + * @deprecated + */ nodeRef: React.Ref; + + onMotionFinish?: (event: null, data: { direction: 'enter' | 'exit' }) => void; }; diff --git a/packages/react-components/react-toast/src/components/ToastContainer/ToastContainerMotion.tsx b/packages/react-components/react-toast/src/components/ToastContainer/ToastContainerMotion.tsx new file mode 100644 index 00000000000000..6590ef36978f6a --- /dev/null +++ b/packages/react-components/react-toast/src/components/ToastContainer/ToastContainerMotion.tsx @@ -0,0 +1,33 @@ +import { createPresenceComponent } from '@fluentui/react-motions-preview'; + +export const ToastContainerMotion = createPresenceComponent(element => ({ + enter: [ + { + keyframes: [ + { marginTop: 0, minHeight: 0, maxHeight: 0, opacity: 0 }, + { marginTop: '16px', minHeight: 44, maxHeight: `${element.scrollHeight}px`, opacity: 0 }, + ], + duration: 200, + }, + { + keyframes: [{ opacity: 0 }, { opacity: 1 }], + delay: 200, + duration: 400, + }, + ], + + exit: [ + { + keyframes: [ + { marginTop: '16px', minHeight: 44, maxHeight: `${element.scrollHeight}px` }, + { marginTop: 0, minHeight: 0, maxHeight: 0 }, + ], + delay: 400, + duration: 200, + }, + { + keyframes: [{ opacity: 1 }, { opacity: 0 }], + duration: 400, + }, + ], +})); diff --git a/packages/react-components/react-toast/src/components/ToastContainer/__snapshots__/ToastContainer.test.tsx.snap b/packages/react-components/react-toast/src/components/ToastContainer/__snapshots__/ToastContainer.test.tsx.snap index d07d3b13de8df6..744939d837eb4b 100644 --- a/packages/react-components/react-toast/src/components/ToastContainer/__snapshots__/ToastContainer.test.tsx.snap +++ b/packages/react-components/react-toast/src/components/ToastContainer/__snapshots__/ToastContainer.test.tsx.snap @@ -8,7 +8,6 @@ exports[`ToastContainer renders a default state 1`] = ` class="fui-ToastContainer" data-tabster="{\\"groupper\\":{\\"tabbability\\":2},\\"focusable\\":{\\"ignoreKeydown\\":{\\"Tab\\":true,\\"Escape\\":true,\\"Enter\\":true}}}" role="listitem" - style="--fui-toast-height: 0px;" tabindex="0" > Default ToastContainer diff --git a/packages/react-components/react-toast/src/components/ToastContainer/renderToastContainer.tsx b/packages/react-components/react-toast/src/components/ToastContainer/renderToastContainer.tsx index 4a4ca7a8185b8d..025e41113ae039 100644 --- a/packages/react-components/react-toast/src/components/ToastContainer/renderToastContainer.tsx +++ b/packages/react-components/react-toast/src/components/ToastContainer/renderToastContainer.tsx @@ -1,9 +1,9 @@ /** @jsxRuntime automatic */ /** @jsxImportSource @fluentui/react-jsx-runtime */ import { assertSlots } from '@fluentui/react-utilities'; -import { Transition } from 'react-transition-group'; import type { ToastContainerState, ToastContainerSlots, ToastContainerContextValues } from './ToastContainer.types'; import { ToastContainerContextProvider } from '../../contexts/toastContainerContext'; +import { ToastContainerMotion } from './ToastContainerMotion'; /** * Render the final JSX of ToastContainer @@ -12,23 +12,17 @@ export const renderToastContainer_unstable = ( state: ToastContainerState, contextValues: ToastContainerContextValues, ) => { - const { onTransitionEntering, visible, transitionTimeout, remove, nodeRef, updateId } = state; + const { onMotionFinish, visible, updateId } = state; assertSlots(state); return ( - - - - - - + + + + {state.root.children} + + + + ); }; diff --git a/packages/react-components/react-toast/src/components/ToastContainer/useToastContainer.ts b/packages/react-components/react-toast/src/components/ToastContainer/useToastContainer.ts index 90468221fbafb6..3de56e5f4c1c62 100644 --- a/packages/react-components/react-toast/src/components/ToastContainer/useToastContainer.ts +++ b/packages/react-components/react-toast/src/components/ToastContainer/useToastContainer.ts @@ -129,37 +129,21 @@ export const useToastContainer_unstable = ( } }, [targetDocument, pause, play, pauseOnWindowBlur]); - // It's impossible to animate to height: auto in CSS, the actual pixel value must be known - // Get the height of the toast before animation styles have been applied and set a CSS - // variable with its height. The CSS variable will be used by the styles - const onTransitionEntering = () => { - if (!toastRef.current) { - return; - } - - const element = toastRef.current; - element.style.setProperty('--fui-toast-height', `${element.scrollHeight}px`); - }; - // Users never actually use ToastContainer as a JSX but imperatively through useToastContainerController const userRootSlot = (data as { root?: ExtractSlotProps> }).root; + const onMotionFinish: ToastContainerState['onMotionFinish'] = React.useCallback( + (_, { direction }) => { + if (direction === 'exit') { + remove(); + } - // Using a ref callback here because addEventListener supports `once` - const toastAnimationRef = React.useCallback( - (el: HTMLDivElement | null) => { - if (el && toastRef.current) { - toastRef.current.addEventListener( - 'animationend', - () => { - // start toast once it's fully animated in - play(); - onStatusChange('visible'); - }, - { once: true }, - ); + if (direction === 'enter') { + // start toast once it's fully animated in + play(); + onStatusChange('visible'); } }, - [play, onStatusChange], + [onStatusChange, play, remove], ); const onMouseEnter = useEventCallback((e: React.MouseEvent) => { @@ -220,7 +204,7 @@ export const useToastContainer_unstable = ( // 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: useMergedRefs(ref, toastRef, toastAnimationRef) as React.Ref, + ref: useMergedRefs(ref, toastRef) as React.Ref, children, tabIndex: 0, role: 'listitem', @@ -236,16 +220,19 @@ export const useToastContainer_unstable = ( { elementType: 'div' }, ), timerTimeout, - transitionTimeout: 500, + transitionTimeout: 0, running, visible, remove, close, - onTransitionEntering, + onTransitionEntering: () => { + /* no-op */ + }, updateId, nodeRef: toastRef, intent, titleId, bodyId, + onMotionFinish, }; }; diff --git a/packages/react-components/react-toast/src/components/ToastContainer/useToastContainerStyles.styles.ts b/packages/react-components/react-toast/src/components/ToastContainer/useToastContainerStyles.styles.ts index 14f292cab3ee2f..92333fc181446e 100644 --- a/packages/react-components/react-toast/src/components/ToastContainer/useToastContainerStyles.styles.ts +++ b/packages/react-components/react-toast/src/components/ToastContainer/useToastContainerStyles.styles.ts @@ -1,4 +1,4 @@ -import { makeResetStyles, makeStyles, mergeClasses } from '@griffel/react'; +import { makeResetStyles, mergeClasses } from '@griffel/react'; import { tokens } from '@fluentui/react-theme'; import type { SlotClassNames } from '@fluentui/react-utilities'; import { createCustomFocusIndicatorStyle } from '@fluentui/react-tabster'; @@ -12,84 +12,19 @@ export const toastContainerClassNames: SlotClassNames = { const useRootBaseClassName = makeResetStyles({ boxSizing: 'border-box', marginTop: '16px', - minHeight: '44px', pointerEvents: 'all', borderRadius: tokens.borderRadiusMedium, - '--fui-toast-height': '44px', ...createCustomFocusIndicatorStyle({ outline: `${tokens.strokeWidthThick} solid ${tokens.colorStrokeFocus2}`, }), }); -/** - * Styles for the root slot - */ -const useStyles = makeStyles({ - enter: { - animationDuration: '200ms, 400ms', - animationDelay: '0ms, 200ms', - animationName: [ - { - from: { - maxHeight: 0, - opacity: 0, - marginTop: 0, - }, - to: { - marginTop: '16px', - opacity: 0, - maxHeight: 'var(--fui-toast-height)', - }, - }, - { - from: { - opacity: 0, - }, - to: { - opacity: 1, - }, - }, - ], - }, - - exit: { - animationDuration: '400ms, 200ms', - animationDelay: '0ms, 400ms', - animationName: [ - { - from: { - opacity: 1, - }, - to: { - opacity: 0, - }, - }, - { - from: { - opacity: 0, - }, - to: { - opacity: 0, - marginTop: 0, - maxHeight: 0, - }, - }, - ], - }, -}); - /** * Apply styling to the ToastContainer slots based on the state */ export const useToastContainerStyles_unstable = (state: ToastContainerState): ToastContainerState => { const rootBaseClassName = useRootBaseClassName(); - const styles = useStyles(); - state.root.className = mergeClasses( - toastContainerClassNames.root, - rootBaseClassName, - state.visible ? styles.enter : styles.exit, - state.root.className, - ); + state.root.className = mergeClasses(toastContainerClassNames.root, rootBaseClassName, state.root.className); return state; };