diff --git a/apps/public-docsite-v9/package.json b/apps/public-docsite-v9/package.json index a14427594c743..e549ed058a3ed 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 0000000000000..2362d99340291 --- /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 0000000000000..6d2544660a211 --- /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 0000000000000..3ee9028024c3d --- /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/etc/react-motion-preview.api.md b/packages/react-components/react-motion-preview/etc/react-motion-preview.api.md index 5f1c6b00b3451..0361130acce4c 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 MotionOptions = { + animateOnFirstMount?: boolean; +}; + +// @public (undocumented) +export type MotionShorthand = MotionShorthandValue | MotionState; + +// @public (undocumented) +export type MotionShorthandValue = boolean; + +// @public (undocumented) +export type MotionState = { + ref: React_2.Ref; + type: MotionType; + canRender: boolean; + active: boolean; +}; + +// @public (undocumented) +export type MotionType = 'entering' | 'entered' | 'idle' | 'exiting' | 'exited' | 'unmounted'; + +// @public +export function useMotion(shorthand: MotionShorthand, options?: MotionOptions): MotionState; + // (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 54be407f56245..003ed0a8be4bd 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 0000000000000..4fb9f2d6d5f17 --- /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 0000000000000..2a8f39a7e4505 --- /dev/null +++ b/packages/react-components/react-motion-preview/src/hooks/useMotion.test.ts @@ -0,0 +1,289 @@ +import { act, renderHook } from '@testing-library/react-hooks'; + +import { useMotion, MotionOptions, MotionShorthand, getDefaultMotionState, useIsMotion } from './useMotion'; + +const defaultDuration = 100; +const renderHookWithRef = ( + initialMotion: MotionShorthand, + initialOptions?: MotionOptions, + 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?: MotionOptions }, + }); + + Object.entries(style).forEach(([key, value]) => value && refEl.style.setProperty(key, value)); + + function renderRef() { + act(() => { + if (!hook.result.current.canRender) { + return; + } + + if (typeof hook.result.current.ref === 'function') { + hook.result.current.ref(refEl); + } + }); + } + + act(() => renderRef()); + + function rerender(motion: MotionShorthand, options?: MotionOptions) { + hook.rerender({ motion, options }); + act(() => renderRef()); + } + + return { ...hook, rerender }; +}; + +const jumpAnimationFrame = () => { + // requestAnimationFrame + act(() => jest.advanceTimersToNextTimer()); +}; + +const jumpAnimationTimeout = (timeout: number = defaultDuration) => { + // timeout + requestAnimationFrame + act(() => { + jest.advanceTimersByTime(timeout); + jest.advanceTimersToNextTimer(); + }); +}; + +describe('useMotion', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.resetAllMocks(); + }); + + 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(' '), + ); + }); + }); + + 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); + expect(result.current.canRender).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); + expect(result.current.type).not.toBe('unmounted'); + expect(result.current.canRender).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); + expect(result.current.canRender).toBe(true); + + act(() => jest.advanceTimersToNextTimer()); + + expect(result.current.active).toBe(true); + }); + }); + + describe('when presence changes', () => { + it('should toggle values starting with false', () => { + const { result, rerender } = renderHookWithRef(false); + + act(() => jest.advanceTimersToNextTimer()); + + expect(typeof result.current.ref).toBe('function'); + expect(result.current.type).toBe('unmounted'); + expect(result.current.active).toBe(false); + expect(result.current.canRender).toBe(false); + + rerender(true); + + expect(result.current.canRender).toBe(true); + expect(result.current.type).toBe('entering'); + jumpAnimationFrame(); + + expect(result.current.active).toBe(true); + + jumpAnimationTimeout(); + expect(result.current.type).toBe('entered'); + + jumpAnimationFrame(); + expect(result.current.type).toBe('idle'); + + rerender(false); + + expect(result.current.type).toBe('exiting'); + + jumpAnimationFrame(); + expect(result.current.active).toBe(false); + + jumpAnimationTimeout(); + expect(result.current.type).toBe('exited'); + + jumpAnimationFrame(); + expect(result.current.type).toBe('unmounted'); + expect(result.current.active).toBe(false); + expect(result.current.canRender).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); + expect(result.current.canRender).toBe(true); + + rerender(false); + + expect(result.current.type).toBe('exiting'); + + jumpAnimationFrame(); + expect(result.current.active).toBe(false); + + jumpAnimationTimeout(); + expect(result.current.type).toBe('exited'); + + jumpAnimationFrame(); + expect(result.current.type).toBe('unmounted'); + expect(result.current.canRender).toBe(false); + 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); + expect(result.current.canRender).toBe(false); + + rerender(true); + + expect(result.current.type).toBe('entering'); + + jumpAnimationFrame(); + expect(result.current.active).toBe(true); + + jumpAnimationTimeout(); + expect(result.current.type).toBe('entered'); + + jumpAnimationFrame(); + expect(result.current.type).toBe('idle'); + + rerender(false); + + expect(result.current.type).toBe('exiting'); + + jumpAnimationFrame(); + expect(result.current.active).toBe(false); + + jumpAnimationTimeout(); + expect(result.current.type).toBe('exited'); + + jumpAnimationFrame(); + 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 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); + expect(result.current.canRender).toBe(false); + + rerender(true); + + expect(result.current.type).toBe('entering'); + + jumpAnimationFrame(); + expect(result.current.active).toBe(true); + + jumpAnimationTimeout(0); + expect(result.current.type).toBe('entered'); + + jumpAnimationFrame(); + expect(result.current.type).toBe('idle'); + + rerender(false); + + expect(result.current.type).toBe('exiting'); + + jumpAnimationFrame(); + expect(result.current.active).toBe(false); + + jumpAnimationTimeout(0); + expect(result.current.type).toBe('exited'); + + jumpAnimationFrame(); + expect(result.current.type).toBe('unmounted'); + expect(result.current.active).toBe(false); + }); + }); +}); 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 0000000000000..49237bfa9f9ac --- /dev/null +++ b/packages/react-components/react-motion-preview/src/hooks/useMotion.ts @@ -0,0 +1,266 @@ +import * as React from 'react'; +import { useAnimationFrame, useTimeout, usePrevious, useFirstMount } from '@fluentui/react-utilities'; + +import { getMotionDuration } from '../utils/dom-style'; +import type { HTMLElementWithStyledMap } from '../utils/dom-style'; + +export type MotionOptions = { + /** + * Whether to animate the element on first mount. + * + * @default false + */ + animateOnFirstMount?: boolean; +}; + +export type MotionType = 'entering' | 'entered' | 'idle' | 'exiting' | 'exited' | 'unmounted'; + +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 can be rendered. + * Useful to render the element before animating it or to remove it from the DOM after exit animation. + */ + canRender: boolean; + + /** + * 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. + */ + active: boolean; +}; + +export type MotionShorthandValue = boolean; + +export type MotionShorthand = MotionShorthandValue | MotionState; + +/** + * @internal + * + * Checks if components was mounted after first render and a certain condition is met. + * + * @param condition - Condition to check + */ +const useFirstMountCondition = (condition: boolean): boolean => { + const isFirst = React.useRef(true); + + if (isFirst.current && condition) { + isFirst.current = false; + return true; + } + + return isFirst.current; +}; + +/** + * @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: MotionOptions = {}, +): MotionState { + const { animateOnFirstMount } = { animateOnFirstMount: false, ...options }; + + const [type, setType] = React.useState( + presence && animateOnFirstMount ? 'entering' : presence ? 'idle' : 'unmounted', + ); + const [active, setActive] = React.useState(!animateOnFirstMount && presence); + + const [setAnimationTimeout, clearAnimationTimeout] = useTimeout(); + const [setAnimationFrame, cancelAnimationFrame] = useAnimationFrame(); + + const [currentElement, setCurrentElement] = React.useState | null>(null); + + const isFirstReactRender = useFirstMount(); + const isFirstDOMRender = useFirstMountCondition(!!currentElement); + const isInitiallyPresent = React.useRef(presence).current; + const disableAnimation = isFirstDOMRender && isInitiallyPresent && !animateOnFirstMount; + + const ref: React.RefCallback> = React.useCallback(node => { + if (!node) { + return; + } + + setCurrentElement(node); + }, []); + + const onFinished = React.useCallback(() => { + setType(presence ? 'entered' : 'exited'); + setAnimationFrame(() => setType(presence ? 'idle' : 'unmounted')); + }, [presence, setAnimationFrame]); + + React.useEffect(() => { + if (isFirstReactRender) { + return; + } + + /* + * In case animation is disabled, we can skip the animation and go straight to the idle state. + */ + if (disableAnimation) { + setType(presence ? 'idle' : 'unmounted'); + setActive(presence); + return; + } + + setType(presence ? 'entering' : 'exiting'); + + /* + * If the element is not rendered, nothing to do. + */ + if (!currentElement) { + return; + } + + /* + * Wait for the next frame to ensure the element is rendered and the animation can start. + */ + setAnimationFrame(() => { + setActive(presence); + + /* + * Wait for the next frame to ensure the animation has started. + */ + setAnimationFrame(() => { + const duration = getMotionDuration(currentElement); + + if (duration === 0) { + onFinished(); + return; + } + + /** + * Wait for the animation to finish before updating the state. + * 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(() => onFinished(), duration); + }); + }); + + return () => { + cancelAnimationFrame(); + clearAnimationTimeout(); + }; + }, [ + cancelAnimationFrame, + clearAnimationTimeout, + currentElement, + disableAnimation, + isFirstReactRender, + onFinished, + presence, + setAnimationFrame, + setAnimationTimeout, + ]); + + return React.useMemo>( + () => ({ + ref, + type, + active, + canRender: type !== 'unmounted', + }), + // No need to add ref to the deps array as it is stable + // eslint-disable-next-line react-hooks/exhaustive-deps + [active, 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?: MotionOptions, +): 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); +} + +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/index.ts b/packages/react-components/react-motion-preview/src/index.ts index cb0ff5c3b541f..dc8bd77378016 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, MotionOptions } 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 0000000000000..faa0aef71fa58 --- /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/tsconfig.base.all.json b/tsconfig.base.all.json index b3fd6ec094480..257989ebd92fd 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"] } } }