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..2362d993402914 --- /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/packages/react-components/react-motion-preview/package.json b/packages/react-components/react-motion-preview/package.json index 6bd34c0e77dfc1..532c570d60ff75 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/useMotion.test.ts b/packages/react-components/react-motion-preview/src/hooks/useMotion.test.ts index 4e2540fc44382a..56dde9425d4998 100644 --- a/packages/react-components/react-motion-preview/src/hooks/useMotion.test.ts +++ b/packages/react-components/react-motion-preview/src/hooks/useMotion.test.ts @@ -1,20 +1,19 @@ -import * as React from 'react'; import { act, renderHook } from '@testing-library/react-hooks'; -import { useMotion, UseMotionOptions, MotionShorthand } from './useMotion'; +import { useMotion, MotionOptions, MotionShorthand, getDefaultMotionState, useIsMotion } from './useMotion'; const defaultDuration = 100; const renderHookWithRef = ( - initialState: MotionShorthand, - initialOptions?: UseMotionOptions, + initialMotion: MotionShorthand, + initialOptions?: MotionOptions, style: Record = { 'transition-duration': `${defaultDuration}ms` }, ) => { const refEl = document.createElement('div'); - const hook = renderHook(({ state, options }) => useMotion(state, options), { + const hook = renderHook(({ motion, options }) => useMotion(motion, options), { initialProps: { - state: initialState, + motion: initialMotion, options: initialOptions, - } as { state: MotionShorthand; options?: UseMotionOptions }, + } as { motion: MotionShorthand; options?: MotionOptions }, }); Object.entries(style).forEach(([key, value]) => value && refEl.style.setProperty(key, value)); @@ -43,6 +42,7 @@ describe('useMotion', () => { afterEach(() => { jest.useRealTimers(); + jest.resetAllMocks(); }); describe('when presence is false by default', () => { @@ -50,7 +50,7 @@ describe('useMotion', () => { const { result } = renderHookWithRef(false); expect(typeof result.current.ref).toBe('function'); - expect(result.current.state).toBe('unmounted'); + expect(result.current.type).toBe('unmounted'); expect(result.current.active).toBe(false); }); }); @@ -80,34 +80,34 @@ describe('useMotion', () => { const { result, rerender } = renderHookWithRef(false); expect(typeof result.current.ref).toBe('function'); - expect(result.current.state).toBe('unmounted'); + expect(result.current.type).toBe('unmounted'); expect(result.current.active).toBe(false); - rerender({ state: true }); + rerender({ motion: true }); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.state).toBe('entering'); + expect(result.current.type).toBe('entering'); expect(result.current.active).toBe(true); act(() => jest.advanceTimersByTime(defaultDuration + 1)); - expect(result.current.state).toBe('entered'); + expect(result.current.type).toBe('entered'); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.state).toBe('idle'); + expect(result.current.type).toBe('idle'); - rerender({ state: false }); + rerender({ motion: false }); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.state).toBe('exiting'); + expect(result.current.type).toBe('exiting'); expect(result.current.active).toBe(false); act(() => jest.advanceTimersByTime(defaultDuration + 1)); - expect(result.current.state).toBe('exited'); + expect(result.current.type).toBe('exited'); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.state).toBe('unmounted'); + expect(result.current.type).toBe('unmounted'); expect(result.current.active).toBe(false); }); @@ -117,18 +117,18 @@ describe('useMotion', () => { expect(typeof result.current.ref).toBe('function'); expect(result.current.active).toBe(true); - rerender({ state: false }); + rerender({ motion: false }); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.state).toBe('exiting'); + expect(result.current.type).toBe('exiting'); expect(result.current.active).toBe(false); act(() => jest.advanceTimersByTime(defaultDuration + 1)); - expect(result.current.state).toBe('exited'); + expect(result.current.type).toBe('exited'); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.state).toBe('unmounted'); + expect(result.current.type).toBe('unmounted'); expect(result.current.active).toBe(false); }); }); @@ -143,36 +143,36 @@ describe('useMotion', () => { const { result, rerender } = renderHookWithRef(false, {}, styles); expect(typeof result.current.ref).toBe('function'); - expect(result.current.state).toBe('unmounted'); + expect(result.current.type).toBe('unmounted'); expect(result.current.active).toBe(false); - rerender({ state: true }); + rerender({ motion: true }); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.state).toBe('entering'); + expect(result.current.type).toBe('entering'); expect(result.current.active).toBe(true); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.state).toBe('entered'); + expect(result.current.type).toBe('entered'); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.state).toBe('idle'); + expect(result.current.type).toBe('idle'); - rerender({ state: false }); + rerender({ motion: false }); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.state).toBe('exiting'); + expect(result.current.type).toBe('exiting'); expect(result.current.active).toBe(false); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.state).toBe('exited'); + expect(result.current.type).toBe('exited'); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.state).toBe('unmounted'); + expect(result.current.type).toBe('unmounted'); expect(result.current.active).toBe(false); }); }); @@ -185,25 +185,25 @@ describe('useMotion', () => { const { result, rerender } = renderHookWithRef(false, {}, styles); expect(typeof result.current.ref).toBe('function'); - expect(result.current.state).toBe('unmounted'); + expect(result.current.type).toBe('unmounted'); expect(result.current.active).toBe(false); - rerender({ state: true }); + rerender({ motion: true }); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.state).toBe('entered'); + expect(result.current.type).toBe('entered'); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.state).toBe('idle'); + expect(result.current.type).toBe('idle'); // requestAnimationFrame act(() => jest.advanceTimersToNextTimer()); expect(result.current.active).toBe(true); // timeout act(() => jest.advanceTimersToNextTimer()); - expect(result.current.state).toBe('idle'); + expect(result.current.type).toBe('idle'); - rerender({ state: false }); + rerender({ motion: false }); act(() => jest.advanceTimersToNextTimer()); act(() => jest.advanceTimersToNextTimer()); @@ -213,26 +213,48 @@ describe('useMotion', () => { act(() => jest.advanceTimersToNextTimer()); // timeout act(() => jest.advanceTimersToNextTimer()); - expect(result.current.state).toBe('unmounted'); + expect(result.current.type).toBe('unmounted'); }); }); describe('when motion is received', () => { it('should return default values when presence is false', () => { - const ref = React.createRef(); - const { result } = renderHookWithRef({ state: 'unmounted', active: false, ref }); + const defaultState = getDefaultMotionState(); + const { result } = renderHookWithRef(getDefaultMotionState()); - expect(result.current.state).toBe('unmounted'); - expect(result.current.ref).toBe(ref); - expect(result.current.active).toBe(false); + 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 ref = React.createRef(); - const { result } = renderHookWithRef({ state: 'idle', active: true, ref }); + const defaultState = getDefaultMotionState(); + const { result } = renderHookWithRef({ ...getDefaultMotionState(), active: true }); - expect(result.current.ref).toBe(ref); - expect(result.current.active).toBe(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 index 778ee5dd13a95a..13db15a8ed04d9 100644 --- a/packages/react-components/react-motion-preview/src/hooks/useMotion.ts +++ b/packages/react-components/react-motion-preview/src/hooks/useMotion.ts @@ -1,8 +1,7 @@ import * as React from 'react'; import { unstable_batchedUpdates } from 'react-dom'; -import { HTMLElementWithStyledMap, getMotionDuration } from '../utils/style'; -import { useAnimationFrame, useTimeout } from '@fluentui/react-utilities'; -import { useIsMotion } from './useIsMotion'; +import { HTMLElementWithStyledMap, getMotionDuration } from '../utils/dom-style'; +import { useAnimationFrame, useTimeout, usePrevious } from '@fluentui/react-utilities'; export type MotionOptions = { /** @@ -33,23 +32,17 @@ export type MotionState = { */ type: MotionType; - /** - * Indicates whether the component is currently rendered and visible. - * Useful to apply CSS transitions only when the element is active. - */ - isActive(): boolean; - /** * Indicates whether the component can be rendered. - * This can be used to avoid rendering the component when it is not visible anymore. + * Useful to render the element before animating it or to remove it from the DOM after exit animation. */ - canRender(): boolean; + canRender: boolean; /** - * Whether to disable internal motion. - * This is useful when the component is wrapped in a parent component that handles the motion. + * Indicates whether the component is ready to receive a CSS transition className. + * Useful to apply CSS transitions when the element is mounted and ready to be animated. */ - hasInternalMotion: boolean; + active: boolean; }; export type MotionShorthandValue = boolean; @@ -180,18 +173,15 @@ function useMotionPresence( skipAnimationOnFirstRender.current = false; }, []); - return React.useMemo(() => { - const canRender = () => type !== 'unmounted'; - const isActive = () => active; - - return { + return React.useMemo>( + () => ({ ref, type, - canRender, - isActive, - hasInternalMotion: true, - }; - }, [active, ref, type]); + active, + canRender: type !== 'unmounted', + }), + [active, ref, type], + ); } /** @@ -201,9 +191,8 @@ export function getDefaultMotionState(): MotionStat return { ref: React.createRef(), type: 'unmounted', - hasInternalMotion: false, - isActive: () => false, - canRender: () => false, + active: false, + canRender: false, }; } @@ -224,13 +213,50 @@ export function useMotion( * 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. */ - if (useIsMotion(shorthand)) { - return { - ...shorthand, - hasInternalMotion: false, - }; - } - // eslint-disable-next-line react-hooks/rules-of-hooks - return useMotionPresence(shorthand, options); + return useIsMotion(shorthand) ? shorthand : useMotionPresence(shorthand, options); +} + +const stringifyShorthand = (value: MotionShorthand) => { + return JSON.stringify(value, null, 2); +}; + +/** + * @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) { + // 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/hooks/useMotionFromSlot.ts b/packages/react-components/react-motion-preview/src/hooks/useMotionFromSlot.ts index 8acc70487b281e..40ee7fd2f98ca9 100644 --- a/packages/react-components/react-motion-preview/src/hooks/useMotionFromSlot.ts +++ b/packages/react-components/react-motion-preview/src/hooks/useMotionFromSlot.ts @@ -1,5 +1,5 @@ import { UnknownSlotProps, slot, SlotShorthandValue } from '@fluentui/react-utilities'; -import { MotionShorthand, MotionState, useMotion, UseMotionOptions } from './useMotion'; +import { MotionShorthand, MotionState, useMotion, MotionOptions } from './useMotion'; export interface UnknownSlotPropsWithMotion extends UnknownSlotProps { motion?: MotionShorthand; @@ -16,7 +16,7 @@ export interface UnknownSlotPropsWithMotion extends export function useMotionFromSlot( props: Props | SlotShorthandValue | undefined | null, shorthand: MotionShorthand, - options?: UseMotionOptions, + options?: MotionOptions, ): [Props, MotionState] { const shorthandProps = slot.resolveShorthand(props); const { motion: motionProp, ...slotProps } = (shorthandProps ?? {}) as UnknownSlotPropsWithMotion; diff --git a/packages/react-components/react-motion-preview/src/index.ts b/packages/react-components/react-motion-preview/src/index.ts index f03411d86641e4..73b569d327d67e 100644 --- a/packages/react-components/react-motion-preview/src/index.ts +++ b/packages/react-components/react-motion-preview/src/index.ts @@ -1,9 +1,9 @@ -export { getDefaultMotionState, useMotion, useMotionFromSlot, useMotionStyles } from './hooks'; +export { getDefaultMotionState, useIsMotion, useMotion, useMotionFromSlot, useMotionStyles } from './hooks'; export type { + MotionOptions, MotionShorthand, MotionShorthandValue, MotionState, MotionType, UnknownSlotPropsWithMotion, - UseMotionOptions, } from './hooks'; diff --git a/packages/react-components/react-motion-preview/src/utils/style.ts b/packages/react-components/react-motion-preview/src/utils/dom-style.ts similarity index 96% rename from packages/react-components/react-motion-preview/src/utils/style.ts rename to packages/react-components/react-motion-preview/src/utils/dom-style.ts index 3078f7bc807011..faa0aef71fa582 100644 --- a/packages/react-components/react-motion-preview/src/utils/style.ts +++ b/packages/react-components/react-motion-preview/src/utils/dom-style.ts @@ -81,9 +81,9 @@ export const hasCSSOMSupport = (node: HTMLElementWithStyledMap) => { * @returns - CSS styles. */ export const getElementComputedStyle = (node: HTMLElement): CSSStyleDeclaration => { - const win = node.ownerDocument?.defaultView ? node.ownerDocument.defaultView : window; + const win = canUseDOM() && (node.ownerDocument?.defaultView ?? window); - if (!win || !canUseDOM()) { + if (!win) { return { getPropertyValue: (_: string) => '', } as CSSStyleDeclaration; @@ -105,8 +105,10 @@ export function toMs(duration: string): number { return 0; } - if (trimmed.includes('ms')) { - return parseFloat(trimmed); + if (trimmed.endsWith('ms')) { + const parsed = Number(trimmed.replace('ms', '')); + + return isNaN(parsed) ? 0 : parsed; } return Number(trimmed.slice(0, -1).replace(',', '.')) * 1000; 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"] } } }