diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f8da6d7bca69e2..4c8dcfb79a76b4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -232,13 +232,14 @@ packages/react-components/react-migration-v0-v9 @microsoft/teams-prg packages/react-components/react-datepicker-compat @microsoft/cxe-red @sopranopillow @khmakoto packages/react-components/react-migration-v8-v9 @microsoft/cxe-red @microsoft/cxe-coastal @geoffcoxmsft packages/react-components/react-breadcrumb-preview @microsoft/cxe-prg -packages/react-components/react-drawer @microsoft/cxe-prg +packages/react-components/react-drawer @microsoft/cxe-prg @marcosmoura packages/react-components/react-storybook-addon-codesandbox @microsoft/fluentui-react-build packages/react-components/babel-preset-storybook-full-source @microsoft/fluentui-react-build packages/react-components/react-jsx-runtime @microsoft/teams-prg packages/react-components/react-toast @microsoft/teams-prg packages/react-components/react-search-preview @microsoft/cxe-coastal packages/react-components/react-colorpicker-compat @microsoft/cxe-red @sopranopillow +packages/react-components/react-motion-preview @microsoft/cxe-prg @marcosmoura packages/react-components/react-nav-preview @microsoft/cxe-red @mltejera packages/react-components/react-motion-preview @microsoft/cxe-prg @marcosmoura # <%= NX-CODEOWNER-PLACEHOLDER %> diff --git a/apps/public-docsite-v9/package.json b/apps/public-docsite-v9/package.json index a14427594c743c..e549ed058a3edc 100644 --- a/apps/public-docsite-v9/package.json +++ b/apps/public-docsite-v9/package.json @@ -36,6 +36,7 @@ "@fluentui/react-storybook-addon-codesandbox": "*", "@fluentui/theme-designer": "*", "@fluentui/react-search-preview": "*", + "@fluentui/react-motion-preview": "*", "@griffel/react": "^1.5.14", "react": "17.0.2", "react-dom": "17.0.2", diff --git a/apps/public-docsite-v9/src/Utilities/Motion/useMotion/MotionExample.stories.tsx b/apps/public-docsite-v9/src/Utilities/Motion/useMotion/MotionExample.stories.tsx new file mode 100644 index 00000000000000..34c07534817bd9 --- /dev/null +++ b/apps/public-docsite-v9/src/Utilities/Motion/useMotion/MotionExample.stories.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; + +import { useMotion } from '@fluentui/react-motion-preview'; +import { Button, makeStyles, mergeClasses, shorthands, tokens } from '@fluentui/react-components'; + +const useStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + rowGap: '24px', + }, + + rectangle: { + ...shorthands.borderRadius('8px'), + + width: '200px', + height: '150px', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: tokens.colorBrandBackground2, + opacity: 0, + transform: 'translate3D(0, 0, 0) scale(0.25)', + transitionDuration: `${tokens.durationNormal}, ${tokens.durationNormal}, ${tokens.durationUltraSlow}`, + transitionDelay: `${tokens.durationFast}, 0, ${tokens.durationSlow}`, + transitionProperty: 'opacity, transform, background-color', + willChange: 'opacity, transform, background-color', + color: '#fff', + }, + + visible: { + opacity: 1, + transform: 'translate3D(0, 0, 0) scale(1)', + backgroundColor: tokens.colorBrandBackground, + }, +}); + +export const MotionExample = () => { + const styles = useStyles(); + + const [open, setOpen] = React.useState(false); + const motion = useMotion(open); + + return ( +
+ + + {motion.canRender() && ( +
+ Lorem ipsum +
+ )} +
+ ); +}; diff --git a/apps/public-docsite-v9/src/Utilities/Motion/useMotion/index.stories.mdx b/apps/public-docsite-v9/src/Utilities/Motion/useMotion/index.stories.mdx new file mode 100644 index 00000000000000..6d2544660a211b --- /dev/null +++ b/apps/public-docsite-v9/src/Utilities/Motion/useMotion/index.stories.mdx @@ -0,0 +1,83 @@ +import { Title, Subtitle, Meta, Description } from '@storybook/addon-docs'; + +import { MotionExample } from './MotionExample.stories'; + +useMotion + + + + + A tracker hook, that monitors the state of animations and transitions for a particular element. This hook does not + directly create animations but instead synchronizes with CSS properties to determine the rendering status, visibility, + entering, leaving, and ongoing animation of a component. If any CSS changes or properties are overridden, this hook + will automatically adjust and stay synchronized. + + +Usage + +```tsx +import * as React from 'react'; + +import { useMotion } from '@fluentui/react-motion-preview'; +import { Button, makeStyles, mergeClasses, shorthands, tokens } from '@fluentui/react-components'; + +const useStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + rowGap: '24px', + }, + + rectangle: { + ...shorthands.borderRadius('8px'), + + width: '200px', + height: '150px', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: tokens.colorBrandBackground2, + opacity: 0, + transform: 'translate3D(0, 0, 0) scale(0.25)', + transitionDuration: `${tokens.durationNormal}, ${tokens.durationNormal}, ${tokens.durationUltraSlow}`, + transitionDelay: `${tokens.durationFast}, 0, ${tokens.durationSlow}`, + transitionProperty: 'opacity, transform, background-color', + willChange: 'opacity, transform, background-color', + color: '#fff', + }, + + visible: { + opacity: 1, + transform: 'translate3D(0, 0, 0) scale(1)', + backgroundColor: tokens.colorBrandBackground, + }, +}); + +export const MotionExample = () => { + const styles = useStyles(); + + const [open, setOpen] = React.useState(false); + const motion = useMotion(open); + + return ( +
+ + + {motion.canRender() && ( +
+ Lorem ipsum +
+ )} +
+ ); +}; +``` + +Example + + diff --git a/change/@fluentui-react-motion-preview-a0e15534-5b7d-4fb8-a6dd-f28e388add2a.json b/change/@fluentui-react-motion-preview-a0e15534-5b7d-4fb8-a6dd-f28e388add2a.json new file mode 100644 index 00000000000000..3ee9028024c3d8 --- /dev/null +++ b/change/@fluentui-react-motion-preview-a0e15534-5b7d-4fb8-a6dd-f28e388add2a.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: create react-motion-preview package with useMotion hook", + "packageName": "@fluentui/react-motion-preview", + "email": "marcosvmmoura@gmail.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-utilities-c9616c2d-de84-45eb-bef4-f7509f49cded.json b/change/@fluentui-react-utilities-c9616c2d-de84-45eb-bef4-f7509f49cded.json new file mode 100644 index 00000000000000..7a3c251867b099 --- /dev/null +++ b/change/@fluentui-react-utilities-c9616c2d-de84-45eb-bef4-f7509f49cded.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: create useAnimationFrame hook", + "packageName": "@fluentui/react-utilities", + "email": "marcosvmmoura@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-motion-preview/etc/react-motion-preview.api.md b/packages/react-components/react-motion-preview/etc/react-motion-preview.api.md index 5f1c6b00b34510..c56021c458ed19 100644 --- a/packages/react-components/react-motion-preview/etc/react-motion-preview.api.md +++ b/packages/react-components/react-motion-preview/etc/react-motion-preview.api.md @@ -4,6 +4,36 @@ ```ts +import * as React_2 from 'react'; + +// @public +export function getDefaultMotionState(): MotionState; + +// @public (undocumented) +export type MotionShorthand = MotionShorthandValue | MotionState; + +// @public (undocumented) +export type MotionShorthandValue = boolean; + +// @public (undocumented) +export type MotionState = { + ref: React_2.Ref; + type: MotionType; + active: boolean; + canRender: boolean; +}; + +// @public (undocumented) +export type MotionType = 'unmounted' | 'entering' | 'entered' | 'idle' | 'exiting' | 'exited'; + +// @public +export function useMotion(shorthand: MotionShorthand, options?: UseMotionOptions): MotionState; + +// @public (undocumented) +export type UseMotionOptions = { + animateOnFirstMount?: boolean; +}; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/react-components/react-motion-preview/package.json b/packages/react-components/react-motion-preview/package.json index a8cee6eb58d21f..a60bd40c1e8adc 100644 --- a/packages/react-components/react-motion-preview/package.json +++ b/packages/react-components/react-motion-preview/package.json @@ -1,7 +1,6 @@ { "name": "@fluentui/react-motion-preview", "version": "0.0.0", - "private": true, "description": "New fluentui react package", "main": "lib-commonjs/index.js", "module": "lib/index.js", diff --git a/packages/react-components/react-motion-preview/src/hooks/index.ts b/packages/react-components/react-motion-preview/src/hooks/index.ts new file mode 100644 index 00000000000000..4fb9f2d6d5f179 --- /dev/null +++ b/packages/react-components/react-motion-preview/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './useMotion'; diff --git a/packages/react-components/react-motion-preview/src/hooks/useMotion.test.ts b/packages/react-components/react-motion-preview/src/hooks/useMotion.test.ts new file mode 100644 index 00000000000000..0056bd8fc2fac4 --- /dev/null +++ b/packages/react-components/react-motion-preview/src/hooks/useMotion.test.ts @@ -0,0 +1,260 @@ +import { act, renderHook } from '@testing-library/react-hooks'; + +import { useMotion, UseMotionOptions, MotionShorthand, getDefaultMotionState, useIsMotion } from './useMotion'; + +const defaultDuration = 100; +const renderHookWithRef = ( + initialMotion: MotionShorthand, + initialOptions?: UseMotionOptions, + style: Record = { 'transition-duration': `${defaultDuration}ms` }, +) => { + const refEl = document.createElement('div'); + const hook = renderHook(({ motion, options }) => useMotion(motion, options), { + initialProps: { + motion: initialMotion, + options: initialOptions, + } as { motion: MotionShorthand; options?: UseMotionOptions }, + }); + + Object.entries(style).forEach(([key, value]) => value && refEl.style.setProperty(key, value)); + + act(() => { + if (typeof hook.result.current.ref === 'function') { + hook.result.current.ref(refEl); + } + }); + + return hook; +}; + +const jumpToNextFrame = () => { + act(() => { + // requestAnimationFrame + timeout callbacks + jest.advanceTimersToNextTimer(); + jest.advanceTimersToNextTimer(); + }); +}; + +describe('useMotion', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.resetAllMocks(); + }); + + describe('when presence is false by default', () => { + it('should return default values when presence is false', () => { + const { result } = renderHookWithRef(false); + + expect(typeof result.current.ref).toBe('function'); + expect(result.current.type).toBe('unmounted'); + expect(result.current.active).toBe(false); + }); + }); + + describe('when presence is true by default', () => { + it('should return default values', () => { + const { result } = renderHookWithRef(true); + + expect(typeof result.current.ref).toBe('function'); + expect(result.current.active).toBe(true); + }); + + it('should change visible to true when animateOnFirstMount is true', () => { + const { result } = renderHookWithRef(true, { animateOnFirstMount: true }); + + expect(typeof result.current.ref).toBe('function'); + expect(result.current.active).toBe(false); + + jumpToNextFrame(); + + expect(result.current.active).toBe(true); + }); + }); + + describe('when presence changes', () => { + it('should toggle values starting with false', () => { + const { result, rerender } = renderHookWithRef(false); + + expect(typeof result.current.ref).toBe('function'); + expect(result.current.type).toBe('unmounted'); + expect(result.current.active).toBe(false); + + rerender({ motion: true }); + + act(() => jest.advanceTimersToNextTimer()); + + expect(result.current.type).toBe('entering'); + expect(result.current.active).toBe(true); + + act(() => jest.advanceTimersByTime(defaultDuration + 1)); + expect(result.current.type).toBe('entered'); + + act(() => jest.advanceTimersToNextTimer()); + + expect(result.current.type).toBe('idle'); + + rerender({ motion: false }); + + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.type).toBe('exiting'); + expect(result.current.active).toBe(false); + + act(() => jest.advanceTimersByTime(defaultDuration + 1)); + expect(result.current.type).toBe('exited'); + + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.type).toBe('unmounted'); + expect(result.current.active).toBe(false); + }); + + it('should toggle values starting with true', () => { + const { result, rerender } = renderHookWithRef(true); + + expect(typeof result.current.ref).toBe('function'); + expect(result.current.active).toBe(true); + + rerender({ motion: false }); + + act(() => jest.advanceTimersToNextTimer()); + + expect(result.current.type).toBe('exiting'); + expect(result.current.active).toBe(false); + + act(() => jest.advanceTimersByTime(defaultDuration + 1)); + expect(result.current.type).toBe('exited'); + + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.type).toBe('unmounted'); + expect(result.current.active).toBe(false); + }); + }); + + describe.each([ + { message: 'with transition', styles: { 'transition-duration': '100ms' } }, + { message: 'with long transition', styles: { 'transition-duration': '1000ms' } }, + { message: 'with animation', styles: { 'animation-duration': '100ms' } }, + { message: 'with long animation', styles: { 'animation-duration': '1000ms' } }, + ])('when presence changes - $message', ({ styles }) => { + it('should toggle values starting with false', () => { + const { result, rerender } = renderHookWithRef(false, {}, styles); + + expect(typeof result.current.ref).toBe('function'); + expect(result.current.type).toBe('unmounted'); + expect(result.current.active).toBe(false); + + rerender({ motion: true }); + + act(() => jest.advanceTimersToNextTimer()); + + expect(result.current.type).toBe('entering'); + expect(result.current.active).toBe(true); + + act(() => jest.advanceTimersToNextTimer()); + + expect(result.current.type).toBe('entered'); + + act(() => jest.advanceTimersToNextTimer()); + + expect(result.current.type).toBe('idle'); + + rerender({ motion: false }); + + act(() => jest.advanceTimersToNextTimer()); + + expect(result.current.type).toBe('exiting'); + expect(result.current.active).toBe(false); + + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.type).toBe('exited'); + + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.type).toBe('unmounted'); + expect(result.current.active).toBe(false); + }); + }); + + describe.each([ + { message: 'with no transition', styles: { 'transition-duration': '0' } }, + { message: 'with no animation', styles: { 'animation-duration': '0' } }, + ])('when presence changes - $message', ({ styles }) => { + it('should toggle values when transition-duration is 0', () => { + const { result, rerender } = renderHookWithRef(false, {}, styles); + + expect(typeof result.current.ref).toBe('function'); + expect(result.current.type).toBe('unmounted'); + expect(result.current.active).toBe(false); + + rerender({ motion: true }); + + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.type).toBe('entered'); + + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.type).toBe('idle'); + + // requestAnimationFrame + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.active).toBe(true); + // timeout + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.type).toBe('idle'); + + rerender({ motion: false }); + + act(() => jest.advanceTimersToNextTimer()); + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.active).toBe(false); + + // requestAnimationFrame + act(() => jest.advanceTimersToNextTimer()); + // timeout + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.type).toBe('unmounted'); + }); + }); + + describe('when motion is received', () => { + it('should return default values when presence is false', () => { + const defaultState = getDefaultMotionState(); + const { result } = renderHookWithRef(getDefaultMotionState()); + + expect(result.current.type).toStrictEqual('unmounted'); + expect(result.current.ref).toStrictEqual(defaultState.ref); + expect(result.current.active).toStrictEqual(false); + }); + + it('should return default values when presence is true', () => { + const defaultState = getDefaultMotionState(); + const { result } = renderHookWithRef({ ...getDefaultMotionState(), active: true }); + + expect(result.current.ref).toStrictEqual(defaultState.ref); + expect(result.current.active).toStrictEqual(true); + }); + }); + + it('should show error when motion changes to a different type', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => ({})); + let defaultMotion: MotionShorthand = getDefaultMotionState(); + const { rerender } = renderHook(() => useIsMotion(defaultMotion)); + + defaultMotion = false; + + rerender(); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + [ + 'useMotion: The hook needs to be called with the same typeof of shorthand on every render.', + 'This is to ensure the internal state of the hook is stable and can be used to accurately detect the motion state.', + 'Please make sure to not change the shorthand on subsequent renders or to use the hook conditionally.', + '\nCurrent shorthand:', + JSON.stringify(defaultMotion, null, 2), + '\nPrevious shorthand:', + JSON.stringify(getDefaultMotionState(), null, 2), + ].join(' '), + ); + }); +}); diff --git a/packages/react-components/react-motion-preview/src/hooks/useMotion.ts b/packages/react-components/react-motion-preview/src/hooks/useMotion.ts new file mode 100644 index 00000000000000..7f5dc0c421ccd2 --- /dev/null +++ b/packages/react-components/react-motion-preview/src/hooks/useMotion.ts @@ -0,0 +1,260 @@ +import * as React from 'react'; +import { unstable_batchedUpdates } from 'react-dom'; +import { HTMLElementWithStyledMap, getMotionDuration } from '../utils/dom-style'; +import { useAnimationFrame, useTimeout, usePrevious } from '@fluentui/react-utilities'; + +export type UseMotionOptions = { + /** + * Whether to animate the element on first mount. + * + * @default false + */ + animateOnFirstMount?: boolean; +}; + +export type MotionType = 'unmounted' | 'entering' | 'entered' | 'idle' | 'exiting' | 'exited'; + +export type MotionState = { + /** + * Ref to the element. + */ + ref: React.Ref; + + /** + * Current state of the element. + * + * - `unmounted` - The element is not yet rendered or can be safely removed from the DOM. + * - `entering` - The element is performing enter animation. + * - `entered` - The element has finished enter animation. + * - `idle` - The element is currently not animating, but rendered on screen. + * - `exiting` - The element is performing exit animation. + * - `exited` - The element has finished exit animation. + */ + type: MotionType; + + /** + * Indicates whether the component is currently rendered and visible. + * Useful to apply CSS transitions only when the element is active. + */ + active: boolean; + + /** + * Indicates whether the component can be rendered. + * This can be used to avoid rendering the component when it is not visible anymore. + */ + canRender: boolean; +}; + +export type MotionShorthandValue = boolean; + +export type MotionShorthand = MotionShorthandValue | MotionState; + +/** + * @internal + * + * Hook to manage the presence of an element in the DOM based on its CSS transition/animation state. + * + * @param present - Whether the element should be present in the DOM + * @param events - Callbacks for when the element enters or exits the DOM + */ +function useMotionPresence( + presence: boolean, + options: UseMotionOptions = {}, +): MotionState { + const { animateOnFirstMount } = { animateOnFirstMount: false, ...options }; + + const [type, setType] = React.useState(presence ? 'idle' : 'unmounted'); + const [active, setActive] = React.useState(false); + + const [currentElement, setCurrentElement] = React.useState | null>(null); + const [setAnimationTimeout, clearAnimationTimeout] = useTimeout(); + const [setActiveAnimationFrame, cancelActiveAnimationFrame] = useAnimationFrame(); + const [setProcessingAnimationFrame, cancelProcessingAnimationFrame] = useAnimationFrame(); + const [setDelayedAnimationFrame, cancelDelayedAnimationFrame] = useAnimationFrame(); + const skipAnimationOnFirstRender = React.useRef(!animateOnFirstMount); + + const processAnimation = React.useCallback( + (callback: () => void) => { + const targetElement = currentElement; + + if (!targetElement) { + return; + } + + clearAnimationTimeout(); + cancelProcessingAnimationFrame(); + setProcessingAnimationFrame(() => { + const duration = getMotionDuration(targetElement); + + if (duration === 0) { + callback(); + return; + } + + /** + * Use CSS transition duration + 1ms to ensure the animation has finished on both enter and exit states. + * This is an alternative to using the `transitionend` event which can be unreliable as it fires multiple times + * if the transition has multiple properties. + */ + setAnimationTimeout(() => callback(), duration + 1); + }); + + return () => { + clearAnimationTimeout(); + cancelProcessingAnimationFrame(); + }; + }, + [ + cancelProcessingAnimationFrame, + clearAnimationTimeout, + currentElement, + setAnimationTimeout, + setProcessingAnimationFrame, + ], + ); + + const ref: React.RefCallback> = React.useCallback(node => { + if (!node) { + return; + } + + setCurrentElement(node); + }, []); + + React.useEffect(() => { + if (presence) { + unstable_batchedUpdates(() => { + setType('entering'); + setActive(skipAnimationOnFirstRender.current ? true : false); + }); + } + }, [presence]); + + React.useEffect(() => { + const skipAnimation = skipAnimationOnFirstRender.current; + const onUnmount = () => { + cancelActiveAnimationFrame(); + cancelDelayedAnimationFrame(); + }; + + setActiveAnimationFrame(() => { + unstable_batchedUpdates(() => { + setActive(presence); + setType(() => { + if (skipAnimation) { + return presence ? 'idle' : 'unmounted'; + } + + return presence ? 'entering' : 'exiting'; + }); + }); + }); + + if (skipAnimation) { + return onUnmount; + } + + processAnimation(() => { + setType(presence ? 'entered' : 'exited'); + setDelayedAnimationFrame(() => setType(presence ? 'idle' : 'unmounted')); + }); + + return onUnmount; + }, [ + cancelActiveAnimationFrame, + cancelDelayedAnimationFrame, + presence, + processAnimation, + setActiveAnimationFrame, + setDelayedAnimationFrame, + ]); + + React.useEffect(() => { + skipAnimationOnFirstRender.current = false; + }, []); + + return React.useMemo>( + () => ({ + ref, + type, + canRender: type !== 'unmounted', + active, + }), + [active, ref, type], + ); +} + +/** + * Returns a default motion state. + */ +export function getDefaultMotionState(): MotionState { + return { + ref: React.createRef(), + type: 'unmounted', + active: false, + canRender: false, + }; +} + +/** + * Hook to manage the presence of an element in the DOM based on its CSS transition/animation state. + * + * @param props - Motion props to manage the presence of an element in the DOM + * @param options - Motion options to configure the hook + */ +export function useMotion( + shorthand: MotionShorthand, + options?: UseMotionOptions, +): MotionState { + /** + * Heads up! + * This hook returns a Motion but also accepts Motion as an argument. + * In case the hook is called with a Motion as argument, we don't need to perform the expensive computation of the + * motion state and can just return the motion value as is. This is intentional as it allows others to use the hook + * on their side without having to worry about the performance impact of the hook. + */ + // eslint-disable-next-line react-hooks/rules-of-hooks + return useIsMotion(shorthand) ? shorthand : useMotionPresence(shorthand, options); +} + +/** + * @internal + * + * This method emits a warning if the hook is called with + * a different typeof of shorthand on subsequent renders, + * since this can lead breaking the rules of hooks. + * + * It also return a boolean indicating whether the shorthand is a motion object. + */ +export function useIsMotion( + shorthand: MotionShorthand, +): shorthand is MotionState { + const previousShorthand = usePrevious(shorthand); + + /** + * Heads up! + * We don't want these warnings in production even though it is against native behavior + */ + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line react-hooks/rules-of-hooks + React.useEffect(() => { + if (previousShorthand !== null && typeof previousShorthand !== typeof shorthand) { + const stringifyShorthand = (value: MotionShorthand) => JSON.stringify(value, null, 2); + + // eslint-disable-next-line no-console + console.error( + [ + 'useMotion: The hook needs to be called with the same typeof of shorthand on every render.', + 'This is to ensure the internal state of the hook is stable and can be used to accurately detect the motion state.', + 'Please make sure to not change the shorthand on subsequent renders or to use the hook conditionally.', + '\nCurrent shorthand:', + stringifyShorthand(shorthand), + '\nPrevious shorthand:', + stringifyShorthand(previousShorthand), + ].join(' '), + ); + } + }, [shorthand, previousShorthand]); + } + return typeof shorthand === 'object'; +} diff --git a/packages/react-components/react-motion-preview/src/index.ts b/packages/react-components/react-motion-preview/src/index.ts index cb0ff5c3b541f6..b70d2c237cae24 100644 --- a/packages/react-components/react-motion-preview/src/index.ts +++ b/packages/react-components/react-motion-preview/src/index.ts @@ -1 +1,2 @@ -export {}; +export { getDefaultMotionState, useMotion } from './hooks'; +export type { MotionShorthand, MotionShorthandValue, MotionState, MotionType, UseMotionOptions } from './hooks'; diff --git a/packages/react-components/react-motion-preview/src/utils/dom-style.ts b/packages/react-components/react-motion-preview/src/utils/dom-style.ts new file mode 100644 index 00000000000000..faa0aef71fa582 --- /dev/null +++ b/packages/react-components/react-motion-preview/src/utils/dom-style.ts @@ -0,0 +1,193 @@ +import { canUseDOM } from '@fluentui/react-utilities'; + +/** + * CSS Typed Object Model + * @see https://drafts.css-houdini.org/css-typed-om-1/ + * @see https://developer.mozilla.org/en-US/docs/Web/API/CSSUnitValue + */ +export interface CSSUnitValue { + value: number; + readonly unit: string; +} + +/** + * Style property map read only. + * @see https://developer.mozilla.org/en-US/docs/Web/API/StylePropertyMapReadOnly + */ +export interface StylePropertyMapReadOnly { + [Symbol.iterator](): IterableIterator<[string, CSSUnitValue[]]>; + + get(property: string): CSSUnitValue | undefined; + getAll(property: string): CSSUnitValue[]; + has(property: string): boolean; + readonly size: number; +} + +/** + * HTMLElement with styled map. + * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/computedStyleMap + */ +export type HTMLElementWithStyledMap = T & { + computedStyleMap(): StylePropertyMapReadOnly; +}; + +/** + * CSS with number parsing. + * @see https://drafts.css-houdini.org/css-typed-om-1/#css + * @see https://developer.mozilla.org/en-US/docs/Web/API/CSS/number + */ +export type CSSWithNumber = typeof CSS & { + number(value: number): { + value: number; + readonly unit: string; + }; +}; + +/** + * + * Gets the computed style of a given element. + * If the browser supports CSSOM, it will return a ComputedStyleMap object. + * Otherwise, it will return a CSSStyleDeclaration object. + */ +export const getCSSStyle = (node: HTMLElementWithStyledMap): CSSStyleDeclaration | StylePropertyMapReadOnly => { + if (hasCSSOMSupport(node)) { + return node.computedStyleMap() as unknown as StylePropertyMapReadOnly; + } + + return getElementComputedStyle(node); +}; + +/** + * Checks if the browser supports CSSOM. + * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/computedStyleMap + * + * @param node - DOM node + * @returns Whether the browser supports CSSOM + */ +export const hasCSSOMSupport = (node: HTMLElementWithStyledMap) => { + /** + * As we are using the experimental CSSOM API, we need to check if the browser supports it. + * The typecast here is to allow the use of the `number` function that is not yet part of the CSSOM typings. + * @see https://www.npmjs.com/package/@types/w3c-css-typed-object-model-level-1 + */ + return Boolean(typeof CSS !== 'undefined' && (CSS as CSSWithNumber).number && node.computedStyleMap); +}; + +/** + * @internal + * + * Returns CSS styles of the given node. + * @param node - DOM node. + * @returns - CSS styles. + */ +export const getElementComputedStyle = (node: HTMLElement): CSSStyleDeclaration => { + const win = canUseDOM() && (node.ownerDocument?.defaultView ?? window); + + if (!win) { + return { + getPropertyValue: (_: string) => '', + } as CSSStyleDeclaration; + } + + return win.getComputedStyle(node, null); +}; + +/** + * Converts a CSS duration string to milliseconds. + * + * @param duration - CSS duration string + * @returns Duration in milliseconds + */ +export function toMs(duration: string): number { + const trimmed = duration.trim(); + + if (trimmed.includes('auto')) { + return 0; + } + + if (trimmed.endsWith('ms')) { + const parsed = Number(trimmed.replace('ms', '')); + + return isNaN(parsed) ? 0 : parsed; + } + + return Number(trimmed.slice(0, -1).replace(',', '.')) * 1000; +} + +/** + * Gets the computed map property for a given element using the CSSOM API. + * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/computedStyleMap + * + * @param computedStyle - Computed style of the element + * @param prop - CSS property + * @returns Computed map property + */ +export const getComputedMapProp = (computedStyle: StylePropertyMapReadOnly, prop: string): string[] => { + const props = computedStyle.getAll(prop); + + if (props.length > 0) { + return props.map(({ value, unit }) => `${value}${unit}`); + } + + return ['0']; +}; + +/** + * Gets the computed style property for a given element using the getComputedStyle API. + * + * @param computedStyle - Computed style of the element + * @param prop - CSS property + * @returns Computed style property + */ +export const getComputedStyleProp = (computedStyle: CSSStyleDeclaration, prop: string): string[] => { + const propValue = computedStyle.getPropertyValue(prop); + + return propValue ? propValue.split(',') : ['0']; +}; + +/** + * Gets the maximum duration from a list of CSS durations. + * + * @param durations - List of CSS durations + * @param delays - List of CSS delays + * @returns Maximum duration + */ +export const getMaxCSSDuration = (durations: string[], delays: string[]): number => { + const totalProps = Math.max(durations.length, delays.length); + const totalDurations = []; + + if (totalProps === 0) { + return 0; + } + + for (let i = 0; i < totalProps; i++) { + const duration = toMs(durations[i] || '0'); + const delay = toMs(delays[i] || '0'); + + totalDurations.push(duration + delay); + } + + return Math.max(...totalDurations); +}; + +/** + * Gets the motion information for a given element. + * + * @param computedStyle - Computed style of the element + * @returns motion information + */ +export const getMotionDuration = (node: HTMLElementWithStyledMap) => { + const hasModernCSSSupport = hasCSSOMSupport(node); + const computedStyle = getCSSStyle(node); + + const getProp = (prop: string): string[] => { + return hasModernCSSSupport + ? getComputedMapProp(computedStyle as StylePropertyMapReadOnly, prop) + : getComputedStyleProp(computedStyle as CSSStyleDeclaration, prop); + }; + + const transitionDuration = getMaxCSSDuration(getProp('transition-duration'), getProp('transition-delay')); + const animationDuration = getMaxCSSDuration(getProp('animation-duration'), getProp('animation-delay')); + + return Math.max(transitionDuration, animationDuration); +}; 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 04860139511370..1c89e468cc02ed 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 @@ -36,4 +36,5 @@ export type ToastContainerState = ComponentState & running: boolean; onTransitionEntering: () => void; nodeRef: React.Ref; + shouldRender: boolean; }; 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 64895bbfe19116..3b8471a203028d 100644 --- a/packages/react-components/react-toast/src/components/ToastContainer/renderToastContainer.tsx +++ b/packages/react-components/react-toast/src/components/ToastContainer/renderToastContainer.tsx @@ -14,9 +14,20 @@ export const renderToastContainer_unstable = ( state: ToastContainerState, contextValues: ToastContainerContextValues, ) => { - const { onTransitionEntering, visible, transitionTimeout, remove, nodeRef } = state; + const { onTransitionEntering, visible, transitionTimeout, remove, nodeRef, shouldRender } = state; assertSlots(state); + if (shouldRender) { + return ( + + + + + ); + } + + return null; + return ( , ): ToastContainerState => { const { - visible, + visible: visibleProp, children, close: closeProp, remove, @@ -66,6 +68,25 @@ export const useToastContainer_unstable = ( ignoreDefaultKeydown: { Tab: true, Escape: true, Enter: true }, }); + const { active: visible, canRender: shouldRender, type, ref: motionRef } = useMotion(visibleProp); + + useIsomorphicLayoutEffect(() => { + if (type !== 'entering' || !toastRef.current) { + return; + } + + const element = toastRef.current; + element.style.setProperty('--fui-toast-height', `${element.scrollHeight}px`); + }, [type]); + + useIsomorphicLayoutEffect(() => { + if (type !== 'exited') { + return; + } + + remove(); + }, [type, remove]); + const close = useEventCallback(() => { const activeElement = targetDocument?.activeElement; if (activeElement && toastRef.current?.contains(activeElement)) { @@ -220,7 +241,7 @@ export const useToastContainer_unstable = ( ), root: slot.always( getNativeElementProps('div', { - ref: useMergedRefs(ref, toastRef, toastAnimationRef), + ref: useMergedRefs(ref, toastRef, toastAnimationRef, motionRef), children, tabIndex: 0, role: 'listitem', @@ -239,6 +260,7 @@ export const useToastContainer_unstable = ( transitionTimeout: 500, running, visible, + shouldRender, remove, close, onTransitionEntering, diff --git a/packages/react-components/react-toast/src/state/useToaster.ts b/packages/react-components/react-toast/src/state/useToaster.ts index ac01976ecb6f62..7ea007059b692f 100644 --- a/packages/react-components/react-toast/src/state/useToaster.ts +++ b/packages/react-components/react-toast/src/state/useToaster.ts @@ -67,6 +67,7 @@ export function useToaster(option const tryRestoreFocus = React.useCallback(() => { const mostRecentToast = getMostRecentVisibleToast(); + console.log(mostRecentToast?.imperativeRef); if (mostRecentToast?.imperativeRef.current) { mostRecentToast.imperativeRef.current.focus(); } else { diff --git a/packages/react-components/react-toast/stories/Toast/ToasterLimit.stories.tsx b/packages/react-components/react-toast/stories/Toast/ToasterLimit.stories.tsx index a277eac6798a5b..9636881c480546 100644 --- a/packages/react-components/react-toast/stories/Toast/ToasterLimit.stories.tsx +++ b/packages/react-components/react-toast/stories/Toast/ToasterLimit.stories.tsx @@ -10,6 +10,8 @@ import { Toast, } from '@fluentui/react-components'; +let count = 0; + export const ToasterLimit = () => { const toasterId = useId('toaster'); const { dispatchToast } = useToastController(toasterId); @@ -17,10 +19,11 @@ export const ToasterLimit = () => { const notify = () => dispatchToast( - Limited to 3 toasts + Limited to 3 toasts {count++} , { intent: 'success' }, ); + console.log(limit); return ( <> diff --git a/packages/react-components/react-utilities/etc/react-utilities.api.md b/packages/react-components/react-utilities/etc/react-utilities.api.md index 0c237a9665b025..832fd9488e27ff 100644 --- a/packages/react-components/react-utilities/etc/react-utilities.api.md +++ b/packages/react-components/react-utilities/etc/react-utilities.api.md @@ -294,6 +294,9 @@ export type UnknownSlotProps = Pick, 'childr as?: keyof JSX.IntrinsicElements; }; +// @internal +export function useAnimationFrame(): readonly [(fn: () => void, delay?: number | undefined) => number, () => void]; + // @internal export const useControllableState: (options: UseControllableStateOptions) => [State, React_2.Dispatch>]; @@ -350,7 +353,7 @@ export function useScrollbarWidth(options: UseScrollbarWidthOptions): number | u export function useSelection(params: SelectionHookParams): readonly [Set, SelectionMethods]; // @internal -export function useTimeout(): readonly [(fn: () => void, delay: number) => void, () => void]; +export function useTimeout(): readonly [(fn: () => void, delay?: number | undefined) => number, () => void]; // (No @packageDocumentation comment for this package) diff --git a/packages/react-components/react-utilities/src/hooks/index.ts b/packages/react-components/react-utilities/src/hooks/index.ts index e5928f400aa78f..018e521c02bb6b 100644 --- a/packages/react-components/react-utilities/src/hooks/index.ts +++ b/packages/react-components/react-utilities/src/hooks/index.ts @@ -1,3 +1,4 @@ +export * from './useAnimationFrame'; export * from './useControllableState'; export * from './useEventCallback'; export * from './useFirstMount'; diff --git a/packages/react-components/react-utilities/src/hooks/useAnimationFrame.test.ts b/packages/react-components/react-utilities/src/hooks/useAnimationFrame.test.ts new file mode 100644 index 00000000000000..307eddec185bc6 --- /dev/null +++ b/packages/react-components/react-utilities/src/hooks/useAnimationFrame.test.ts @@ -0,0 +1,106 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useAnimationFrame } from './useAnimationFrame'; + +describe('useAnimationFrame', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('calls the callback only on the next frame', () => { + const [setTestRequestAnimationFrame] = renderHook(() => useAnimationFrame()).result.current; + const callback = jest.fn(); + + setTestRequestAnimationFrame(callback); + + expect(callback).not.toHaveBeenCalled(); + + jest.runAllTimers(); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('does not call the callback if cancel is called', () => { + const [setTestRequestAnimationFrame, cancelTEstRequestAnimationFrame] = renderHook(() => useAnimationFrame()).result + .current; + const callback = jest.fn(); + + setTestRequestAnimationFrame(callback); + cancelTEstRequestAnimationFrame(); + + jest.runAllTimers(); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('cancel the previous requestAnimationFrame if set is called again', () => { + const [setTestRequestAnimationFrame] = renderHook(() => useAnimationFrame()).result.current; + const callbackA = jest.fn(); + const callbackB = jest.fn(); + + setTestRequestAnimationFrame(callbackA); + setTestRequestAnimationFrame(callbackB); + + jest.runAllTimers(); + + expect(callbackA).not.toHaveBeenCalled(); + expect(callbackB).toHaveBeenCalledTimes(1); + }); + + it('allows another requestAnimationFrame to be set after the previous has run', () => { + const [setTestRequestAnimationFrame] = renderHook(() => useAnimationFrame()).result.current; + const callbackA = jest.fn(); + const callbackB = jest.fn(); + + setTestRequestAnimationFrame(callbackA); + + jest.runAllTimers(); + + setTestRequestAnimationFrame(callbackB); + + jest.runAllTimers(); + + expect(callbackA).toHaveBeenCalledTimes(1); + expect(callbackB).toHaveBeenCalledTimes(1); + }); + + it('does not cancel the requestAnimationFrame between renders', () => { + const { result, rerender } = renderHook(() => useAnimationFrame()); + const [setTestRequestAnimationFrame] = result.current; + const callback = jest.fn(); + + setTestRequestAnimationFrame(callback); + + rerender(); + + jest.runAllTimers(); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('cancel the requestAnimationFrame when the component is unmounted', () => { + const { result, unmount } = renderHook(() => useAnimationFrame()); + const [setTestRequestAnimationFrame] = result.current; + const callback = jest.fn(); + + setTestRequestAnimationFrame(callback); + + unmount(); + + jest.runAllTimers(); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('returns the same functions every render', () => { + const { result, rerender } = renderHook(() => useAnimationFrame()); + const [setTestRequestAnimationFrame, cancelTEstRequestAnimationFrame] = result.current; + + rerender(); + + expect(result.current).toStrictEqual([setTestRequestAnimationFrame, cancelTEstRequestAnimationFrame]); + }); +}); diff --git a/packages/react-components/react-utilities/src/hooks/useAnimationFrame.ts b/packages/react-components/react-utilities/src/hooks/useAnimationFrame.ts new file mode 100644 index 00000000000000..de44967dad2027 --- /dev/null +++ b/packages/react-components/react-utilities/src/hooks/useAnimationFrame.ts @@ -0,0 +1,13 @@ +import { useBrowserTimer } from './useBrowserTimer'; + +/** + * @internal + * Helper to manage a browser requestAnimationFrame. + * Ensures that the requestAnimationFrame isn't set multiple times at once, + * and is cleaned up when the component is unloaded. + * + * @returns A pair of [requestAnimationFrame, cancelAnimationFrame] that are stable between renders. + */ +export function useAnimationFrame() { + return useBrowserTimer(requestAnimationFrame, cancelAnimationFrame); +} diff --git a/packages/react-components/react-utilities/src/hooks/useBrowserTimer.test.ts b/packages/react-components/react-utilities/src/hooks/useBrowserTimer.test.ts new file mode 100644 index 00000000000000..26600feb7262bb --- /dev/null +++ b/packages/react-components/react-utilities/src/hooks/useBrowserTimer.test.ts @@ -0,0 +1,61 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useBrowserTimer } from './useBrowserTimer'; + +const setTimer = jest.fn((callback: jest.Func) => { + callback(); + return 0; +}); + +const cancelTimer = jest.fn(() => 0); + +describe('useBrowserTimer', () => { + it('should return array with functions', () => { + const hookValues = renderHook(() => useBrowserTimer(setTimer, cancelTimer)).result.current; + + expect(hookValues).toHaveLength(2); + expect(typeof hookValues[0]).toBe('function'); + expect(typeof hookValues[1]).toBe('function'); + expect(hookValues[0].name).toBe('set'); + expect(hookValues[1].name).toBe('cancel'); + }); + + it('calls the setter only n times', () => { + const [setTestTimer] = renderHook(() => useBrowserTimer(setTimer, cancelTimer)).result.current; + const callback = jest.fn(); + + setTestTimer(callback); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).not.toHaveBeenCalledTimes(2); + expect(setTimer).toHaveBeenCalledTimes(1); + expect(setTimer).not.toHaveBeenCalledTimes(2); + }); + + it('setter should return timer id', () => { + const [setTestTimer] = renderHook(() => useBrowserTimer(setTimer, cancelTimer)).result.current; + const callback = jest.fn(); + + const timerId = setTestTimer(callback); + + expect(callback).toHaveBeenCalledTimes(1); + expect(timerId).toBe(0); + }); + + it('should not call the cancel callback if not setter was called', () => { + const [, cancelTestTimer] = renderHook(() => useBrowserTimer(setTimer, cancelTimer)).result.current; + + cancelTestTimer(); + + expect(cancelTimer).not.toHaveBeenCalledTimes(1); + }); + + it('calls the cancel only n times', () => { + const [setTestTimer, cancelTestTimer] = renderHook(() => useBrowserTimer(setTimer, cancelTimer)).result.current; + + setTestTimer(jest.fn()); + cancelTestTimer(); + + expect(cancelTimer).toHaveBeenCalledTimes(1); + expect(cancelTimer).not.toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/react-components/react-utilities/src/hooks/useBrowserTimer.ts b/packages/react-components/react-utilities/src/hooks/useBrowserTimer.ts new file mode 100644 index 00000000000000..5422ca5f795611 --- /dev/null +++ b/packages/react-components/react-utilities/src/hooks/useBrowserTimer.ts @@ -0,0 +1,47 @@ +import * as React from 'react'; + +type UseBrowserTimerSetter = + | ((fn: () => void, duration?: number, ...args: Record[]) => number) + | ((fn: () => void) => number); +type UseBrowserTimerCancel = ((timerId: number) => void) | (() => void); + +/** + * @internal + * Helper to manage a browser timer. + * Ensures that the timer isn't set multiple times at once, + * and is cleaned up when the component is unloaded. + * + * @param setTimer - The timer setter function + * @param cancelTimer - The timer cancel function + * @returns A pair of [setTimer, cancelTimer] that are stable between renders. + * + * @example + * const [setTimer, cancelTimer] = useBrowserTimer(setTimeout, cancelTimeout); + * + * setTimer(() => console.log('Hello world!'), 1000); + * cancelTimer(); + */ +export function useBrowserTimer( + setTimer: TSetter, + cancelTimer: TCancel, +) { + const [timeout] = React.useState(() => ({ + id: undefined as number | undefined, + set: (fn: () => void, delay?: number) => { + timeout.cancel(); + timeout.id = delay ? setTimer(fn, delay) : setTimer(fn); + return timeout.id; + }, + cancel: () => { + if (timeout.id !== undefined) { + cancelTimer(timeout.id); + timeout.id = undefined; + } + }, + })); + + // Clean up the timeout when the component is unloaded + React.useEffect(() => timeout.cancel, [timeout]); + + return [timeout.set, timeout.cancel] as const; +} diff --git a/packages/react-components/react-utilities/src/hooks/useTimeout.ts b/packages/react-components/react-utilities/src/hooks/useTimeout.ts index 250c64fdf9b9df..16d5c78499e89c 100644 --- a/packages/react-components/react-utilities/src/hooks/useTimeout.ts +++ b/packages/react-components/react-utilities/src/hooks/useTimeout.ts @@ -1,4 +1,4 @@ -import * as React from 'react'; +import { useBrowserTimer } from './useBrowserTimer'; /** * @internal @@ -9,22 +9,5 @@ import * as React from 'react'; * @returns A pair of [setTimeout, clearTimeout] that are stable between renders. */ export function useTimeout() { - const [timeout] = React.useState(() => ({ - id: undefined as ReturnType | undefined, - set: (fn: () => void, delay: number) => { - timeout.clear(); - timeout.id = setTimeout(fn, delay); - }, - clear: () => { - if (timeout.id !== undefined) { - clearTimeout(timeout.id); - timeout.id = undefined; - } - }, - })); - - // Clean up the timeout when the component is unloaded - React.useEffect(() => timeout.clear, [timeout]); - - return [timeout.set, timeout.clear] as const; + return useBrowserTimer(setTimeout, clearTimeout); } diff --git a/packages/react-components/react-utilities/src/index.ts b/packages/react-components/react-utilities/src/index.ts index ca628ac61ce07b..97fc981ebeb745 100644 --- a/packages/react-components/react-utilities/src/index.ts +++ b/packages/react-components/react-utilities/src/index.ts @@ -30,6 +30,7 @@ export type { export { IdPrefixProvider, resetIdsForTests, + useAnimationFrame, useControllableState, useEventCallback, useFirstMount, diff --git a/tsconfig.base.all.json b/tsconfig.base.all.json index b3fd6ec0944808..257989ebd92fd0 100644 --- a/tsconfig.base.all.json +++ b/tsconfig.base.all.json @@ -122,6 +122,7 @@ "@fluentui/react-menu": ["packages/react-components/react-menu/src/index.ts"], "@fluentui/react-migration-v0-v9": ["packages/react-components/react-migration-v0-v9/src/index.ts"], "@fluentui/react-migration-v8-v9": ["packages/react-components/react-migration-v8-v9/src/index.ts"], + "@fluentui/react-motion-preview": ["packages/react-components/react-motion-preview/src/index.ts"], "@fluentui/react-nav-preview": ["packages/react-components/react-nav-preview/src/index.ts"], "@fluentui/react-overflow": ["packages/react-components/react-overflow/src/index.ts"], "@fluentui/react-persona": ["packages/react-components/react-persona/src/index.ts"], @@ -160,8 +161,7 @@ "@fluentui/react-virtualizer": ["packages/react-components/react-virtualizer/src/index.ts"], "@fluentui/theme-designer": ["packages/react-components/theme-designer/src/index.ts"], "@fluentui/tokens": ["packages/tokens/src/index.ts"], - "@fluentui/workspace-plugin": ["tools/workspace-plugin/src/index.ts"], - "@fluentui/react-motion-preview": ["packages/react-components/react-motion-preview/src/index.ts"] + "@fluentui/workspace-plugin": ["tools/workspace-plugin/src/index.ts"] } } }