From 1faa4c8b64f9c08a5fd20a81c96fb264e39b2976 Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Tue, 1 Aug 2023 14:15:17 +0200 Subject: [PATCH 01/33] feat: implement useMotionPresence hook --- .../react-utilities/src/hooks/index.ts | 1 + .../src/hooks/useMotionPresence.test.ts | 247 +++++++++++++ .../src/hooks/useMotionPresence.ts | 345 ++++++++++++++++++ .../react-utilities/src/index.ts | 9 +- 4 files changed, 601 insertions(+), 1 deletion(-) create mode 100644 packages/react-components/react-utilities/src/hooks/useMotionPresence.test.ts create mode 100644 packages/react-components/react-utilities/src/hooks/useMotionPresence.ts diff --git a/packages/react-components/react-utilities/src/hooks/index.ts b/packages/react-components/react-utilities/src/hooks/index.ts index e5928f400aa78..c43fadc10fe58 100644 --- a/packages/react-components/react-utilities/src/hooks/index.ts +++ b/packages/react-components/react-utilities/src/hooks/index.ts @@ -10,3 +10,4 @@ export * from './useOnScrollOutside'; export * from './usePrevious'; export * from './useScrollbarWidth'; export * from './useTimeout'; +export * from './useMotionPresence'; diff --git a/packages/react-components/react-utilities/src/hooks/useMotionPresence.test.ts b/packages/react-components/react-utilities/src/hooks/useMotionPresence.test.ts new file mode 100644 index 0000000000000..f018b6c8ae867 --- /dev/null +++ b/packages/react-components/react-utilities/src/hooks/useMotionPresence.test.ts @@ -0,0 +1,247 @@ +import { act, renderHook } from '@testing-library/react-hooks'; + +import { useMotionPresence, UseMotionPresenceOptions } from './useMotionPresence'; + +const defaultDuration = 100; +const renderHookWithRef = ( + initialPresence: boolean, + initialOptions?: UseMotionPresenceOptions, + style: Record = { 'transition-duration': `${defaultDuration}ms` }, +) => { + const refEl = document.createElement('div'); + const hook = renderHook(({ presence, options }) => useMotionPresence(presence, options), { + initialProps: { + presence: initialPresence, + options: initialOptions, + } as { + presence: boolean; + options?: UseMotionPresenceOptions; + }, + }); + + Object.entries(style).forEach(([key, value]) => value && refEl.style.setProperty(key, value)); + + act(() => hook.result.current.ref(refEl)); + + return hook; +}; + +describe('useMotionPresence', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + 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.motionState).toBe('unmounted'); + expect(result.current.shouldRender).toBe(false); + expect(result.current.visible).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.motionState).toBe('resting'); + expect(result.current.shouldRender).toBe(true); + expect(result.current.visible).toBe(true); + }); + + it('should not change values after timeout ', () => { + const { result } = renderHookWithRef(true); + + const assertSameValues = () => { + expect(typeof result.current.ref).toBe('function'); + expect(result.current.motionState).toBe('resting'); + expect(result.current.shouldRender).toBe(true); + expect(result.current.visible).toBe(true); + }; + + assertSameValues(); + act(() => jest.advanceTimersToNextTimer()); + act(() => jest.advanceTimersToNextTimer()); + assertSameValues(); + }); + + 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.motionState).toBe('resting'); + expect(result.current.shouldRender).toBe(true); + expect(result.current.visible).toBe(false); + + act(() => jest.advanceTimersToNextTimer()); + + expect(result.current.visible).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.motionState).toBe('unmounted'); + expect(result.current.shouldRender).toBe(false); + expect(result.current.visible).toBe(false); + + rerender({ presence: true }); + + expect(result.current.shouldRender).toBe(true); + expect(result.current.motionState).toBe('resting'); + + // double requestAnimationFrame + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.visible).toBe(true); + + rerender({ presence: false }); + + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.motionState).toBe('exiting'); + expect(result.current.visible).toBe(false); + + act(() => { + // requestAnimationFrame + act(() => jest.advanceTimersToNextTimer()); + + // timeout + jest.advanceTimersByTime(defaultDuration + 1); + }); + + expect(result.current.motionState).toBe('unmounted'); + expect(result.current.shouldRender).toBe(false); + expect(result.current.visible).toBe(false); + }); + + it('should toggle values starting with true', () => { + const { result, rerender } = renderHookWithRef(true); + + expect(typeof result.current.ref).toBe('function'); + expect(result.current.motionState).toBe('resting'); + expect(result.current.shouldRender).toBe(true); + expect(result.current.visible).toBe(true); + + rerender({ presence: false }); + + // requestAnimationFrame + act(() => jest.advanceTimersToNextTimer()); + + expect(result.current.motionState).toBe('exiting'); + expect(result.current.visible).toBe(false); + + act(() => { + // requestAnimationFrame + jest.advanceTimersToNextTimer(); + + // timeout + jest.advanceTimersByTime(defaultDuration + 1); + }); + + expect(result.current.motionState).toBe('unmounted'); + expect(result.current.shouldRender).toBe(false); + expect(result.current.visible).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 when animateOnFirstMount is true', () => { + const { result, rerender } = renderHookWithRef( + false, + { + animateOnFirstMount: true, + }, + styles, + ); + + expect(typeof result.current.ref).toBe('function'); + expect(result.current.motionState).toBe('unmounted'); + expect(result.current.shouldRender).toBe(false); + expect(result.current.visible).toBe(false); + + rerender({ presence: true }); + + expect(result.current.shouldRender).toBe(true); + expect(result.current.motionState).toBe('resting'); + // requestAnimationFrame + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.visible).toBe(true); + expect(result.current.motionState).toBe('entering'); + // timeout + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.motionState).toBe('resting'); + + rerender({ presence: false }); + + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.visible).toBe(false); + expect(result.current.motionState).toBe('exiting'); + + // requestAnimationFrame + act(() => jest.advanceTimersToNextTimer()); + // timeout + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.motionState).toBe('unmounted'); + expect(result.current.shouldRender).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, + { + animateOnFirstMount: true, + }, + styles, + ); + + expect(typeof result.current.ref).toBe('function'); + expect(result.current.motionState).toBe('unmounted'); + expect(result.current.shouldRender).toBe(false); + expect(result.current.visible).toBe(false); + + rerender({ presence: true }); + + expect(result.current.shouldRender).toBe(true); + expect(result.current.motionState).toBe('resting'); + // requestAnimationFrame + act(() => jest.advanceTimersToNextTimer()); + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.visible).toBe(true); + // timeout + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.motionState).toBe('resting'); + + rerender({ presence: false }); + + act(() => jest.advanceTimersToNextTimer()); + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.visible).toBe(false); + + // requestAnimationFrame + act(() => jest.advanceTimersToNextTimer()); + // timeout + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.motionState).toBe('unmounted'); + expect(result.current.shouldRender).toBe(false); + }); + }); +}); diff --git a/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts b/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts new file mode 100644 index 0000000000000..886d48961f69a --- /dev/null +++ b/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts @@ -0,0 +1,345 @@ +import * as React from 'react'; +import { useTimeout } from './useTimeout'; + +/** + * 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 + */ +interface CSSUnitValue { + value: number; + readonly unit: string; +} + +/** + * Style property map read only. + * @see https://developer.mozilla.org/en-US/docs/Web/API/StylePropertyMapReadOnly + */ +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 + */ +type HTMLElementWithStyledMap = TElement & { + computedStyleMap(): StylePropertyMapReadOnly; +}; + +interface CSSWithNumber { + number(value: number): { + value: number; + readonly unit: string; + }; +} + +/** + * State for useMotionPresence hook. + */ +export type UseMotionPresenceState = { + /** + * Ref to the element. + */ + ref: React.RefCallback; + + /** + * Whether the element should be rendered in the DOM. + * This should be used to conditionally render the element. + */ + shouldRender: boolean; + + /** + * Whether the element is currently visible in the DOM. + */ + visible: boolean; + + /** + * Current state of the element. + * + * - `entering` - The element is entering the DOM. + * - `exiting` - The element is exiting the DOM. + * - `resting` - The element is currently not animating. This is the final and initial state of the element. + * - `unmounted` - The element is not rendered in the DOM. + */ + motionState: 'entering' | 'exiting' | 'resting' | 'unmounted'; +}; + +/** + * Options for useMotionPresence hook. + */ +export type UseMotionPresenceOptions = { + /** + * Whether to animate the element on first mount. + * + * @default false + */ + animateOnFirstMount?: boolean; +}; + +/** + * Returns CSS styles of the given node. + * @param node - DOM node. + * @returns - CSS styles. + */ +const getElementComputedStyle = (node: HTMLElement): CSSStyleDeclaration => { + const window = node.ownerDocument?.defaultView; + + return window!.getComputedStyle(node, null); +}; + +/** + * Converts a CSS duration string to milliseconds. + * + * @param duration - CSS duration string + * @returns Duration in milliseconds + */ +function toMs(duration: string): number { + const trimmed = duration.trim(); + + if (trimmed.includes('auto')) { + return 0; + } + + if (trimmed.includes('ms')) { + return parseFloat(trimmed); + } + + return Number(trimmed.slice(0, -1).replace(',', '.')) * 1000; +} + +/** + * 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 + */ +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 unknown as CSSWithNumber).number && node.computedStyleMap); +}; + +/** + * + * 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. + */ +const getCSSStyle = (node: HTMLElementWithStyledMap): CSSStyleDeclaration | StylePropertyMapReadOnly => { + if (hasCSSOMSupport(node)) { + return node.computedStyleMap(); + } + + return getElementComputedStyle(node); +}; + +/** + * 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 + */ +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 + */ +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 + */ +const getMaxCSSDuration = (durations: string[], delays: string[]): number => { + const totalDurations: number[] = []; + + durations.forEach(duration => totalDurations.push(toMs(duration.trim()))); + + delays.forEach((delay, index) => { + const parsedDelay = toMs(delay.trim()); + + if (totalDurations[index]) { + totalDurations[index] = totalDurations[index] + parsedDelay; + } else { + totalDurations[index] = parsedDelay; + } + }); + + return Math.max(...totalDurations); +}; + +/** + * Gets the motion information for a given element. + * + * @param computedStyle - Computed style of the element + * @returns motion information + */ +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); +}; + +/** + * 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 + */ +export const useMotionPresence = ( + present: boolean, + options: UseMotionPresenceOptions = {}, +): UseMotionPresenceState => { + const { animateOnFirstMount } = { animateOnFirstMount: false, ...options }; + + const [state, setState] = React.useState, 'ref'>>({ + shouldRender: present, + motionState: present ? 'resting' : 'unmounted', + visible: false, + }); + + const [currentElement, setCurrentElement] = React.useState | null>(null); + const [setAnimationTimeout, clearAnimationTimeout] = useTimeout(); + const skipAnimationOnFirstRender = React.useRef(!animateOnFirstMount); + + const processAnimation = React.useCallback( + (callback: () => void) => { + if (!currentElement) { + return; + } + + clearAnimationTimeout(); + const animationFrame = requestAnimationFrame(() => { + const duration = getMotionDuration(currentElement); + + 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(); + cancelAnimationFrame(animationFrame); + }; + }, + [clearAnimationTimeout, currentElement, setAnimationTimeout], + ); + + const ref: React.RefCallback> = React.useCallback(node => { + if (!node) { + return; + } + + setCurrentElement(node); + }, []); + + React.useEffect(() => { + if (present) { + setState({ + shouldRender: true, + visible: skipAnimationOnFirstRender.current ? true : false, + motionState: 'resting', + }); + } + }, [present]); + + React.useEffect(() => { + if (!currentElement) { + return; + } + + let animationFrame: number; + const skipAnimation = skipAnimationOnFirstRender.current; + const onDestroy = () => cancelAnimationFrame(animationFrame); + + animationFrame = requestAnimationFrame(() => { + setState(prevState => { + let motionState = prevState.motionState; + + if (skipAnimation) { + motionState = present ? 'resting' : 'unmounted'; + } else { + motionState = present ? 'entering' : 'exiting'; + } + + return { + ...prevState, + motionState, + visible: present, + }; + }); + }); + + if (skipAnimation) { + return onDestroy; + } + + processAnimation(() => { + setState(prevState => ({ + ...prevState, + motionState: present ? 'resting' : 'unmounted', + shouldRender: present, + })); + }); + + return onDestroy; + }, [currentElement, present, processAnimation]); + + React.useEffect(() => { + skipAnimationOnFirstRender.current = false; + }, []); + + return { + ref, + ...state, + }; +}; diff --git a/packages/react-components/react-utilities/src/index.ts b/packages/react-components/react-utilities/src/index.ts index bb8b54b62d684..256e276efe6a3 100644 --- a/packages/react-components/react-utilities/src/index.ts +++ b/packages/react-components/react-utilities/src/index.ts @@ -36,8 +36,15 @@ export { usePrevious, useScrollbarWidth, useTimeout, + useMotionPresence, +} from './hooks/index'; +export type { + RefObjectFunction, + UseControllableStateOptions, + UseOnClickOrScrollOutsideOptions, + UseMotionPresenceOptions, + UseMotionPresenceState, } from './hooks/index'; -export type { RefObjectFunction, UseControllableStateOptions, UseOnClickOrScrollOutsideOptions } from './hooks/index'; export { canUseDOM, useIsSSR, SSRProvider } from './ssr/index'; From 230e273804e13682ee7d3d4a2a286ccc970ab200 Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Tue, 1 Aug 2023 14:16:49 +0200 Subject: [PATCH 02/33] docs: add missing change files --- ...act-utilities-01c80b27-0a05-464e-abdf-bdddf86d72ac.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@fluentui-react-utilities-01c80b27-0a05-464e-abdf-bdddf86d72ac.json diff --git a/change/@fluentui-react-utilities-01c80b27-0a05-464e-abdf-bdddf86d72ac.json b/change/@fluentui-react-utilities-01c80b27-0a05-464e-abdf-bdddf86d72ac.json new file mode 100644 index 0000000000000..1b3a3e5dee293 --- /dev/null +++ b/change/@fluentui-react-utilities-01c80b27-0a05-464e-abdf-bdddf86d72ac.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: new useMotionPresence hook - get the state for css animations/transitions", + "packageName": "@fluentui/react-utilities", + "email": "marcosvmmoura@gmail.com", + "dependentChangeType": "patch" +} From b8128e74bdd881df0eaf32b58cf3df1b410bb7ba Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Wed, 2 Aug 2023 17:09:53 +0200 Subject: [PATCH 03/33] fix: improve performance of duration calculation --- .../src/hooks/useMotionPresence.ts | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts b/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts index 886d48961f69a..883ce60315e01 100644 --- a/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts +++ b/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts @@ -182,19 +182,19 @@ const getComputedStyleProp = (computedStyle: CSSStyleDeclaration, prop: string): * @returns Maximum duration */ const getMaxCSSDuration = (durations: string[], delays: string[]): number => { - const totalDurations: number[] = []; + const totalProps = Math.max(durations.length, delays.length); + const totalDurations = []; - durations.forEach(duration => totalDurations.push(toMs(duration.trim()))); + if (totalProps === 0) { + return 0; + } - delays.forEach((delay, index) => { - const parsedDelay = toMs(delay.trim()); + for (let i = 0; i < totalProps; i++) { + const duration = toMs(durations[i] || '0'); + const delay = toMs(delays[i] || '0'); - if (totalDurations[index]) { - totalDurations[index] = totalDurations[index] + parsedDelay; - } else { - totalDurations[index] = parsedDelay; - } - }); + totalDurations.push(duration + delay); + } return Math.max(...totalDurations); }; @@ -293,10 +293,6 @@ export const useMotionPresence = ( }, [present]); React.useEffect(() => { - if (!currentElement) { - return; - } - let animationFrame: number; const skipAnimation = skipAnimationOnFirstRender.current; const onDestroy = () => cancelAnimationFrame(animationFrame); @@ -332,7 +328,7 @@ export const useMotionPresence = ( }); return onDestroy; - }, [currentElement, present, processAnimation]); + }, [present, processAnimation]); React.useEffect(() => { skipAnimationOnFirstRender.current = false; From c721171e3da00bea4b4341594df1f04f1bba3339 Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Thu, 3 Aug 2023 14:20:24 +0200 Subject: [PATCH 04/33] feat: implement useAnimationFrame hook to help controlling the motion presence requestAnimationFrame --- .../src/hooks/useAnimationFrame.test.ts | 106 ++++++++++++++++++ .../src/hooks/useAnimationFrame.ts | 13 +++ .../src/hooks/useBrowserTimer.test.ts | 61 ++++++++++ .../src/hooks/useBrowserTimer.ts | 47 ++++++++ .../src/hooks/useMotionPresence.ts | 13 ++- .../react-utilities/src/hooks/useTimeout.ts | 21 +--- 6 files changed, 236 insertions(+), 25 deletions(-) create mode 100644 packages/react-components/react-utilities/src/hooks/useAnimationFrame.test.ts create mode 100644 packages/react-components/react-utilities/src/hooks/useAnimationFrame.ts create mode 100644 packages/react-components/react-utilities/src/hooks/useBrowserTimer.test.ts create mode 100644 packages/react-components/react-utilities/src/hooks/useBrowserTimer.ts 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 0000000000000..307eddec185bc --- /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 0000000000000..de44967dad202 --- /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 0000000000000..26600feb7262b --- /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 0000000000000..5422ca5f79561 --- /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/useMotionPresence.ts b/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts index 883ce60315e01..0eb1654e8bf76 100644 --- a/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts +++ b/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import { useAnimationFrame } from './useAnimationFrame'; import { useTimeout } from './useTimeout'; /** @@ -241,6 +242,7 @@ export const useMotionPresence = ( const [currentElement, setCurrentElement] = React.useState | null>(null); const [setAnimationTimeout, clearAnimationTimeout] = useTimeout(); + const [setProcessingAnimationFrame, cancelProcessingAnimationFrame] = useAnimationFrame(); const skipAnimationOnFirstRender = React.useRef(!animateOnFirstMount); const processAnimation = React.useCallback( @@ -293,11 +295,10 @@ export const useMotionPresence = ( }, [present]); React.useEffect(() => { - let animationFrame: number; const skipAnimation = skipAnimationOnFirstRender.current; - const onDestroy = () => cancelAnimationFrame(animationFrame); + const onUnmount = () => cancelProcessingAnimationFrame(); - animationFrame = requestAnimationFrame(() => { + setProcessingAnimationFrame(() => { setState(prevState => { let motionState = prevState.motionState; @@ -316,7 +317,7 @@ export const useMotionPresence = ( }); if (skipAnimation) { - return onDestroy; + return onUnmount; } processAnimation(() => { @@ -327,8 +328,8 @@ export const useMotionPresence = ( })); }); - return onDestroy; - }, [present, processAnimation]); + return onUnmount; + }, [cancelProcessingAnimationFrame, present, processAnimation, setProcessingAnimationFrame]); React.useEffect(() => { skipAnimationOnFirstRender.current = false; diff --git a/packages/react-components/react-utilities/src/hooks/useTimeout.ts b/packages/react-components/react-utilities/src/hooks/useTimeout.ts index 250c64fdf9b9d..16d5c78499e89 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); } From 6bbf319e70065420c37e728aa6ec9f0628c55367 Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Thu, 3 Aug 2023 14:32:51 +0200 Subject: [PATCH 05/33] fix: improve type definition to avoid typecast --- .../react-utilities/src/hooks/useMotionPresence.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts b/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts index 0eb1654e8bf76..5bb0d2e007441 100644 --- a/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts +++ b/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts @@ -33,12 +33,12 @@ type HTMLElementWithStyledMap = TEle computedStyleMap(): StylePropertyMapReadOnly; }; -interface CSSWithNumber { +type CSSWithNumber = typeof CSS & { number(value: number): { value: number; readonly unit: string; }; -} +}; /** * State for useMotionPresence hook. @@ -127,7 +127,7 @@ const hasCSSOMSupport = (node: HTMLElementWithStyledMap) => { * 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 unknown as CSSWithNumber).number && node.computedStyleMap); + return Boolean(typeof CSS !== 'undefined' && (CSS as CSSWithNumber).number && node.computedStyleMap); }; /** From 44b58d541787338baba1ba2347cfc59ef2e42a3d Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Thu, 3 Aug 2023 14:39:21 +0200 Subject: [PATCH 06/33] fix: return a blank style declaration in case global "window" do not exist --- .../react-utilities/src/hooks/useMotionPresence.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts b/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts index 5bb0d2e007441..7b03952689d66 100644 --- a/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts +++ b/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import { canUseDOM } from '../ssr/canUseDOM'; import { useAnimationFrame } from './useAnimationFrame'; import { useTimeout } from './useTimeout'; @@ -89,9 +90,15 @@ export type UseMotionPresenceOptions = { * @returns - CSS styles. */ const getElementComputedStyle = (node: HTMLElement): CSSStyleDeclaration => { - const window = node.ownerDocument?.defaultView; + const window = node.ownerDocument.defaultView; - return window!.getComputedStyle(node, null); + if (!window || !canUseDOM()) { + return { + getPropertyValue: (_: string) => '', + } as CSSStyleDeclaration; + } + + return window.getComputedStyle(node, null); }; /** From b3124d6161c15eae95df1ee3592280a07c2cc63e Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Thu, 3 Aug 2023 15:18:35 +0200 Subject: [PATCH 07/33] fix: regenerate API --- .../react-utilities/etc/react-utilities.api.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) 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 2def011e5a643..ad9f1090de6ff 100644 --- a/packages/react-components/react-utilities/etc/react-utilities.api.md +++ b/packages/react-components/react-utilities/etc/react-utilities.api.md @@ -282,6 +282,22 @@ export function useIsSSR(): boolean; // @public export function useMergedRefs(...refs: (React_2.Ref | undefined)[]): RefObjectFunction; +// @public +export const useMotionPresence: (present: boolean, options?: UseMotionPresenceOptions) => UseMotionPresenceState; + +// @public +export type UseMotionPresenceOptions = { + animateOnFirstMount?: boolean; +}; + +// @public +export type UseMotionPresenceState = { + ref: React_2.RefCallback; + shouldRender: boolean; + visible: boolean; + motionState: 'entering' | 'exiting' | 'resting' | 'unmounted'; +}; + // @internal (undocumented) export type UseOnClickOrScrollOutsideOptions = { element: Document | undefined; @@ -307,7 +323,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) From caef0bdedefccc75f87b1600e7f29de133b934e5 Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Thu, 3 Aug 2023 15:20:33 +0200 Subject: [PATCH 08/33] fix: regenerate API --- .../react-utilities/etc/react-utilities.api.md | 2 +- .../react-utilities/src/hooks/useMotionPresence.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 ad9f1090de6ff..96e23c5b874c4 100644 --- a/packages/react-components/react-utilities/etc/react-utilities.api.md +++ b/packages/react-components/react-utilities/etc/react-utilities.api.md @@ -292,7 +292,7 @@ export type UseMotionPresenceOptions = { // @public export type UseMotionPresenceState = { - ref: React_2.RefCallback; + ref: React_2.Ref; shouldRender: boolean; visible: boolean; motionState: 'entering' | 'exiting' | 'resting' | 'unmounted'; diff --git a/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts b/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts index 7b03952689d66..a75cbb049ef83 100644 --- a/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts +++ b/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts @@ -48,7 +48,7 @@ export type UseMotionPresenceState = { /** * Ref to the element. */ - ref: React.RefCallback; + ref: React.Ref; /** * Whether the element should be rendered in the DOM. @@ -283,7 +283,7 @@ export const useMotionPresence = ( [clearAnimationTimeout, currentElement, setAnimationTimeout], ); - const ref: React.RefCallback> = React.useCallback(node => { + const ref: React.Ref> = React.useCallback(node => { if (!node) { return; } From 8fa8f4afcba989409d54ed7c99c3866a660d0b7a Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Thu, 3 Aug 2023 17:23:55 +0200 Subject: [PATCH 09/33] revert: use RefCallback instead of Ref --- .../react-utilities/src/hooks/useMotionPresence.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts b/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts index a75cbb049ef83..7b03952689d66 100644 --- a/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts +++ b/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts @@ -48,7 +48,7 @@ export type UseMotionPresenceState = { /** * Ref to the element. */ - ref: React.Ref; + ref: React.RefCallback; /** * Whether the element should be rendered in the DOM. @@ -283,7 +283,7 @@ export const useMotionPresence = ( [clearAnimationTimeout, currentElement, setAnimationTimeout], ); - const ref: React.Ref> = React.useCallback(node => { + const ref: React.RefCallback> = React.useCallback(node => { if (!node) { return; } From 45ac91f46277220d7aaa0a180f6efbf695bd2192 Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Thu, 3 Aug 2023 17:25:17 +0200 Subject: [PATCH 10/33] fix: regenerate API --- .../react-utilities/etc/react-utilities.api.md | 2 +- .../react-utilities/src/hooks/useMotionPresence.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) 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 96e23c5b874c4..ad9f1090de6ff 100644 --- a/packages/react-components/react-utilities/etc/react-utilities.api.md +++ b/packages/react-components/react-utilities/etc/react-utilities.api.md @@ -292,7 +292,7 @@ export type UseMotionPresenceOptions = { // @public export type UseMotionPresenceState = { - ref: React_2.Ref; + ref: React_2.RefCallback; shouldRender: boolean; visible: boolean; motionState: 'entering' | 'exiting' | 'resting' | 'unmounted'; diff --git a/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts b/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts index 7b03952689d66..ccd6668619dde 100644 --- a/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts +++ b/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts @@ -42,6 +42,8 @@ type CSSWithNumber = typeof CSS & { }; /** + * @internal + * * State for useMotionPresence hook. */ export type UseMotionPresenceState = { @@ -73,6 +75,8 @@ export type UseMotionPresenceState = { }; /** + * @internal + * * Options for useMotionPresence hook. */ export type UseMotionPresenceOptions = { @@ -85,6 +89,8 @@ export type UseMotionPresenceOptions = { }; /** + * @internal + * * Returns CSS styles of the given node. * @param node - DOM node. * @returns - CSS styles. From cceb8322baa79200de0ad1ddbd67b4c508d08644 Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Thu, 3 Aug 2023 18:05:49 +0200 Subject: [PATCH 11/33] fix: regenerate API --- .../react-utilities/etc/react-utilities.api.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 ad9f1090de6ff..86a785bca2c92 100644 --- a/packages/react-components/react-utilities/etc/react-utilities.api.md +++ b/packages/react-components/react-utilities/etc/react-utilities.api.md @@ -282,15 +282,18 @@ export function useIsSSR(): boolean; // @public export function useMergedRefs(...refs: (React_2.Ref | undefined)[]): RefObjectFunction; +// Warning: (ae-incompatible-release-tags) The symbol "useMotionPresence" is marked as @public, but its signature references "UseMotionPresenceOptions" which is marked as @internal +// Warning: (ae-incompatible-release-tags) The symbol "useMotionPresence" is marked as @public, but its signature references "UseMotionPresenceState" which is marked as @internal +// // @public export const useMotionPresence: (present: boolean, options?: UseMotionPresenceOptions) => UseMotionPresenceState; -// @public +// @internal export type UseMotionPresenceOptions = { animateOnFirstMount?: boolean; }; -// @public +// @internal export type UseMotionPresenceState = { ref: React_2.RefCallback; shouldRender: boolean; From d3cbdede203b812b60745d57dacf0795334b54fd Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Mon, 7 Aug 2023 18:21:54 +0200 Subject: [PATCH 12/33] feat: simplify API by removing redundant prop --- .../src/hooks/useMotionPresence.test.ts | 53 +++++++------------ .../src/hooks/useMotionPresence.ts | 43 ++++++++------- 2 files changed, 44 insertions(+), 52 deletions(-) diff --git a/packages/react-components/react-utilities/src/hooks/useMotionPresence.test.ts b/packages/react-components/react-utilities/src/hooks/useMotionPresence.test.ts index f018b6c8ae867..6d0ebc0dff909 100644 --- a/packages/react-components/react-utilities/src/hooks/useMotionPresence.test.ts +++ b/packages/react-components/react-utilities/src/hooks/useMotionPresence.test.ts @@ -41,8 +41,7 @@ describe('useMotionPresence', () => { expect(typeof result.current.ref).toBe('function'); expect(result.current.motionState).toBe('unmounted'); - expect(result.current.shouldRender).toBe(false); - expect(result.current.visible).toBe(false); + expect(result.current.active).toBe(false); }); }); @@ -52,8 +51,7 @@ describe('useMotionPresence', () => { expect(typeof result.current.ref).toBe('function'); expect(result.current.motionState).toBe('resting'); - expect(result.current.shouldRender).toBe(true); - expect(result.current.visible).toBe(true); + expect(result.current.active).toBe(true); }); it('should not change values after timeout ', () => { @@ -62,8 +60,7 @@ describe('useMotionPresence', () => { const assertSameValues = () => { expect(typeof result.current.ref).toBe('function'); expect(result.current.motionState).toBe('resting'); - expect(result.current.shouldRender).toBe(true); - expect(result.current.visible).toBe(true); + expect(result.current.active).toBe(true); }; assertSameValues(); @@ -72,17 +69,16 @@ describe('useMotionPresence', () => { assertSameValues(); }); - it('should change visible to true when animateOnFirstMount is true', () => { + it('should change active to true when animateOnFirstMount is true', () => { const { result } = renderHookWithRef(true, { animateOnFirstMount: true }); expect(typeof result.current.ref).toBe('function'); expect(result.current.motionState).toBe('resting'); - expect(result.current.shouldRender).toBe(true); - expect(result.current.visible).toBe(false); + expect(result.current.active).toBe(false); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.visible).toBe(true); + expect(result.current.active).toBe(true); }); }); @@ -92,23 +88,21 @@ describe('useMotionPresence', () => { expect(typeof result.current.ref).toBe('function'); expect(result.current.motionState).toBe('unmounted'); - expect(result.current.shouldRender).toBe(false); - expect(result.current.visible).toBe(false); + expect(result.current.active).toBe(false); rerender({ presence: true }); - expect(result.current.shouldRender).toBe(true); expect(result.current.motionState).toBe('resting'); // double requestAnimationFrame act(() => jest.advanceTimersToNextTimer()); - expect(result.current.visible).toBe(true); + expect(result.current.active).toBe(true); rerender({ presence: false }); act(() => jest.advanceTimersToNextTimer()); expect(result.current.motionState).toBe('exiting'); - expect(result.current.visible).toBe(false); + expect(result.current.active).toBe(false); act(() => { // requestAnimationFrame @@ -119,8 +113,7 @@ describe('useMotionPresence', () => { }); expect(result.current.motionState).toBe('unmounted'); - expect(result.current.shouldRender).toBe(false); - expect(result.current.visible).toBe(false); + expect(result.current.active).toBe(false); }); it('should toggle values starting with true', () => { @@ -128,8 +121,7 @@ describe('useMotionPresence', () => { expect(typeof result.current.ref).toBe('function'); expect(result.current.motionState).toBe('resting'); - expect(result.current.shouldRender).toBe(true); - expect(result.current.visible).toBe(true); + expect(result.current.active).toBe(true); rerender({ presence: false }); @@ -137,7 +129,7 @@ describe('useMotionPresence', () => { act(() => jest.advanceTimersToNextTimer()); expect(result.current.motionState).toBe('exiting'); - expect(result.current.visible).toBe(false); + expect(result.current.active).toBe(false); act(() => { // requestAnimationFrame @@ -148,8 +140,7 @@ describe('useMotionPresence', () => { }); expect(result.current.motionState).toBe('unmounted'); - expect(result.current.shouldRender).toBe(false); - expect(result.current.visible).toBe(false); + expect(result.current.active).toBe(false); }); }); @@ -170,16 +161,14 @@ describe('useMotionPresence', () => { expect(typeof result.current.ref).toBe('function'); expect(result.current.motionState).toBe('unmounted'); - expect(result.current.shouldRender).toBe(false); - expect(result.current.visible).toBe(false); + expect(result.current.active).toBe(false); rerender({ presence: true }); - expect(result.current.shouldRender).toBe(true); expect(result.current.motionState).toBe('resting'); // requestAnimationFrame act(() => jest.advanceTimersToNextTimer()); - expect(result.current.visible).toBe(true); + expect(result.current.active).toBe(true); expect(result.current.motionState).toBe('entering'); // timeout act(() => jest.advanceTimersToNextTimer()); @@ -188,7 +177,7 @@ describe('useMotionPresence', () => { rerender({ presence: false }); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.visible).toBe(false); + expect(result.current.active).toBe(false); expect(result.current.motionState).toBe('exiting'); // requestAnimationFrame @@ -196,7 +185,6 @@ describe('useMotionPresence', () => { // timeout act(() => jest.advanceTimersToNextTimer()); expect(result.current.motionState).toBe('unmounted'); - expect(result.current.shouldRender).toBe(false); }); }); @@ -215,17 +203,15 @@ describe('useMotionPresence', () => { expect(typeof result.current.ref).toBe('function'); expect(result.current.motionState).toBe('unmounted'); - expect(result.current.shouldRender).toBe(false); - expect(result.current.visible).toBe(false); + expect(result.current.active).toBe(false); rerender({ presence: true }); - expect(result.current.shouldRender).toBe(true); expect(result.current.motionState).toBe('resting'); // requestAnimationFrame act(() => jest.advanceTimersToNextTimer()); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.visible).toBe(true); + expect(result.current.active).toBe(true); // timeout act(() => jest.advanceTimersToNextTimer()); expect(result.current.motionState).toBe('resting'); @@ -234,14 +220,13 @@ describe('useMotionPresence', () => { act(() => jest.advanceTimersToNextTimer()); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.visible).toBe(false); + expect(result.current.active).toBe(false); // requestAnimationFrame act(() => jest.advanceTimersToNextTimer()); // timeout act(() => jest.advanceTimersToNextTimer()); expect(result.current.motionState).toBe('unmounted'); - expect(result.current.shouldRender).toBe(false); }); }); }); diff --git a/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts b/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts index ccd6668619dde..335cb013c24f7 100644 --- a/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts +++ b/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts @@ -49,27 +49,37 @@ type CSSWithNumber = typeof CSS & { export type UseMotionPresenceState = { /** * Ref to the element. + * + * @example + * const { ref } = useMotionPresence(isOpen); + * + *
*/ ref: React.RefCallback; /** - * Whether the element should be rendered in the DOM. - * This should be used to conditionally render the element. - */ - shouldRender: boolean; - - /** - * Whether the element is currently visible in the DOM. + * Whether the element is currently active in the DOM. + * Useful to apply CSS transitions only when the element is active. + * + * @example + * const { active, ref } = useMotionPresence(isOpen); + * + *
*/ - visible: boolean; + active: boolean; /** * Current state of the element. * - * - `entering` - The element is entering the DOM. - * - `exiting` - The element is exiting the DOM. - * - `resting` - The element is currently not animating. This is the final and initial state of the element. - * - `unmounted` - The element is not rendered in the DOM. + * - `entering` - The element is performing enter animation. + * - `exiting` - The element is performing exit animation. + * - `resting` - The element is currently not animating, but rendered on screen. + * - `unmounted` - The element is not rendered or can be removed from the DOM. + * + * @example + * const { motionState, ref } = useMotionPresence(isOpen); + * + *
*/ motionState: 'entering' | 'exiting' | 'resting' | 'unmounted'; }; @@ -248,9 +258,8 @@ export const useMotionPresence = ( const { animateOnFirstMount } = { animateOnFirstMount: false, ...options }; const [state, setState] = React.useState, 'ref'>>({ - shouldRender: present, motionState: present ? 'resting' : 'unmounted', - visible: false, + active: false, }); const [currentElement, setCurrentElement] = React.useState | null>(null); @@ -300,8 +309,7 @@ export const useMotionPresence = ( React.useEffect(() => { if (present) { setState({ - shouldRender: true, - visible: skipAnimationOnFirstRender.current ? true : false, + active: skipAnimationOnFirstRender.current ? true : false, motionState: 'resting', }); } @@ -324,7 +332,7 @@ export const useMotionPresence = ( return { ...prevState, motionState, - visible: present, + active: present, }; }); }); @@ -337,7 +345,6 @@ export const useMotionPresence = ( setState(prevState => ({ ...prevState, motionState: present ? 'resting' : 'unmounted', - shouldRender: present, })); }); From 04ee33952459a202057e705982119e6bd9596477 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Tue, 8 Aug 2023 20:05:06 +0200 Subject: [PATCH 13/33] experiment: useMotionPresence with Toast --- .../ToastContainer/ToastContainer.types.ts | 1 + .../ToastContainer/renderToastContainer.tsx | 13 +++++++++- .../ToastContainer/useToastContainer.ts | 26 +++++++++++++++++-- .../stories/Toast/ToasterLimit.stories.tsx | 7 +++-- 4 files changed, 42 insertions(+), 5 deletions(-) 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 0486013951137..1c89e468cc02e 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 64895bbfe1911..3b8471a203028 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 { shouldRender, visible, ref: motionRef, motionState } = useMotionPresence(visibleProp); + + useIsomorphicLayoutEffect(() => { + if (motionState !== 'entering' || !toastRef.current) { + return; + } + + const element = toastRef.current; + element.style.setProperty('--fui-toast-height', `${element.scrollHeight}px`); + }, [motionState]); + + useIsomorphicLayoutEffect(() => { + if (motionState !== 'exiting') { + return; + } + + remove(); + }, [motionState, 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/stories/Toast/ToasterLimit.stories.tsx b/packages/react-components/react-toast/stories/Toast/ToasterLimit.stories.tsx index a277eac6798a5..9847cb68adb10 100644 --- a/packages/react-components/react-toast/stories/Toast/ToasterLimit.stories.tsx +++ b/packages/react-components/react-toast/stories/Toast/ToasterLimit.stories.tsx @@ -10,17 +10,20 @@ import { Toast, } from '@fluentui/react-components'; +let count = 0; + export const ToasterLimit = () => { const toasterId = useId('toaster'); const { dispatchToast } = useToastController(toasterId); - const [limit, setLimit] = React.useState(3); + const [limit, setLimit] = React.useState(1); const notify = () => dispatchToast( - Limited to 3 toasts + Limited to 3 toasts {count++} , { intent: 'success' }, ); + console.log(limit); return ( <> From ab9ff66eaa9cc393a76d4e7c7882121a2157eaa6 Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Thu, 17 Aug 2023 18:06:54 +0200 Subject: [PATCH 14/33] feat: implement useMotion hook instead of useMotionPresence --- .github/CODEOWNERS | 3 +- .../react-motion-preview/.babelrc.json | 4 + .../react-motion-preview/.eslintrc.json | 4 + .../react-motion-preview/.npmignore | 38 ++ .../react-motion-preview/.storybook/main.js | 14 + .../.storybook/preview.js | 7 + .../.storybook/tsconfig.json | 10 + .../react-motion-preview/.swcrc | 30 ++ .../react-motion-preview/LICENSE | 15 + .../react-motion-preview/README.md | 5 + .../config/api-extractor.json | 4 + .../react-motion-preview/config/tests.js | 1 + .../etc/react-motion-preview.api.md | 28 + .../react-motion-preview/jest.config.js | 21 + .../react-motion-preview/just.config.ts | 5 + .../react-motion-preview/package.json | 61 +++ .../react-motion-preview/project.json | 8 + .../react-motion-preview/src/hooks/index.ts | 1 + .../src/hooks/useMotion.test.ts | 237 +++++++++ .../src/hooks/useMotion.ts | 484 ++++++++++++++++++ .../react-motion-preview/src/index.ts | 2 + .../useMotion/UseMotionBestPractices.md | 5 + .../useMotion/UseMotionDefault.stories.tsx | 60 +++ .../stories/useMotion/UseMotionDescription.md | 0 .../stories/useMotion/index.stories.tsx | 15 + .../react-motion-preview/tsconfig.json | 25 + .../react-motion-preview/tsconfig.lib.json | 22 + .../react-motion-preview/tsconfig.spec.json | 17 + tsconfig.base.all.json | 1 + tsconfig.base.json | 1 + 30 files changed, 1127 insertions(+), 1 deletion(-) create mode 100644 packages/react-components/react-motion-preview/.babelrc.json create mode 100644 packages/react-components/react-motion-preview/.eslintrc.json create mode 100644 packages/react-components/react-motion-preview/.npmignore create mode 100644 packages/react-components/react-motion-preview/.storybook/main.js create mode 100644 packages/react-components/react-motion-preview/.storybook/preview.js create mode 100644 packages/react-components/react-motion-preview/.storybook/tsconfig.json create mode 100644 packages/react-components/react-motion-preview/.swcrc create mode 100644 packages/react-components/react-motion-preview/LICENSE create mode 100644 packages/react-components/react-motion-preview/README.md create mode 100644 packages/react-components/react-motion-preview/config/api-extractor.json create mode 100644 packages/react-components/react-motion-preview/config/tests.js create mode 100644 packages/react-components/react-motion-preview/etc/react-motion-preview.api.md create mode 100644 packages/react-components/react-motion-preview/jest.config.js create mode 100644 packages/react-components/react-motion-preview/just.config.ts create mode 100644 packages/react-components/react-motion-preview/package.json create mode 100644 packages/react-components/react-motion-preview/project.json create mode 100644 packages/react-components/react-motion-preview/src/hooks/index.ts create mode 100644 packages/react-components/react-motion-preview/src/hooks/useMotion.test.ts create mode 100644 packages/react-components/react-motion-preview/src/hooks/useMotion.ts create mode 100644 packages/react-components/react-motion-preview/src/index.ts create mode 100644 packages/react-components/react-motion-preview/stories/useMotion/UseMotionBestPractices.md create mode 100644 packages/react-components/react-motion-preview/stories/useMotion/UseMotionDefault.stories.tsx create mode 100644 packages/react-components/react-motion-preview/stories/useMotion/UseMotionDescription.md create mode 100644 packages/react-components/react-motion-preview/stories/useMotion/index.stories.tsx create mode 100644 packages/react-components/react-motion-preview/tsconfig.json create mode 100644 packages/react-components/react-motion-preview/tsconfig.lib.json create mode 100644 packages/react-components/react-motion-preview/tsconfig.spec.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a217cfad365b7..7f96317e797c9 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 # <%= NX-CODEOWNER-PLACEHOLDER %> diff --git a/packages/react-components/react-motion-preview/.babelrc.json b/packages/react-components/react-motion-preview/.babelrc.json new file mode 100644 index 0000000000000..45fb71ca16d2c --- /dev/null +++ b/packages/react-components/react-motion-preview/.babelrc.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../.babelrc-v9.json", + "plugins": ["annotate-pure-calls", "@babel/transform-react-pure-annotations"] +} diff --git a/packages/react-components/react-motion-preview/.eslintrc.json b/packages/react-components/react-motion-preview/.eslintrc.json new file mode 100644 index 0000000000000..ceea884c70dcc --- /dev/null +++ b/packages/react-components/react-motion-preview/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "extends": ["plugin:@fluentui/eslint-plugin/react"], + "root": true +} diff --git a/packages/react-components/react-motion-preview/.npmignore b/packages/react-components/react-motion-preview/.npmignore new file mode 100644 index 0000000000000..a5817be2414de --- /dev/null +++ b/packages/react-components/react-motion-preview/.npmignore @@ -0,0 +1,38 @@ +.storybook/ +.vscode/ +bundle-size/ +config/ +coverage/ +docs/ +etc/ +node_modules/ +src/ +stories/ +dist/types/ +temp/ +__fixtures__ +__mocks__ +__tests__ + +*.api.json +*.log +*.spec.* +*.cy.* +*.test.* +*.yml + +# config files +*config.* +*rc.* +.editorconfig +.eslint* +.git* +.prettierignore +.swcrc +project.json + +# exclude gitignore patterns explicitly +!lib +!lib-commonjs +!lib-amd +!dist/*.d.ts diff --git a/packages/react-components/react-motion-preview/.storybook/main.js b/packages/react-components/react-motion-preview/.storybook/main.js new file mode 100644 index 0000000000000..26536b61b387f --- /dev/null +++ b/packages/react-components/react-motion-preview/.storybook/main.js @@ -0,0 +1,14 @@ +const rootMain = require('../../../../.storybook/main'); + +module.exports = /** @type {Omit} */ ({ + ...rootMain, + stories: [...rootMain.stories, '../stories/**/*.stories.mdx', '../stories/**/index.stories.@(ts|tsx)'], + addons: [...rootMain.addons], + webpackFinal: (config, options) => { + const localConfig = { ...rootMain.webpackFinal(config, options) }; + + // add your own webpack tweaks if needed + + return localConfig; + }, +}); diff --git a/packages/react-components/react-motion-preview/.storybook/preview.js b/packages/react-components/react-motion-preview/.storybook/preview.js new file mode 100644 index 0000000000000..1939500a3d18c --- /dev/null +++ b/packages/react-components/react-motion-preview/.storybook/preview.js @@ -0,0 +1,7 @@ +import * as rootPreview from '../../../../.storybook/preview'; + +/** @type {typeof rootPreview.decorators} */ +export const decorators = [...rootPreview.decorators]; + +/** @type {typeof rootPreview.parameters} */ +export const parameters = { ...rootPreview.parameters }; diff --git a/packages/react-components/react-motion-preview/.storybook/tsconfig.json b/packages/react-components/react-motion-preview/.storybook/tsconfig.json new file mode 100644 index 0000000000000..ea89218a3d916 --- /dev/null +++ b/packages/react-components/react-motion-preview/.storybook/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "", + "allowJs": true, + "checkJs": true, + "types": ["static-assets", "environment", "storybook__addons"] + }, + "include": ["../stories/**/*.stories.ts", "../stories/**/*.stories.tsx", "*.js"] +} diff --git a/packages/react-components/react-motion-preview/.swcrc b/packages/react-components/react-motion-preview/.swcrc new file mode 100644 index 0000000000000..b4ffa86dee306 --- /dev/null +++ b/packages/react-components/react-motion-preview/.swcrc @@ -0,0 +1,30 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "exclude": [ + "/testing", + "/**/*.cy.ts", + "/**/*.cy.tsx", + "/**/*.spec.ts", + "/**/*.spec.tsx", + "/**/*.test.ts", + "/**/*.test.tsx" + ], + "jsc": { + "parser": { + "syntax": "typescript", + "tsx": true, + "decorators": false, + "dynamicImport": false + }, + "externalHelpers": true, + "transform": { + "react": { + "runtime": "classic", + "useSpread": true + } + }, + "target": "es2019" + }, + "minify": false, + "sourceMaps": true +} diff --git a/packages/react-components/react-motion-preview/LICENSE b/packages/react-components/react-motion-preview/LICENSE new file mode 100644 index 0000000000000..6789473dee14c --- /dev/null +++ b/packages/react-components/react-motion-preview/LICENSE @@ -0,0 +1,15 @@ +@fluentui/react-motion-preview + +Copyright (c) Microsoft Corporation + +All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the ""Software""), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED _AS IS_, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Note: Usage of the fonts and icons referenced in Fluent UI React is subject to the terms listed at https://aka.ms/fluentui-assets-license diff --git a/packages/react-components/react-motion-preview/README.md b/packages/react-components/react-motion-preview/README.md new file mode 100644 index 0000000000000..b4d66fbd60296 --- /dev/null +++ b/packages/react-components/react-motion-preview/README.md @@ -0,0 +1,5 @@ +# @fluentui/react-motion-preview + +**React Motion components for [Fluent UI React](https://react.fluentui.dev/)** + +These are not production-ready components and **should never be used in product**. This space is useful for testing new components whose APIs might change before final release. diff --git a/packages/react-components/react-motion-preview/config/api-extractor.json b/packages/react-components/react-motion-preview/config/api-extractor.json new file mode 100644 index 0000000000000..e533bf30b48a2 --- /dev/null +++ b/packages/react-components/react-motion-preview/config/api-extractor.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "@fluentui/scripts-api-extractor/api-extractor.common.v-next.json" +} diff --git a/packages/react-components/react-motion-preview/config/tests.js b/packages/react-components/react-motion-preview/config/tests.js new file mode 100644 index 0000000000000..2e211ae9e2142 --- /dev/null +++ b/packages/react-components/react-motion-preview/config/tests.js @@ -0,0 +1 @@ +/** Jest test setup file. */ 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 new file mode 100644 index 0000000000000..32e3232c24a72 --- /dev/null +++ b/packages/react-components/react-motion-preview/etc/react-motion-preview.api.md @@ -0,0 +1,28 @@ +## API Report File for "@fluentui/react-motion-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import * as React_2 from 'react'; +import { RefObjectFunction } from '@fluentui/react-utilities'; + +// @public (undocumented) +export type MotionOptions = { + animateOnFirstMount?: boolean; +}; + +// @internal +export type MotionProps = { + presence: boolean; + ref?: RefObjectFunction | React_2.RefCallback | React_2.Ref; + active?: boolean; + state?: 'unmounted' | 'entering' | 'entered' | 'idle' | 'exiting' | 'exited'; +}; + +// @internal +export const useMotion: (props: MotionProps, options?: MotionOptions) => Required>; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-motion-preview/jest.config.js b/packages/react-components/react-motion-preview/jest.config.js new file mode 100644 index 0000000000000..bdaab7ff09ee9 --- /dev/null +++ b/packages/react-components/react-motion-preview/jest.config.js @@ -0,0 +1,21 @@ +// @ts-check + +/** + * @type {import('@jest/types').Config.InitialOptions} + */ +module.exports = { + displayName: 'react-motion-preview', + preset: '../../../jest.preset.js', + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.spec.json', + isolatedModules: true, + }, + ], + }, + coverageDirectory: './coverage', + setupFilesAfterEnv: ['./config/tests.js'], + snapshotSerializers: ['@griffel/jest-serializer'], +}; diff --git a/packages/react-components/react-motion-preview/just.config.ts b/packages/react-components/react-motion-preview/just.config.ts new file mode 100644 index 0000000000000..b7b2c9a33bf43 --- /dev/null +++ b/packages/react-components/react-motion-preview/just.config.ts @@ -0,0 +1,5 @@ +import { preset, task } from '@fluentui/scripts-tasks'; + +preset(); + +task('build', 'build:react-components').cached?.(); diff --git a/packages/react-components/react-motion-preview/package.json b/packages/react-components/react-motion-preview/package.json new file mode 100644 index 0000000000000..88e5d44f3897b --- /dev/null +++ b/packages/react-components/react-motion-preview/package.json @@ -0,0 +1,61 @@ +{ + "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", + "typings": "./dist/index.d.ts", + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/microsoft/fluentui" + }, + "license": "MIT", + "scripts": { + "build": "just-scripts build", + "clean": "just-scripts clean", + "generate-api": "just-scripts generate-api", + "lint": "just-scripts lint", + "start": "yarn storybook", + "storybook": "start-storybook", + "test": "jest --passWithNoTests", + "test-ssr": "test-ssr \"./stories/**/*.stories.tsx\"", + "type-check": "tsc -b tsconfig.json" + }, + "devDependencies": { + "@fluentui/eslint-plugin": "*", + "@fluentui/react-conformance": "*", + "@fluentui/react-conformance-griffel": "*", + "@fluentui/scripts-api-extractor": "*", + "@fluentui/scripts-tasks": "*" + }, + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.0.0-alpha.13", + "@fluentui/react-theme": "^9.1.10", + "@fluentui/react-utilities": "^9.11.0", + "@griffel/react": "^1.5.7", + "@swc/helpers": "^0.4.14" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <19.0.0", + "@types/react-dom": ">=16.8.0 <19.0.0", + "react": ">=16.8.0 <19.0.0", + "react-dom": ">=16.8.0 <19.0.0" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "node": "./lib-commonjs/index.js", + "import": "./lib/index.js", + "require": "./lib-commonjs/index.js" + }, + "./package.json": "./package.json" + }, + "beachball": { + "disallowedChangeTypes": [ + "major", + "prerelease" + ] + } +} diff --git a/packages/react-components/react-motion-preview/project.json b/packages/react-components/react-motion-preview/project.json new file mode 100644 index 0000000000000..b47a10e7e354f --- /dev/null +++ b/packages/react-components/react-motion-preview/project.json @@ -0,0 +1,8 @@ +{ + "name": "@fluentui/react-motion-preview", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "packages/react-components/react-motion-preview/src", + "tags": ["platform:web", "vNext"], + "implicitDependencies": [] +} 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..ecbef73a23c80 --- /dev/null +++ b/packages/react-components/react-motion-preview/src/hooks/useMotion.test.ts @@ -0,0 +1,237 @@ +import { act, renderHook } from '@testing-library/react-hooks'; + +import { useMotion, MotionProps, MotionOptions } from './useMotion'; + +const defaultDuration = 100; +const renderHookWithRef = ( + initialState: MotionProps, + initialOptions?: MotionOptions, + style: Record = { 'transition-duration': `${defaultDuration}ms` }, +) => { + const refEl = document.createElement('div'); + const hook = renderHook(({ state, options }) => useMotion(state, options), { + initialProps: { + state: initialState, + options: initialOptions, + } as { state: MotionProps; options?: MotionOptions }, + }); + + 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(); + }); + + describe('when presence is false by default', () => { + it('should return default values when presence is false', () => { + const { result } = renderHookWithRef({ presence: false }); + + expect(typeof result.current.ref).toBe('function'); + expect(result.current.state).toBe('unmounted'); + expect(result.current.active).toBe(false); + }); + }); + + describe('when presence is true by default', () => { + it('should return default values', () => { + const { result } = renderHookWithRef({ presence: 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({ presence: 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({ presence: false }); + + expect(typeof result.current.ref).toBe('function'); + expect(result.current.state).toBe('unmounted'); + expect(result.current.active).toBe(false); + + rerender({ state: { presence: true } }); + + act(() => jest.advanceTimersToNextTimer()); + + expect(result.current.state).toBe('entering'); + expect(result.current.active).toBe(true); + + act(() => jest.advanceTimersByTime(defaultDuration + 1)); + expect(result.current.state).toBe('entered'); + + act(() => jest.advanceTimersToNextTimer()); + + expect(result.current.state).toBe('idle'); + + rerender({ state: { presence: false } }); + + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.state).toBe('exiting'); + expect(result.current.active).toBe(false); + + act(() => jest.advanceTimersByTime(defaultDuration + 1)); + expect(result.current.state).toBe('exited'); + + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.state).toBe('unmounted'); + expect(result.current.active).toBe(false); + }); + + it('should toggle values starting with true', () => { + const { result, rerender } = renderHookWithRef({ presence: true }); + + expect(typeof result.current.ref).toBe('function'); + expect(result.current.active).toBe(true); + + rerender({ state: { presence: false } }); + + act(() => jest.advanceTimersToNextTimer()); + + expect(result.current.state).toBe('exiting'); + expect(result.current.active).toBe(false); + + act(() => jest.advanceTimersByTime(defaultDuration + 1)); + expect(result.current.state).toBe('exited'); + + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.state).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({ presence: false }, {}, styles); + + expect(typeof result.current.ref).toBe('function'); + expect(result.current.state).toBe('unmounted'); + expect(result.current.active).toBe(false); + + rerender({ state: { presence: true } }); + + act(() => jest.advanceTimersToNextTimer()); + + expect(result.current.state).toBe('entering'); + expect(result.current.active).toBe(true); + + act(() => jest.advanceTimersToNextTimer()); + + expect(result.current.state).toBe('entered'); + + act(() => jest.advanceTimersToNextTimer()); + + expect(result.current.state).toBe('idle'); + + rerender({ state: { presence: false } }); + + act(() => jest.advanceTimersToNextTimer()); + + expect(result.current.state).toBe('exiting'); + expect(result.current.active).toBe(false); + + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.state).toBe('exited'); + + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.state).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({ presence: false }, {}, styles); + + expect(typeof result.current.ref).toBe('function'); + expect(result.current.state).toBe('unmounted'); + expect(result.current.active).toBe(false); + + rerender({ state: { presence: true } }); + + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.state).toBe('entered'); + + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.state).toBe('idle'); + + // requestAnimationFrame + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.active).toBe(true); + // timeout + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.state).toBe('idle'); + + rerender({ state: { presence: false } }); + + act(() => jest.advanceTimersToNextTimer()); + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.active).toBe(false); + + // requestAnimationFrame + act(() => jest.advanceTimersToNextTimer()); + // timeout + act(() => jest.advanceTimersToNextTimer()); + expect(result.current.state).toBe('unmounted'); + }); + }); + + describe('when motion is received', () => { + it('should return default values when presence is false', () => { + const { result } = renderHookWithRef({ presence: false, state: 'unmounted', active: false }); + + expect(typeof result.current.ref).toBe('function'); + expect(result.current.state).toBe('unmounted'); + expect(result.current.presence).toBe(false); + expect(result.current.active).toBe(false); + }); + + it('should return default values when presence is true', () => { + const { result } = renderHookWithRef({ presence: true, state: 'idle', active: true }); + + expect(typeof result.current.ref).toBe('function'); + expect(result.current.presence).toBe(true); + expect(result.current.active).toBe(true); + }); + }); +}); 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..a42087363f9de --- /dev/null +++ b/packages/react-components/react-motion-preview/src/hooks/useMotion.ts @@ -0,0 +1,484 @@ +import * as React from 'react'; + +import { + canUseDOM, + RefObjectFunction, + useAnimationFrame, + useMergedRefs, + usePrevious, + useTimeout, +} 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 + */ +interface CSSUnitValue { + value: number; + readonly unit: string; +} + +/** + * Style property map read only. + * @see https://developer.mozilla.org/en-US/docs/Web/API/StylePropertyMapReadOnly + */ +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 + */ +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 + */ +type CSSWithNumber = typeof CSS & { + number(value: number): { + value: number; + readonly unit: string; + }; +}; + +/** + * @internal + * + * Motion props. + */ +export type MotionProps = { + /** + * Whether the element should be present in the DOM. + * + * @default false + */ + presence: boolean; + + /** + * Ref to the element. + * + * @example + * const motion = useMotion({ presence: isOpen }); + * + *
+ */ + ref?: RefObjectFunction | React.RefCallback | React.Ref; + + /** + * Whether the element is currently active in the DOM. + * Useful to apply CSS transitions only when the element is active. + * + * @example + * const motion = useMotion({ presence: isOpen }); + * + *
+ */ + active?: boolean; + + /** + * 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. + * + * @example + * const motion = useMotion({ presence: isOpen }); + * + *
+ */ + state?: 'unmounted' | 'entering' | 'entered' | 'idle' | 'exiting' | 'exited'; +}; + +export type MotionOptions = { + /** + * Whether to animate the element on first mount. + * + * @default false + */ + animateOnFirstMount?: boolean; +}; + +/** + * @internal + * + * Returns CSS styles of the given node. + * @param node - DOM node. + * @returns - CSS styles. + */ +const getElementComputedStyle = (node: HTMLElement): CSSStyleDeclaration => { + const win = node.ownerDocument?.defaultView ? node.ownerDocument.defaultView : window; + + if (!win || !canUseDOM()) { + 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 + */ +function toMs(duration: string): number { + const trimmed = duration.trim(); + + if (trimmed.includes('auto')) { + return 0; + } + + if (trimmed.includes('ms')) { + return parseFloat(trimmed); + } + + return Number(trimmed.slice(0, -1).replace(',', '.')) * 1000; +} + +/** + * 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 + */ +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); +}; + +/** + * + * 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. + */ +const getCSSStyle = (node: HTMLElementWithStyledMap): CSSStyleDeclaration | StylePropertyMapReadOnly => { + if (hasCSSOMSupport(node)) { + return node.computedStyleMap(); + } + + return getElementComputedStyle(node); +}; + +/** + * 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 + */ +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 + */ +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 + */ +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 + */ +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); +}; + +/** + * @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 + */ +const useMotionPresence = ( + present: boolean, + options: MotionOptions = {}, +): Required, 'presence'>> => { + const { animateOnFirstMount } = { animateOnFirstMount: false, ...options }; + + const [state, setState] = React.useState>>({ + state: present ? 'idle' : 'unmounted', + active: 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 (present) { + setState(prevState => ({ + ...prevState, + state: 'entering', + active: skipAnimationOnFirstRender.current ? true : false, + })); + } + }, [present]); + + React.useEffect(() => { + const skipAnimation = skipAnimationOnFirstRender.current; + const onUnmount = () => { + cancelActiveAnimationFrame(); + cancelDelayedAnimationFrame(); + }; + + setActiveAnimationFrame(() => { + setState(prevState => { + let newState = prevState.state; + + if (skipAnimation) { + newState = present ? 'idle' : 'unmounted'; + } else { + newState = present ? 'entering' : 'exiting'; + } + + return { + state: newState, + active: present, + }; + }); + }); + + if (skipAnimation) { + return onUnmount; + } + + processAnimation(() => { + setState(prevState => ({ + ...prevState, + state: present ? 'entered' : 'exited', + })); + + setDelayedAnimationFrame(() => { + setState(prevState => ({ + ...prevState, + state: present ? 'idle' : 'unmounted', + })); + }); + }); + + return onUnmount; + }, [ + cancelActiveAnimationFrame, + cancelDelayedAnimationFrame, + present, + processAnimation, + setActiveAnimationFrame, + setDelayedAnimationFrame, + ]); + + React.useEffect(() => { + skipAnimationOnFirstRender.current = false; + }, []); + + return { + ref, + ...state, + }; +}; + +/** + * @internal + * + * 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 const useMotion = ( + props: MotionProps, + options?: MotionOptions, +): Required> => { + const { ref, presence, active, state } = props; + const previousProps = usePrevious(props); + const mergedRef = useMergedRefs(ref); + + /** + * 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 (previousProps && Object.keys(props).length !== Object.keys(previousProps).length) { + // eslint-disable-next-line no-console + console.error( + [ + 'useMotion: The hook needs to be called with the same amount of props 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 props on subsequent renders or to use the hook conditionally.', + '\nCurrent props:', + JSON.stringify(props, null, 2), + '\nPrevious props:', + JSON.stringify(previousProps, null, 2), + ].join(' '), + ); + } + }, [props, previousProps]); + } + + if (typeof ref !== 'undefined' && typeof state !== 'undefined') { + const isMounted = state !== 'unmounted'; + + return { + ref: mergedRef, + state, + presence: presence ?? isMounted, + active: active ?? (isMounted && state !== 'exited'), + }; + } + + if (process.env.NODE_ENV !== 'production') { + if (typeof presence === 'undefined') { + throw new Error('useMotion: The hook needs either a `ref` and `state` or `presence` prop to work.'); + } + } + + const isPresent = !!presence; + /** + * Heads up! + * This hook returns a MotionProps but also accepts MotionProps as an argument. + * In case the hook is called with a MotionProps argument, we don't need to perform the expensive computation of the + * motion state and can just return the props 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 + const { ref: motionRef, ...motionPresence } = useMotionPresence(isPresent, options); + + return { + presence: isPresent, + // eslint-disable-next-line react-hooks/rules-of-hooks + ref: useMergedRefs(ref, motionRef as RefObjectFunction), + active: motionPresence.active, + state: motionPresence.state, + }; +}; diff --git a/packages/react-components/react-motion-preview/src/index.ts b/packages/react-components/react-motion-preview/src/index.ts new file mode 100644 index 0000000000000..0a75af31e5294 --- /dev/null +++ b/packages/react-components/react-motion-preview/src/index.ts @@ -0,0 +1,2 @@ +export { useMotion } from './hooks'; +export type { MotionProps, MotionOptions } from './hooks'; diff --git a/packages/react-components/react-motion-preview/stories/useMotion/UseMotionBestPractices.md b/packages/react-components/react-motion-preview/stories/useMotion/UseMotionBestPractices.md new file mode 100644 index 0000000000000..08ff8ddeeb5f8 --- /dev/null +++ b/packages/react-components/react-motion-preview/stories/useMotion/UseMotionBestPractices.md @@ -0,0 +1,5 @@ +## Best practices + +### Do + +### Don't diff --git a/packages/react-components/react-motion-preview/stories/useMotion/UseMotionDefault.stories.tsx b/packages/react-components/react-motion-preview/stories/useMotion/UseMotionDefault.stories.tsx new file mode 100644 index 0000000000000..b6628f71ebd43 --- /dev/null +++ b/packages/react-components/react-motion-preview/stories/useMotion/UseMotionDefault.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 Default = () => { + const styles = useStyles(); + + const [open, setOpen] = React.useState(false); + const motion = useMotion({ + presence: open, + }); + + return ( +
+ + +
+ Lorem ipsum +
+
+ ); +}; diff --git a/packages/react-components/react-motion-preview/stories/useMotion/UseMotionDescription.md b/packages/react-components/react-motion-preview/stories/useMotion/UseMotionDescription.md new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/react-components/react-motion-preview/stories/useMotion/index.stories.tsx b/packages/react-components/react-motion-preview/stories/useMotion/index.stories.tsx new file mode 100644 index 0000000000000..a9cbc03f36c83 --- /dev/null +++ b/packages/react-components/react-motion-preview/stories/useMotion/index.stories.tsx @@ -0,0 +1,15 @@ +import descriptionMd from './UseMotionDescription.md'; +import bestPracticesMd from './UseMotionBestPractices.md'; + +export { Default } from './UseMotionDefault.stories'; + +export default { + title: 'Preview Components/useMotion', + parameters: { + docs: { + description: { + component: [descriptionMd, bestPracticesMd].join('\n'), + }, + }, + }, +}; diff --git a/packages/react-components/react-motion-preview/tsconfig.json b/packages/react-components/react-motion-preview/tsconfig.json new file mode 100644 index 0000000000000..1941a041d46c1 --- /dev/null +++ b/packages/react-components/react-motion-preview/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2019", + "noEmit": true, + "isolatedModules": true, + "importHelpers": true, + "jsx": "react", + "noUnusedLocals": true, + "preserveConstEnums": true + }, + "include": [], + "files": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + }, + { + "path": "./.storybook/tsconfig.json" + } + ] +} diff --git a/packages/react-components/react-motion-preview/tsconfig.lib.json b/packages/react-components/react-motion-preview/tsconfig.lib.json new file mode 100644 index 0000000000000..2de444c2059a5 --- /dev/null +++ b/packages/react-components/react-motion-preview/tsconfig.lib.json @@ -0,0 +1,22 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "lib": ["ES2019", "dom"], + "declaration": true, + "declarationDir": "../../../dist/out-tsc/types", + "outDir": "../../../dist/out-tsc", + "inlineSources": true, + "types": ["static-assets", "environment"] + }, + "exclude": [ + "src/components/AnimatePresence/testing/**", + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.stories.ts", + "**/*.stories.tsx" + ], + "include": ["./src/**/*.ts", "./src/**/*.tsx"] +} diff --git a/packages/react-components/react-motion-preview/tsconfig.spec.json b/packages/react-components/react-motion-preview/tsconfig.spec.json new file mode 100644 index 0000000000000..a7e6c90d91b8e --- /dev/null +++ b/packages/react-components/react-motion-preview/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "dist", + "types": ["jest", "node"] + }, + "include": [ + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.d.ts", + "src/components/AnimatePresence/testing/**/*.ts", + "src/components/AnimatePresence/testing/**/*.tsx" + ] +} diff --git a/tsconfig.base.all.json b/tsconfig.base.all.json index 23cba2e23e074..12123bda7698a 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-overflow": ["packages/react-components/react-overflow/src/index.ts"], "@fluentui/react-persona": ["packages/react-components/react-persona/src/index.ts"], "@fluentui/react-popover": ["packages/react-components/react-popover/src/index.ts"], diff --git a/tsconfig.base.json b/tsconfig.base.json index f68a109eb4c57..a651e6c671d55 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -56,6 +56,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"], From 76d1b821bde991c5d29e425ca11e1b0c9f1ca75b Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Thu, 17 Aug 2023 18:12:29 +0200 Subject: [PATCH 15/33] feat: remove useMotionPresence hook --- .../etc/react-utilities.api.md | 22 +- .../react-utilities/src/hooks/index.ts | 2 +- .../src/hooks/useMotionPresence.test.ts | 232 ----------- .../src/hooks/useMotionPresence.ts | 362 ------------------ .../react-utilities/src/index.ts | 10 +- 5 files changed, 6 insertions(+), 622 deletions(-) delete mode 100644 packages/react-components/react-utilities/src/hooks/useMotionPresence.test.ts delete mode 100644 packages/react-components/react-utilities/src/hooks/useMotionPresence.ts 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 e4af98d9d07e6..832fd9488e27f 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>]; @@ -325,25 +328,6 @@ export function useIsSSR(): boolean; // @public export function useMergedRefs(...refs: (React_2.Ref | undefined)[]): RefObjectFunction; -// Warning: (ae-incompatible-release-tags) The symbol "useMotionPresence" is marked as @public, but its signature references "UseMotionPresenceOptions" which is marked as @internal -// Warning: (ae-incompatible-release-tags) The symbol "useMotionPresence" is marked as @public, but its signature references "UseMotionPresenceState" which is marked as @internal -// -// @public -export const useMotionPresence: (present: boolean, options?: UseMotionPresenceOptions) => UseMotionPresenceState; - -// @internal -export type UseMotionPresenceOptions = { - animateOnFirstMount?: boolean; -}; - -// @internal -export type UseMotionPresenceState = { - ref: React_2.RefCallback; - shouldRender: boolean; - visible: boolean; - motionState: 'entering' | 'exiting' | 'resting' | 'unmounted'; -}; - // @internal (undocumented) export type UseOnClickOrScrollOutsideOptions = { element: Document | undefined; diff --git a/packages/react-components/react-utilities/src/hooks/index.ts b/packages/react-components/react-utilities/src/hooks/index.ts index c43fadc10fe58..487ebcf72f805 100644 --- a/packages/react-components/react-utilities/src/hooks/index.ts +++ b/packages/react-components/react-utilities/src/hooks/index.ts @@ -10,4 +10,4 @@ export * from './useOnScrollOutside'; export * from './usePrevious'; export * from './useScrollbarWidth'; export * from './useTimeout'; -export * from './useMotionPresence'; +export * from './useAnimationFrame'; diff --git a/packages/react-components/react-utilities/src/hooks/useMotionPresence.test.ts b/packages/react-components/react-utilities/src/hooks/useMotionPresence.test.ts deleted file mode 100644 index 6d0ebc0dff909..0000000000000 --- a/packages/react-components/react-utilities/src/hooks/useMotionPresence.test.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { act, renderHook } from '@testing-library/react-hooks'; - -import { useMotionPresence, UseMotionPresenceOptions } from './useMotionPresence'; - -const defaultDuration = 100; -const renderHookWithRef = ( - initialPresence: boolean, - initialOptions?: UseMotionPresenceOptions, - style: Record = { 'transition-duration': `${defaultDuration}ms` }, -) => { - const refEl = document.createElement('div'); - const hook = renderHook(({ presence, options }) => useMotionPresence(presence, options), { - initialProps: { - presence: initialPresence, - options: initialOptions, - } as { - presence: boolean; - options?: UseMotionPresenceOptions; - }, - }); - - Object.entries(style).forEach(([key, value]) => value && refEl.style.setProperty(key, value)); - - act(() => hook.result.current.ref(refEl)); - - return hook; -}; - -describe('useMotionPresence', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - 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.motionState).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.motionState).toBe('resting'); - expect(result.current.active).toBe(true); - }); - - it('should not change values after timeout ', () => { - const { result } = renderHookWithRef(true); - - const assertSameValues = () => { - expect(typeof result.current.ref).toBe('function'); - expect(result.current.motionState).toBe('resting'); - expect(result.current.active).toBe(true); - }; - - assertSameValues(); - act(() => jest.advanceTimersToNextTimer()); - act(() => jest.advanceTimersToNextTimer()); - assertSameValues(); - }); - - it('should change active to true when animateOnFirstMount is true', () => { - const { result } = renderHookWithRef(true, { animateOnFirstMount: true }); - - expect(typeof result.current.ref).toBe('function'); - expect(result.current.motionState).toBe('resting'); - expect(result.current.active).toBe(false); - - 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); - - expect(typeof result.current.ref).toBe('function'); - expect(result.current.motionState).toBe('unmounted'); - expect(result.current.active).toBe(false); - - rerender({ presence: true }); - - expect(result.current.motionState).toBe('resting'); - - // double requestAnimationFrame - act(() => jest.advanceTimersToNextTimer()); - expect(result.current.active).toBe(true); - - rerender({ presence: false }); - - act(() => jest.advanceTimersToNextTimer()); - expect(result.current.motionState).toBe('exiting'); - expect(result.current.active).toBe(false); - - act(() => { - // requestAnimationFrame - act(() => jest.advanceTimersToNextTimer()); - - // timeout - jest.advanceTimersByTime(defaultDuration + 1); - }); - - expect(result.current.motionState).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.motionState).toBe('resting'); - expect(result.current.active).toBe(true); - - rerender({ presence: false }); - - // requestAnimationFrame - act(() => jest.advanceTimersToNextTimer()); - - expect(result.current.motionState).toBe('exiting'); - expect(result.current.active).toBe(false); - - act(() => { - // requestAnimationFrame - jest.advanceTimersToNextTimer(); - - // timeout - jest.advanceTimersByTime(defaultDuration + 1); - }); - - expect(result.current.motionState).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 when animateOnFirstMount is true', () => { - const { result, rerender } = renderHookWithRef( - false, - { - animateOnFirstMount: true, - }, - styles, - ); - - expect(typeof result.current.ref).toBe('function'); - expect(result.current.motionState).toBe('unmounted'); - expect(result.current.active).toBe(false); - - rerender({ presence: true }); - - expect(result.current.motionState).toBe('resting'); - // requestAnimationFrame - act(() => jest.advanceTimersToNextTimer()); - expect(result.current.active).toBe(true); - expect(result.current.motionState).toBe('entering'); - // timeout - act(() => jest.advanceTimersToNextTimer()); - expect(result.current.motionState).toBe('resting'); - - rerender({ presence: false }); - - act(() => jest.advanceTimersToNextTimer()); - expect(result.current.active).toBe(false); - expect(result.current.motionState).toBe('exiting'); - - // requestAnimationFrame - act(() => jest.advanceTimersToNextTimer()); - // timeout - act(() => jest.advanceTimersToNextTimer()); - expect(result.current.motionState).toBe('unmounted'); - }); - }); - - 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, - { - animateOnFirstMount: true, - }, - styles, - ); - - expect(typeof result.current.ref).toBe('function'); - expect(result.current.motionState).toBe('unmounted'); - expect(result.current.active).toBe(false); - - rerender({ presence: true }); - - expect(result.current.motionState).toBe('resting'); - // requestAnimationFrame - act(() => jest.advanceTimersToNextTimer()); - act(() => jest.advanceTimersToNextTimer()); - expect(result.current.active).toBe(true); - // timeout - act(() => jest.advanceTimersToNextTimer()); - expect(result.current.motionState).toBe('resting'); - - rerender({ presence: false }); - - act(() => jest.advanceTimersToNextTimer()); - act(() => jest.advanceTimersToNextTimer()); - expect(result.current.active).toBe(false); - - // requestAnimationFrame - act(() => jest.advanceTimersToNextTimer()); - // timeout - act(() => jest.advanceTimersToNextTimer()); - expect(result.current.motionState).toBe('unmounted'); - }); - }); -}); diff --git a/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts b/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts deleted file mode 100644 index 335cb013c24f7..0000000000000 --- a/packages/react-components/react-utilities/src/hooks/useMotionPresence.ts +++ /dev/null @@ -1,362 +0,0 @@ -import * as React from 'react'; -import { canUseDOM } from '../ssr/canUseDOM'; -import { useAnimationFrame } from './useAnimationFrame'; -import { useTimeout } from './useTimeout'; - -/** - * 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 - */ -interface CSSUnitValue { - value: number; - readonly unit: string; -} - -/** - * Style property map read only. - * @see https://developer.mozilla.org/en-US/docs/Web/API/StylePropertyMapReadOnly - */ -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 - */ -type HTMLElementWithStyledMap = TElement & { - computedStyleMap(): StylePropertyMapReadOnly; -}; - -type CSSWithNumber = typeof CSS & { - number(value: number): { - value: number; - readonly unit: string; - }; -}; - -/** - * @internal - * - * State for useMotionPresence hook. - */ -export type UseMotionPresenceState = { - /** - * Ref to the element. - * - * @example - * const { ref } = useMotionPresence(isOpen); - * - *
- */ - ref: React.RefCallback; - - /** - * Whether the element is currently active in the DOM. - * Useful to apply CSS transitions only when the element is active. - * - * @example - * const { active, ref } = useMotionPresence(isOpen); - * - *
- */ - active: boolean; - - /** - * Current state of the element. - * - * - `entering` - The element is performing enter animation. - * - `exiting` - The element is performing exit animation. - * - `resting` - The element is currently not animating, but rendered on screen. - * - `unmounted` - The element is not rendered or can be removed from the DOM. - * - * @example - * const { motionState, ref } = useMotionPresence(isOpen); - * - *
- */ - motionState: 'entering' | 'exiting' | 'resting' | 'unmounted'; -}; - -/** - * @internal - * - * Options for useMotionPresence hook. - */ -export type UseMotionPresenceOptions = { - /** - * Whether to animate the element on first mount. - * - * @default false - */ - animateOnFirstMount?: boolean; -}; - -/** - * @internal - * - * Returns CSS styles of the given node. - * @param node - DOM node. - * @returns - CSS styles. - */ -const getElementComputedStyle = (node: HTMLElement): CSSStyleDeclaration => { - const window = node.ownerDocument.defaultView; - - if (!window || !canUseDOM()) { - return { - getPropertyValue: (_: string) => '', - } as CSSStyleDeclaration; - } - - return window.getComputedStyle(node, null); -}; - -/** - * Converts a CSS duration string to milliseconds. - * - * @param duration - CSS duration string - * @returns Duration in milliseconds - */ -function toMs(duration: string): number { - const trimmed = duration.trim(); - - if (trimmed.includes('auto')) { - return 0; - } - - if (trimmed.includes('ms')) { - return parseFloat(trimmed); - } - - return Number(trimmed.slice(0, -1).replace(',', '.')) * 1000; -} - -/** - * 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 - */ -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); -}; - -/** - * - * 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. - */ -const getCSSStyle = (node: HTMLElementWithStyledMap): CSSStyleDeclaration | StylePropertyMapReadOnly => { - if (hasCSSOMSupport(node)) { - return node.computedStyleMap(); - } - - return getElementComputedStyle(node); -}; - -/** - * 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 - */ -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 - */ -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 - */ -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 - */ -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); -}; - -/** - * 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 - */ -export const useMotionPresence = ( - present: boolean, - options: UseMotionPresenceOptions = {}, -): UseMotionPresenceState => { - const { animateOnFirstMount } = { animateOnFirstMount: false, ...options }; - - const [state, setState] = React.useState, 'ref'>>({ - motionState: present ? 'resting' : 'unmounted', - active: false, - }); - - const [currentElement, setCurrentElement] = React.useState | null>(null); - const [setAnimationTimeout, clearAnimationTimeout] = useTimeout(); - const [setProcessingAnimationFrame, cancelProcessingAnimationFrame] = useAnimationFrame(); - const skipAnimationOnFirstRender = React.useRef(!animateOnFirstMount); - - const processAnimation = React.useCallback( - (callback: () => void) => { - if (!currentElement) { - return; - } - - clearAnimationTimeout(); - const animationFrame = requestAnimationFrame(() => { - const duration = getMotionDuration(currentElement); - - 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(); - cancelAnimationFrame(animationFrame); - }; - }, - [clearAnimationTimeout, currentElement, setAnimationTimeout], - ); - - const ref: React.RefCallback> = React.useCallback(node => { - if (!node) { - return; - } - - setCurrentElement(node); - }, []); - - React.useEffect(() => { - if (present) { - setState({ - active: skipAnimationOnFirstRender.current ? true : false, - motionState: 'resting', - }); - } - }, [present]); - - React.useEffect(() => { - const skipAnimation = skipAnimationOnFirstRender.current; - const onUnmount = () => cancelProcessingAnimationFrame(); - - setProcessingAnimationFrame(() => { - setState(prevState => { - let motionState = prevState.motionState; - - if (skipAnimation) { - motionState = present ? 'resting' : 'unmounted'; - } else { - motionState = present ? 'entering' : 'exiting'; - } - - return { - ...prevState, - motionState, - active: present, - }; - }); - }); - - if (skipAnimation) { - return onUnmount; - } - - processAnimation(() => { - setState(prevState => ({ - ...prevState, - motionState: present ? 'resting' : 'unmounted', - })); - }); - - return onUnmount; - }, [cancelProcessingAnimationFrame, present, processAnimation, setProcessingAnimationFrame]); - - React.useEffect(() => { - skipAnimationOnFirstRender.current = false; - }, []); - - return { - ref, - ...state, - }; -}; diff --git a/packages/react-components/react-utilities/src/index.ts b/packages/react-components/react-utilities/src/index.ts index 56fe2286e0d15..97fc981ebeb74 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, @@ -42,15 +43,8 @@ export { usePrevious, useScrollbarWidth, useTimeout, - useMotionPresence, -} from './hooks/index'; -export type { - RefObjectFunction, - UseControllableStateOptions, - UseOnClickOrScrollOutsideOptions, - UseMotionPresenceOptions, - UseMotionPresenceState, } from './hooks/index'; +export type { RefObjectFunction, UseControllableStateOptions, UseOnClickOrScrollOutsideOptions } from './hooks/index'; export { canUseDOM, useIsSSR, SSRProvider } from './ssr/index'; From 7fd87f9b458c988f42cde7b0aef9d9f037cc52ba Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Thu, 17 Aug 2023 18:18:06 +0200 Subject: [PATCH 16/33] fix: remove old changefile --- ...act-utilities-01c80b27-0a05-464e-abdf-bdddf86d72ac.json | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 change/@fluentui-react-utilities-01c80b27-0a05-464e-abdf-bdddf86d72ac.json diff --git a/change/@fluentui-react-utilities-01c80b27-0a05-464e-abdf-bdddf86d72ac.json b/change/@fluentui-react-utilities-01c80b27-0a05-464e-abdf-bdddf86d72ac.json deleted file mode 100644 index 1b3a3e5dee293..0000000000000 --- a/change/@fluentui-react-utilities-01c80b27-0a05-464e-abdf-bdddf86d72ac.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "type": "minor", - "comment": "feat: new useMotionPresence hook - get the state for css animations/transitions", - "packageName": "@fluentui/react-utilities", - "email": "marcosvmmoura@gmail.com", - "dependentChangeType": "patch" -} From 1e1674308cb5a78915bf6a4b80b4647986c1fca9 Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Thu, 17 Aug 2023 18:18:33 +0200 Subject: [PATCH 17/33] feat: expose useAnimationFrame hook --- packages/react-components/react-utilities/src/hooks/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-components/react-utilities/src/hooks/index.ts b/packages/react-components/react-utilities/src/hooks/index.ts index 487ebcf72f805..018e521c02bb6 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'; @@ -10,4 +11,3 @@ export * from './useOnScrollOutside'; export * from './usePrevious'; export * from './useScrollbarWidth'; export * from './useTimeout'; -export * from './useAnimationFrame'; From 41e9e8ce1ef213e1e89a77af1095c62f282d143a Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Thu, 17 Aug 2023 18:19:17 +0200 Subject: [PATCH 18/33] fix: add missing changefile --- ...act-utilities-c9616c2d-de84-45eb-bef4-f7509f49cded.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@fluentui-react-utilities-c9616c2d-de84-45eb-bef4-f7509f49cded.json 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 0000000000000..7a3c251867b09 --- /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" +} From f384b82974fd1fc2b03bcb617346d2f1411e2ce3 Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Thu, 17 Aug 2023 18:27:23 +0200 Subject: [PATCH 19/33] fix: mismatch dependencies --- .../react-components/react-motion-preview/package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-components/react-motion-preview/package.json b/packages/react-components/react-motion-preview/package.json index 88e5d44f3897b..a8cee6eb58d21 100644 --- a/packages/react-components/react-motion-preview/package.json +++ b/packages/react-components/react-motion-preview/package.json @@ -31,10 +31,10 @@ "@fluentui/scripts-tasks": "*" }, "dependencies": { - "@fluentui/react-jsx-runtime": "^9.0.0-alpha.13", - "@fluentui/react-theme": "^9.1.10", - "@fluentui/react-utilities": "^9.11.0", - "@griffel/react": "^1.5.7", + "@fluentui/react-jsx-runtime": "^9.0.0", + "@fluentui/react-theme": "^9.1.11", + "@fluentui/react-utilities": "^9.11.1", + "@griffel/react": "^1.5.14", "@swc/helpers": "^0.4.14" }, "peerDependencies": { From ce4d18386e4c36be3b91d6df70da01fa3c5d3917 Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Thu, 17 Aug 2023 18:29:44 +0200 Subject: [PATCH 20/33] feat: add motion docs to public site --- apps/public-docsite-v9/package.json | 1 + .../react-motion-preview/stories/useMotion/index.stories.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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/packages/react-components/react-motion-preview/stories/useMotion/index.stories.tsx b/packages/react-components/react-motion-preview/stories/useMotion/index.stories.tsx index a9cbc03f36c83..9a6e793d138f4 100644 --- a/packages/react-components/react-motion-preview/stories/useMotion/index.stories.tsx +++ b/packages/react-components/react-motion-preview/stories/useMotion/index.stories.tsx @@ -4,7 +4,7 @@ import bestPracticesMd from './UseMotionBestPractices.md'; export { Default } from './UseMotionDefault.stories'; export default { - title: 'Preview Components/useMotion', + title: 'Utilities/Motion/useMotion', parameters: { docs: { description: { From 05ba7e16393fe5c63c6749f1f528f84dd06634bb Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Thu, 17 Aug 2023 18:38:40 +0200 Subject: [PATCH 21/33] docs: add documentatio for the hook --- .../stories/useMotion/UseMotionBestPractices.md | 5 ----- .../stories/useMotion/UseMotionDefault.stories.tsx | 8 +++++--- .../stories/useMotion/UseMotionDescription.md | 1 + .../stories/useMotion/index.stories.tsx | 3 +-- 4 files changed, 7 insertions(+), 10 deletions(-) delete mode 100644 packages/react-components/react-motion-preview/stories/useMotion/UseMotionBestPractices.md diff --git a/packages/react-components/react-motion-preview/stories/useMotion/UseMotionBestPractices.md b/packages/react-components/react-motion-preview/stories/useMotion/UseMotionBestPractices.md deleted file mode 100644 index 08ff8ddeeb5f8..0000000000000 --- a/packages/react-components/react-motion-preview/stories/useMotion/UseMotionBestPractices.md +++ /dev/null @@ -1,5 +0,0 @@ -## Best practices - -### Do - -### Don't diff --git a/packages/react-components/react-motion-preview/stories/useMotion/UseMotionDefault.stories.tsx b/packages/react-components/react-motion-preview/stories/useMotion/UseMotionDefault.stories.tsx index b6628f71ebd43..ede228c503169 100644 --- a/packages/react-components/react-motion-preview/stories/useMotion/UseMotionDefault.stories.tsx +++ b/packages/react-components/react-motion-preview/stories/useMotion/UseMotionDefault.stories.tsx @@ -52,9 +52,11 @@ export const Default = () => { Toggle -
- Lorem ipsum -
+ {motion.state !== 'unmounted' && ( +
+ Lorem ipsum +
+ )}
); }; diff --git a/packages/react-components/react-motion-preview/stories/useMotion/UseMotionDescription.md b/packages/react-components/react-motion-preview/stories/useMotion/UseMotionDescription.md index e69de29bb2d1d..70098da049b45 100644 --- a/packages/react-components/react-motion-preview/stories/useMotion/UseMotionDescription.md +++ b/packages/react-components/react-motion-preview/stories/useMotion/UseMotionDescription.md @@ -0,0 +1 @@ +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. diff --git a/packages/react-components/react-motion-preview/stories/useMotion/index.stories.tsx b/packages/react-components/react-motion-preview/stories/useMotion/index.stories.tsx index 9a6e793d138f4..628a07e28bb59 100644 --- a/packages/react-components/react-motion-preview/stories/useMotion/index.stories.tsx +++ b/packages/react-components/react-motion-preview/stories/useMotion/index.stories.tsx @@ -1,5 +1,4 @@ import descriptionMd from './UseMotionDescription.md'; -import bestPracticesMd from './UseMotionBestPractices.md'; export { Default } from './UseMotionDefault.stories'; @@ -8,7 +7,7 @@ export default { parameters: { docs: { description: { - component: [descriptionMd, bestPracticesMd].join('\n'), + component: [descriptionMd].join('\n'), }, }, }, From e9e8bbebb48e81ce499b809e1bcb831306ed899f Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Mon, 21 Aug 2023 14:04:08 +0200 Subject: [PATCH 22/33] feat: create useMotion hook accepting either a boolean or motion state --- .../etc/react-motion-preview.api.md | 35 +- .../src/hooks/useIsMotion.ts | 43 ++ .../src/hooks/useMotion.test.ts | 140 +++--- .../src/hooks/useMotion.ts | 422 ++++-------------- .../react-motion-preview/src/index.ts | 4 +- .../src/utils/dom-style.ts | 193 ++++++++ 6 files changed, 411 insertions(+), 426 deletions(-) create mode 100644 packages/react-components/react-motion-preview/src/hooks/useIsMotion.ts create mode 100644 packages/react-components/react-motion-preview/src/utils/dom-style.ts 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 32e3232c24a72..ff9f3747df630 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 @@ -5,23 +5,34 @@ ```ts import * as React_2 from 'react'; -import { RefObjectFunction } from '@fluentui/react-utilities'; + +// @public +export function getDefaultMotionState(): MotionState; // @public (undocumented) -export type MotionOptions = { - animateOnFirstMount?: boolean; -}; +export type MotionShorthand = MotionShorthandValue | MotionState; -// @internal -export type MotionProps = { - presence: boolean; - ref?: RefObjectFunction | React_2.RefCallback | React_2.Ref; - active?: boolean; - state?: 'unmounted' | 'entering' | 'entered' | 'idle' | 'exiting' | 'exited'; +// @public (undocumented) +export type MotionShorthandValue = boolean; + +// @public (undocumented) +export type MotionState = { + ref: React_2.Ref; + type: MotionType; + isActive(): boolean; + canRender(): boolean; }; -// @internal -export const useMotion: (props: MotionProps, options?: MotionOptions) => Required>; +// @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/src/hooks/useIsMotion.ts b/packages/react-components/react-motion-preview/src/hooks/useIsMotion.ts new file mode 100644 index 0000000000000..5f2f1204b22d9 --- /dev/null +++ b/packages/react-components/react-motion-preview/src/hooks/useIsMotion.ts @@ -0,0 +1,43 @@ +import * as React from 'react'; +import { usePrevious } from '@fluentui/react-utilities'; +import type { MotionState, MotionShorthand } from './useMotion'; + +/** + * @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:', + JSON.stringify(shorthand, null, 2), + '\nPrevious shorthand:', + JSON.stringify(previousShorthand, null, 2), + ].join(' '), + ); + } + }, [shorthand, previousShorthand]); + } + return typeof shorthand === 'object'; +} 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 ecbef73a23c80..3da78ca040178 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,19 +1,19 @@ import { act, renderHook } from '@testing-library/react-hooks'; -import { useMotion, MotionProps, MotionOptions } from './useMotion'; +import { useMotion, UseMotionOptions, MotionShorthand, getDefaultMotionState } from './useMotion'; const defaultDuration = 100; const renderHookWithRef = ( - initialState: MotionProps, - initialOptions?: MotionOptions, + initialMotion: MotionShorthand, + initialOptions?: UseMotionOptions, 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: MotionProps; options?: MotionOptions }, + } as { motion: MotionShorthand; options?: UseMotionOptions }, }); Object.entries(style).forEach(([key, value]) => value && refEl.style.setProperty(key, value)); @@ -46,89 +46,89 @@ describe('useMotion', () => { describe('when presence is false by default', () => { it('should return default values when presence is false', () => { - const { result } = renderHookWithRef({ presence: false }); + const { result } = renderHookWithRef(false); expect(typeof result.current.ref).toBe('function'); - expect(result.current.state).toBe('unmounted'); - expect(result.current.active).toBe(false); + expect(result.current.type).toBe('unmounted'); + expect(result.current.isActive()).toBe(false); }); }); describe('when presence is true by default', () => { it('should return default values', () => { - const { result } = renderHookWithRef({ presence: true }); + const { result } = renderHookWithRef(true); expect(typeof result.current.ref).toBe('function'); - expect(result.current.active).toBe(true); + expect(result.current.isActive()).toBe(true); }); it('should change visible to true when animateOnFirstMount is true', () => { - const { result } = renderHookWithRef({ presence: true }, { animateOnFirstMount: true }); + const { result } = renderHookWithRef(true, { animateOnFirstMount: true }); expect(typeof result.current.ref).toBe('function'); - expect(result.current.active).toBe(false); + expect(result.current.isActive()).toBe(false); jumpToNextFrame(); - expect(result.current.active).toBe(true); + expect(result.current.isActive()).toBe(true); }); }); describe('when presence changes', () => { it('should toggle values starting with false', () => { - const { result, rerender } = renderHookWithRef({ presence: false }); + const { result, rerender } = renderHookWithRef(false); expect(typeof result.current.ref).toBe('function'); - expect(result.current.state).toBe('unmounted'); - expect(result.current.active).toBe(false); + expect(result.current.type).toBe('unmounted'); + expect(result.current.isActive()).toBe(false); - rerender({ state: { presence: true } }); + rerender({ motion: true }); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.state).toBe('entering'); - expect(result.current.active).toBe(true); + expect(result.current.type).toBe('entering'); + expect(result.current.isActive()).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: { presence: false } }); + rerender({ motion: false }); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.state).toBe('exiting'); - expect(result.current.active).toBe(false); + expect(result.current.type).toBe('exiting'); + expect(result.current.isActive()).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.active).toBe(false); + expect(result.current.type).toBe('unmounted'); + expect(result.current.isActive()).toBe(false); }); it('should toggle values starting with true', () => { - const { result, rerender } = renderHookWithRef({ presence: true }); + const { result, rerender } = renderHookWithRef(true); expect(typeof result.current.ref).toBe('function'); - expect(result.current.active).toBe(true); + expect(result.current.isActive()).toBe(true); - rerender({ state: { presence: false } }); + rerender({ motion: false }); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.state).toBe('exiting'); - expect(result.current.active).toBe(false); + expect(result.current.type).toBe('exiting'); + expect(result.current.isActive()).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.active).toBe(false); + expect(result.current.type).toBe('unmounted'); + expect(result.current.isActive()).toBe(false); }); }); @@ -139,40 +139,40 @@ describe('useMotion', () => { { message: 'with long animation', styles: { 'animation-duration': '1000ms' } }, ])('when presence changes - $message', ({ styles }) => { it('should toggle values starting with false', () => { - const { result, rerender } = renderHookWithRef({ presence: false }, {}, styles); + const { result, rerender } = renderHookWithRef(false, {}, styles); expect(typeof result.current.ref).toBe('function'); - expect(result.current.state).toBe('unmounted'); - expect(result.current.active).toBe(false); + expect(result.current.type).toBe('unmounted'); + expect(result.current.isActive()).toBe(false); - rerender({ state: { presence: true } }); + rerender({ motion: true }); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.state).toBe('entering'); - expect(result.current.active).toBe(true); + expect(result.current.type).toBe('entering'); + expect(result.current.isActive()).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: { presence: false } }); + rerender({ motion: false }); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.state).toBe('exiting'); - expect(result.current.active).toBe(false); + expect(result.current.type).toBe('exiting'); + expect(result.current.isActive()).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.active).toBe(false); + expect(result.current.type).toBe('unmounted'); + expect(result.current.isActive()).toBe(false); }); }); @@ -181,57 +181,57 @@ describe('useMotion', () => { { 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({ presence: false }, {}, styles); + const { result, rerender } = renderHookWithRef(false, {}, styles); expect(typeof result.current.ref).toBe('function'); - expect(result.current.state).toBe('unmounted'); - expect(result.current.active).toBe(false); + expect(result.current.type).toBe('unmounted'); + expect(result.current.isActive()).toBe(false); - rerender({ state: { presence: 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); + expect(result.current.isActive()).toBe(true); // timeout act(() => jest.advanceTimersToNextTimer()); - expect(result.current.state).toBe('idle'); + expect(result.current.type).toBe('idle'); - rerender({ state: { presence: false } }); + rerender({ motion: false }); act(() => jest.advanceTimersToNextTimer()); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.active).toBe(false); + expect(result.current.isActive()).toBe(false); // requestAnimationFrame 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 { result } = renderHookWithRef({ presence: false, state: 'unmounted', active: false }); + const defaultState = getDefaultMotionState(); + const { result } = renderHookWithRef(getDefaultMotionState()); - expect(typeof result.current.ref).toBe('function'); - expect(result.current.state).toBe('unmounted'); - expect(result.current.presence).toBe(false); - expect(result.current.active).toBe(false); + expect(result.current.type).toStrictEqual('unmounted'); + expect(result.current.ref).toStrictEqual(defaultState.ref); + expect(result.current.isActive()).toStrictEqual(false); }); it('should return default values when presence is true', () => { - const { result } = renderHookWithRef({ presence: true, state: 'idle', active: true }); + const defaultState = getDefaultMotionState(); + const { result } = renderHookWithRef({ ...getDefaultMotionState(), isActive: () => true }); - expect(typeof result.current.ref).toBe('function'); - expect(result.current.presence).toBe(true); - expect(result.current.active).toBe(true); + expect(result.current.ref).toStrictEqual(defaultState.ref); + expect(result.current.isActive()).toStrictEqual(true); }); }); }); 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 a42087363f9de..53d5391c2f4ca 100644 --- a/packages/react-components/react-motion-preview/src/hooks/useMotion.ts +++ b/packages/react-components/react-motion-preview/src/hooks/useMotion.ts @@ -1,90 +1,25 @@ import * as React from 'react'; +import { unstable_batchedUpdates } from 'react-dom'; +import { HTMLElementWithStyledMap, getMotionDuration } from '../utils/dom-style'; +import { useAnimationFrame, useTimeout } from '@fluentui/react-utilities'; +import { useIsMotion } from './useIsMotion'; -import { - canUseDOM, - RefObjectFunction, - useAnimationFrame, - useMergedRefs, - usePrevious, - useTimeout, -} 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 - */ -interface CSSUnitValue { - value: number; - readonly unit: string; -} - -/** - * Style property map read only. - * @see https://developer.mozilla.org/en-US/docs/Web/API/StylePropertyMapReadOnly - */ -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 - */ -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 - */ -type CSSWithNumber = typeof CSS & { - number(value: number): { - value: number; - readonly unit: string; - }; -}; - -/** - * @internal - * - * Motion props. - */ -export type MotionProps = { +export type UseMotionOptions = { /** - * Whether the element should be present in the DOM. + * Whether to animate the element on first mount. * * @default false */ - presence: boolean; + animateOnFirstMount?: boolean; +}; - /** - * Ref to the element. - * - * @example - * const motion = useMotion({ presence: isOpen }); - * - *
- */ - ref?: RefObjectFunction | React.RefCallback | React.Ref; +export type MotionType = 'unmounted' | 'entering' | 'entered' | 'idle' | 'exiting' | 'exited'; +export type MotionState = { /** - * Whether the element is currently active in the DOM. - * Useful to apply CSS transitions only when the element is active. - * - * @example - * const motion = useMotion({ presence: isOpen }); - * - *
+ * Ref to the element. */ - active?: boolean; + ref: React.Ref; /** * Current state of the element. @@ -95,170 +30,25 @@ export type MotionProps = { * - `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. - * - * @example - * const motion = useMotion({ presence: isOpen }); - * - *
*/ - state?: 'unmounted' | 'entering' | 'entered' | 'idle' | 'exiting' | 'exited'; -}; + type: MotionType; -export type MotionOptions = { /** - * Whether to animate the element on first mount. - * - * @default false + * Indicates whether the component is currently rendered and visible. + * Useful to apply CSS transitions only when the element is active. */ - animateOnFirstMount?: boolean; -}; - -/** - * @internal - * - * Returns CSS styles of the given node. - * @param node - DOM node. - * @returns - CSS styles. - */ -const getElementComputedStyle = (node: HTMLElement): CSSStyleDeclaration => { - const win = node.ownerDocument?.defaultView ? node.ownerDocument.defaultView : window; - - if (!win || !canUseDOM()) { - return { - getPropertyValue: (_: string) => '', - } as CSSStyleDeclaration; - } + isActive(): boolean; - return win.getComputedStyle(node, null); -}; - -/** - * Converts a CSS duration string to milliseconds. - * - * @param duration - CSS duration string - * @returns Duration in milliseconds - */ -function toMs(duration: string): number { - const trimmed = duration.trim(); - - if (trimmed.includes('auto')) { - return 0; - } - - if (trimmed.includes('ms')) { - return parseFloat(trimmed); - } - - return Number(trimmed.slice(0, -1).replace(',', '.')) * 1000; -} - -/** - * 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 - */ -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 + * Indicates whether the component can be rendered. + * This can be used to avoid rendering the component when it is not visible anymore. */ - return Boolean(typeof CSS !== 'undefined' && (CSS as CSSWithNumber).number && node.computedStyleMap); + canRender(): boolean; }; -/** - * - * 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. - */ -const getCSSStyle = (node: HTMLElementWithStyledMap): CSSStyleDeclaration | StylePropertyMapReadOnly => { - if (hasCSSOMSupport(node)) { - return node.computedStyleMap(); - } +export type MotionShorthandValue = boolean; - return getElementComputedStyle(node); -}; - -/** - * 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 - */ -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 - */ -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 - */ -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 - */ -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); -}; +export type MotionShorthand = MotionShorthandValue | MotionState; /** * @internal @@ -268,18 +58,16 @@ const getMotionDuration = (node: HTMLElementWithStyledMap) => { * @param present - Whether the element should be present in the DOM * @param events - Callbacks for when the element enters or exits the DOM */ -const useMotionPresence = ( - present: boolean, - options: MotionOptions = {}, -): Required, 'presence'>> => { +function useMotionPresence( + presence: boolean, + options: UseMotionOptions = {}, +): MotionState { const { animateOnFirstMount } = { animateOnFirstMount: false, ...options }; - const [state, setState] = React.useState>>({ - state: present ? 'idle' : 'unmounted', - active: false, - }); + const [type, setType] = React.useState(presence ? 'idle' : 'unmounted'); + const [active, setActive] = React.useState(false); - const [currentElement, setCurrentElement] = React.useState | null>(null); + const [currentElement, setCurrentElement] = React.useState | null>(null); const [setAnimationTimeout, clearAnimationTimeout] = useTimeout(); const [setActiveAnimationFrame, cancelActiveAnimationFrame] = useAnimationFrame(); const [setProcessingAnimationFrame, cancelProcessingAnimationFrame] = useAnimationFrame(); @@ -326,7 +114,7 @@ const useMotionPresence = ( ], ); - const ref: React.RefCallback> = React.useCallback(node => { + const ref: React.RefCallback> = React.useCallback(node => { if (!node) { return; } @@ -335,14 +123,13 @@ const useMotionPresence = ( }, []); React.useEffect(() => { - if (present) { - setState(prevState => ({ - ...prevState, - state: 'entering', - active: skipAnimationOnFirstRender.current ? true : false, - })); + if (presence) { + unstable_batchedUpdates(() => { + setType('entering'); + setActive(skipAnimationOnFirstRender.current ? true : false); + }); } - }, [present]); + }, [presence]); React.useEffect(() => { const skipAnimation = skipAnimationOnFirstRender.current; @@ -352,19 +139,15 @@ const useMotionPresence = ( }; setActiveAnimationFrame(() => { - setState(prevState => { - let newState = prevState.state; - - if (skipAnimation) { - newState = present ? 'idle' : 'unmounted'; - } else { - newState = present ? 'entering' : 'exiting'; - } - - return { - state: newState, - active: present, - }; + unstable_batchedUpdates(() => { + setActive(presence); + setType(() => { + if (skipAnimation) { + return presence ? 'idle' : 'unmounted'; + } + + return presence ? 'entering' : 'exiting'; + }); }); }); @@ -373,24 +156,15 @@ const useMotionPresence = ( } processAnimation(() => { - setState(prevState => ({ - ...prevState, - state: present ? 'entered' : 'exited', - })); - - setDelayedAnimationFrame(() => { - setState(prevState => ({ - ...prevState, - state: present ? 'idle' : 'unmounted', - })); - }); + setType(presence ? 'entered' : 'exited'); + setDelayedAnimationFrame(() => setType(presence ? 'idle' : 'unmounted')); }); return onUnmount; }, [ cancelActiveAnimationFrame, cancelDelayedAnimationFrame, - present, + presence, processAnimation, setActiveAnimationFrame, setDelayedAnimationFrame, @@ -400,85 +174,49 @@ const useMotionPresence = ( skipAnimationOnFirstRender.current = false; }, []); + return React.useMemo(() => { + const canRender = () => type !== 'unmounted'; + const isActive = () => active; + + return { + ref, + type, + canRender, + isActive, + hasInternalMotion: true, + }; + }, [active, ref, type]); +} + +/** + * Returns a default motion state. + */ +export function getDefaultMotionState(): MotionState { return { - ref, - ...state, + ref: React.createRef(), + type: 'unmounted', + isActive: () => false, + canRender: () => false, }; -}; +} /** - * @internal - * * 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 const useMotion = ( - props: MotionProps, - options?: MotionOptions, -): Required> => { - const { ref, presence, active, state } = props; - const previousProps = usePrevious(props); - const mergedRef = useMergedRefs(ref); - +export function useMotion( + shorthand: MotionShorthand, + options?: UseMotionOptions, +): MotionState { /** * 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 (previousProps && Object.keys(props).length !== Object.keys(previousProps).length) { - // eslint-disable-next-line no-console - console.error( - [ - 'useMotion: The hook needs to be called with the same amount of props 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 props on subsequent renders or to use the hook conditionally.', - '\nCurrent props:', - JSON.stringify(props, null, 2), - '\nPrevious props:', - JSON.stringify(previousProps, null, 2), - ].join(' '), - ); - } - }, [props, previousProps]); - } - - if (typeof ref !== 'undefined' && typeof state !== 'undefined') { - const isMounted = state !== 'unmounted'; - - return { - ref: mergedRef, - state, - presence: presence ?? isMounted, - active: active ?? (isMounted && state !== 'exited'), - }; - } - - if (process.env.NODE_ENV !== 'production') { - if (typeof presence === 'undefined') { - throw new Error('useMotion: The hook needs either a `ref` and `state` or `presence` prop to work.'); - } - } - - const isPresent = !!presence; - /** - * Heads up! - * This hook returns a MotionProps but also accepts MotionProps as an argument. - * In case the hook is called with a MotionProps argument, we don't need to perform the expensive computation of the - * motion state and can just return the props 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. + * 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 - const { ref: motionRef, ...motionPresence } = useMotionPresence(isPresent, options); - - return { - presence: isPresent, - // eslint-disable-next-line react-hooks/rules-of-hooks - ref: useMergedRefs(ref, motionRef as RefObjectFunction), - active: motionPresence.active, - state: motionPresence.state, - }; -}; + return useIsMotion(shorthand) ? shorthand : useMotionPresence(shorthand, options); +} diff --git a/packages/react-components/react-motion-preview/src/index.ts b/packages/react-components/react-motion-preview/src/index.ts index 0a75af31e5294..b70d2c237cae2 100644 --- a/packages/react-components/react-motion-preview/src/index.ts +++ b/packages/react-components/react-motion-preview/src/index.ts @@ -1,2 +1,2 @@ -export { useMotion } from './hooks'; -export type { MotionProps, MotionOptions } from './hooks'; +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 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); +}; From 3872d0ac2c77d39c7333de14d2d9f14b13cb3b8f Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Mon, 21 Aug 2023 14:16:52 +0200 Subject: [PATCH 23/33] fix: upgrade stories to use latest changes to hook --- .../stories/useMotion/UseMotionDefault.stories.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/react-components/react-motion-preview/stories/useMotion/UseMotionDefault.stories.tsx b/packages/react-components/react-motion-preview/stories/useMotion/UseMotionDefault.stories.tsx index ede228c503169..f53cc2f09184c 100644 --- a/packages/react-components/react-motion-preview/stories/useMotion/UseMotionDefault.stories.tsx +++ b/packages/react-components/react-motion-preview/stories/useMotion/UseMotionDefault.stories.tsx @@ -42,9 +42,7 @@ export const Default = () => { const styles = useStyles(); const [open, setOpen] = React.useState(false); - const motion = useMotion({ - presence: open, - }); + const motion = useMotion(open); return (
@@ -52,8 +50,8 @@ export const Default = () => { Toggle - {motion.state !== 'unmounted' && ( -
+ {motion.canRender() && ( +
Lorem ipsum
)} From 6debd9832e63ab164b948c02ece9718956da9e1a Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Mon, 21 Aug 2023 14:58:44 +0200 Subject: [PATCH 24/33] feat: make package public --- packages/react-components/react-motion-preview/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-components/react-motion-preview/package.json b/packages/react-components/react-motion-preview/package.json index a8cee6eb58d21..a60bd40c1e8ad 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", From 7f27429cd67dbb62ff268ee7b631ff8b593c7ca2 Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Mon, 21 Aug 2023 15:01:22 +0200 Subject: [PATCH 25/33] fix: add missing change file --- ...otion-preview-a0e15534-5b7d-4fb8-a6dd-f28e388add2a.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@fluentui-react-motion-preview-a0e15534-5b7d-4fb8-a6dd-f28e388add2a.json 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" +} From 95930261ae0bb43c54319c0e7933b008e676042e Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Mon, 21 Aug 2023 15:44:49 +0200 Subject: [PATCH 26/33] fix: join two related hooks in one file to be easier to test --- .../src/hooks/useIsMotion.ts | 43 ------------------ .../src/hooks/useMotion.test.ts | 26 ++++++++++- .../src/hooks/useMotion.ts | 45 ++++++++++++++++++- 3 files changed, 68 insertions(+), 46 deletions(-) delete mode 100644 packages/react-components/react-motion-preview/src/hooks/useIsMotion.ts diff --git a/packages/react-components/react-motion-preview/src/hooks/useIsMotion.ts b/packages/react-components/react-motion-preview/src/hooks/useIsMotion.ts deleted file mode 100644 index 5f2f1204b22d9..0000000000000 --- a/packages/react-components/react-motion-preview/src/hooks/useIsMotion.ts +++ /dev/null @@ -1,43 +0,0 @@ -import * as React from 'react'; -import { usePrevious } from '@fluentui/react-utilities'; -import type { MotionState, MotionShorthand } from './useMotion'; - -/** - * @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:', - JSON.stringify(shorthand, null, 2), - '\nPrevious shorthand:', - JSON.stringify(previousShorthand, null, 2), - ].join(' '), - ); - } - }, [shorthand, previousShorthand]); - } - return typeof shorthand === 'object'; -} 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 3da78ca040178..32fb49792d991 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,6 +1,7 @@ import { act, renderHook } from '@testing-library/react-hooks'; +import { fail } from 'assert'; -import { useMotion, UseMotionOptions, MotionShorthand, getDefaultMotionState } from './useMotion'; +import { useMotion, UseMotionOptions, MotionShorthand, getDefaultMotionState, useIsMotion } from './useMotion'; const defaultDuration = 100; const renderHookWithRef = ( @@ -42,6 +43,7 @@ describe('useMotion', () => { afterEach(() => { jest.useRealTimers(); + jest.resetAllMocks(); }); describe('when presence is false by default', () => { @@ -234,4 +236,26 @@ describe('useMotion', () => { expect(result.current.isActive()).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 53d5391c2f4ca..fd40fbca61109 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/dom-style'; -import { useAnimationFrame, useTimeout } from '@fluentui/react-utilities'; -import { useIsMotion } from './useIsMotion'; +import { useAnimationFrame, useTimeout, usePrevious } from '@fluentui/react-utilities'; export type UseMotionOptions = { /** @@ -220,3 +219,45 @@ export function useMotion( // 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'; +} From fd9970f6c29d64527eb15a6afb1f0fdb644b7d69 Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Mon, 21 Aug 2023 16:59:47 +0200 Subject: [PATCH 27/33] fix: remove unused imports --- .../react-motion-preview/src/hooks/useMotion.test.ts | 1 - 1 file changed, 1 deletion(-) 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 32fb49792d991..5150ef6f6d55c 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,5 +1,4 @@ import { act, renderHook } from '@testing-library/react-hooks'; -import { fail } from 'assert'; import { useMotion, UseMotionOptions, MotionShorthand, getDefaultMotionState, useIsMotion } from './useMotion'; From 5cb0a56de1081b31a4c208123678ed88d6d4eb26 Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Mon, 21 Aug 2023 18:18:53 +0200 Subject: [PATCH 28/33] docs: move stories to public site --- .../useMotion/MotionExample.stories.tsx | 2 +- .../Motion/useMotion/index.stories.mdx | 79 +++++++++++++++++++ .../stories/useMotion/UseMotionDescription.md | 1 - .../stories/useMotion/index.stories.tsx | 14 ---- 4 files changed, 80 insertions(+), 16 deletions(-) rename packages/react-components/react-motion-preview/stories/useMotion/UseMotionDefault.stories.tsx => apps/public-docsite-v9/src/Utilities/Motion/useMotion/MotionExample.stories.tsx (97%) create mode 100644 apps/public-docsite-v9/src/Utilities/Motion/useMotion/index.stories.mdx delete mode 100644 packages/react-components/react-motion-preview/stories/useMotion/UseMotionDescription.md delete mode 100644 packages/react-components/react-motion-preview/stories/useMotion/index.stories.tsx diff --git a/packages/react-components/react-motion-preview/stories/useMotion/UseMotionDefault.stories.tsx b/apps/public-docsite-v9/src/Utilities/Motion/useMotion/MotionExample.stories.tsx similarity index 97% rename from packages/react-components/react-motion-preview/stories/useMotion/UseMotionDefault.stories.tsx rename to apps/public-docsite-v9/src/Utilities/Motion/useMotion/MotionExample.stories.tsx index f53cc2f09184c..34c07534817bd 100644 --- a/packages/react-components/react-motion-preview/stories/useMotion/UseMotionDefault.stories.tsx +++ b/apps/public-docsite-v9/src/Utilities/Motion/useMotion/MotionExample.stories.tsx @@ -38,7 +38,7 @@ const useStyles = makeStyles({ }, }); -export const Default = () => { +export const MotionExample = () => { const styles = useStyles(); const [open, setOpen] = React.useState(false); 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..4797461ed3ec2 --- /dev/null +++ b/apps/public-docsite-v9/src/Utilities/Motion/useMotion/index.stories.mdx @@ -0,0 +1,79 @@ +import { Meta, Description } from '@storybook/addon-docs'; + +import { MotionExample } from './MotionExample.stories'; + + + + + 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 +
+ )} +
+ ); +}; +``` + + diff --git a/packages/react-components/react-motion-preview/stories/useMotion/UseMotionDescription.md b/packages/react-components/react-motion-preview/stories/useMotion/UseMotionDescription.md deleted file mode 100644 index 70098da049b45..0000000000000 --- a/packages/react-components/react-motion-preview/stories/useMotion/UseMotionDescription.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/packages/react-components/react-motion-preview/stories/useMotion/index.stories.tsx b/packages/react-components/react-motion-preview/stories/useMotion/index.stories.tsx deleted file mode 100644 index 628a07e28bb59..0000000000000 --- a/packages/react-components/react-motion-preview/stories/useMotion/index.stories.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import descriptionMd from './UseMotionDescription.md'; - -export { Default } from './UseMotionDefault.stories'; - -export default { - title: 'Utilities/Motion/useMotion', - parameters: { - docs: { - description: { - component: [descriptionMd].join('\n'), - }, - }, - }, -}; From 92ecfed7a659690b3727de5d49adb64751941bf3 Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Mon, 21 Aug 2023 19:26:04 +0200 Subject: [PATCH 29/33] docs: add title to example --- .../src/Utilities/Motion/useMotion/index.stories.mdx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 index 4797461ed3ec2..6d2544660a211 100644 --- a/apps/public-docsite-v9/src/Utilities/Motion/useMotion/index.stories.mdx +++ b/apps/public-docsite-v9/src/Utilities/Motion/useMotion/index.stories.mdx @@ -1,7 +1,9 @@ -import { Meta, Description } from '@storybook/addon-docs'; +import { Title, Subtitle, Meta, Description } from '@storybook/addon-docs'; import { MotionExample } from './MotionExample.stories'; +useMotion + @@ -11,7 +13,7 @@ import { MotionExample } from './MotionExample.stories'; will automatically adjust and stay synchronized. -## Usage +Usage ```tsx import * as React from 'react'; @@ -76,4 +78,6 @@ export const MotionExample = () => { }; ``` +Example + From 9086d1ab98b07a2f0e3a655a4175703a9dfe0f8f Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Tue, 22 Aug 2023 15:30:50 +0200 Subject: [PATCH 30/33] fix: remove leftover property --- .../react-components/react-motion-preview/src/hooks/useMotion.ts | 1 - 1 file changed, 1 deletion(-) 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 fd40fbca61109..4bd2449649deb 100644 --- a/packages/react-components/react-motion-preview/src/hooks/useMotion.ts +++ b/packages/react-components/react-motion-preview/src/hooks/useMotion.ts @@ -182,7 +182,6 @@ function useMotionPresence( type, canRender, isActive, - hasInternalMotion: true, }; }, [active, ref, type]); } From 969ce8750cc7dc3f0561b42b219098750fd9d9ad Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Tue, 22 Aug 2023 15:32:14 +0200 Subject: [PATCH 31/33] fix: add type to improve useMemo --- .../react-motion-preview/src/hooks/useMotion.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4bd2449649deb..4015a0d4d0a56 100644 --- a/packages/react-components/react-motion-preview/src/hooks/useMotion.ts +++ b/packages/react-components/react-motion-preview/src/hooks/useMotion.ts @@ -173,7 +173,7 @@ function useMotionPresence( skipAnimationOnFirstRender.current = false; }, []); - return React.useMemo(() => { + return React.useMemo>(() => { const canRender = () => type !== 'unmounted'; const isActive = () => active; From f09ff57059113d43e909cebe45a7f8065349fe5e Mon Sep 17 00:00:00 2001 From: Marcos Moura Date: Tue, 22 Aug 2023 20:32:18 +0200 Subject: [PATCH 32/33] feat: use boolean instead of function --- .../etc/react-motion-preview.api.md | 4 +- .../src/hooks/useMotion.test.ts | 42 +++++++++---------- .../src/hooks/useMotion.ts | 24 +++++------ 3 files changed, 34 insertions(+), 36 deletions(-) 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 ff9f3747df630..c56021c458ed1 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 @@ -19,8 +19,8 @@ export type MotionShorthandValue = boolean; export type MotionState = { ref: React_2.Ref; type: MotionType; - isActive(): boolean; - canRender(): boolean; + active: boolean; + canRender: boolean; }; // @public (undocumented) 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 5150ef6f6d55c..0056bd8fc2fac 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 @@ -51,7 +51,7 @@ describe('useMotion', () => { expect(typeof result.current.ref).toBe('function'); expect(result.current.type).toBe('unmounted'); - expect(result.current.isActive()).toBe(false); + expect(result.current.active).toBe(false); }); }); @@ -60,18 +60,18 @@ describe('useMotion', () => { const { result } = renderHookWithRef(true); expect(typeof result.current.ref).toBe('function'); - expect(result.current.isActive()).toBe(true); + 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.isActive()).toBe(false); + expect(result.current.active).toBe(false); jumpToNextFrame(); - expect(result.current.isActive()).toBe(true); + expect(result.current.active).toBe(true); }); }); @@ -81,14 +81,14 @@ describe('useMotion', () => { expect(typeof result.current.ref).toBe('function'); expect(result.current.type).toBe('unmounted'); - expect(result.current.isActive()).toBe(false); + expect(result.current.active).toBe(false); rerender({ motion: true }); act(() => jest.advanceTimersToNextTimer()); expect(result.current.type).toBe('entering'); - expect(result.current.isActive()).toBe(true); + expect(result.current.active).toBe(true); act(() => jest.advanceTimersByTime(defaultDuration + 1)); expect(result.current.type).toBe('entered'); @@ -101,35 +101,35 @@ describe('useMotion', () => { act(() => jest.advanceTimersToNextTimer()); expect(result.current.type).toBe('exiting'); - expect(result.current.isActive()).toBe(false); + 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.isActive()).toBe(false); + 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.isActive()).toBe(true); + expect(result.current.active).toBe(true); rerender({ motion: false }); act(() => jest.advanceTimersToNextTimer()); expect(result.current.type).toBe('exiting'); - expect(result.current.isActive()).toBe(false); + 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.isActive()).toBe(false); + expect(result.current.active).toBe(false); }); }); @@ -144,14 +144,14 @@ describe('useMotion', () => { expect(typeof result.current.ref).toBe('function'); expect(result.current.type).toBe('unmounted'); - expect(result.current.isActive()).toBe(false); + expect(result.current.active).toBe(false); rerender({ motion: true }); act(() => jest.advanceTimersToNextTimer()); expect(result.current.type).toBe('entering'); - expect(result.current.isActive()).toBe(true); + expect(result.current.active).toBe(true); act(() => jest.advanceTimersToNextTimer()); @@ -166,14 +166,14 @@ describe('useMotion', () => { act(() => jest.advanceTimersToNextTimer()); expect(result.current.type).toBe('exiting'); - expect(result.current.isActive()).toBe(false); + 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.isActive()).toBe(false); + expect(result.current.active).toBe(false); }); }); @@ -186,7 +186,7 @@ describe('useMotion', () => { expect(typeof result.current.ref).toBe('function'); expect(result.current.type).toBe('unmounted'); - expect(result.current.isActive()).toBe(false); + expect(result.current.active).toBe(false); rerender({ motion: true }); @@ -198,7 +198,7 @@ describe('useMotion', () => { // requestAnimationFrame act(() => jest.advanceTimersToNextTimer()); - expect(result.current.isActive()).toBe(true); + expect(result.current.active).toBe(true); // timeout act(() => jest.advanceTimersToNextTimer()); expect(result.current.type).toBe('idle'); @@ -207,7 +207,7 @@ describe('useMotion', () => { act(() => jest.advanceTimersToNextTimer()); act(() => jest.advanceTimersToNextTimer()); - expect(result.current.isActive()).toBe(false); + expect(result.current.active).toBe(false); // requestAnimationFrame act(() => jest.advanceTimersToNextTimer()); @@ -224,15 +224,15 @@ describe('useMotion', () => { expect(result.current.type).toStrictEqual('unmounted'); expect(result.current.ref).toStrictEqual(defaultState.ref); - expect(result.current.isActive()).toStrictEqual(false); + expect(result.current.active).toStrictEqual(false); }); it('should return default values when presence is true', () => { const defaultState = getDefaultMotionState(); - const { result } = renderHookWithRef({ ...getDefaultMotionState(), isActive: () => true }); + const { result } = renderHookWithRef({ ...getDefaultMotionState(), active: true }); expect(result.current.ref).toStrictEqual(defaultState.ref); - expect(result.current.isActive()).toStrictEqual(true); + expect(result.current.active).toStrictEqual(true); }); }); 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 4015a0d4d0a56..7f5dc0c421ccd 100644 --- a/packages/react-components/react-motion-preview/src/hooks/useMotion.ts +++ b/packages/react-components/react-motion-preview/src/hooks/useMotion.ts @@ -36,13 +36,13 @@ export type MotionState = { * Indicates whether the component is currently rendered and visible. * Useful to apply CSS transitions only when the element is active. */ - isActive(): boolean; + 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; + canRender: boolean; }; export type MotionShorthandValue = boolean; @@ -173,17 +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, - }; - }, [active, ref, type]); + canRender: type !== 'unmounted', + active, + }), + [active, ref, type], + ); } /** @@ -193,8 +191,8 @@ export function getDefaultMotionState(): MotionStat return { ref: React.createRef(), type: 'unmounted', - isActive: () => false, - canRender: () => false, + active: false, + canRender: false, }; } From a23dac8cae5b9e441693d07e059c79d2e914e4bf Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Wed, 23 Aug 2023 12:54:53 +0000 Subject: [PATCH 33/33] update --- .../components/ToastContainer/useToastContainer.ts | 12 ++++++------ .../react-toast/src/state/useToaster.ts | 1 + .../stories/Toast/ToasterLimit.stories.tsx | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/react-components/react-toast/src/components/ToastContainer/useToastContainer.ts b/packages/react-components/react-toast/src/components/ToastContainer/useToastContainer.ts index 878b4bf3420e0..5af3725027bb9 100644 --- a/packages/react-components/react-toast/src/components/ToastContainer/useToastContainer.ts +++ b/packages/react-components/react-toast/src/components/ToastContainer/useToastContainer.ts @@ -7,9 +7,9 @@ import { useEventCallback, useId, slot, - useMotionPresence, useIsomorphicLayoutEffect, } from '@fluentui/react-utilities'; +import { useMotion } from '@fluentui/react-motion-preview'; import { useFluent_unstable } from '@fluentui/react-shared-contexts'; import { Delete, Tab } from '@fluentui/keyboard-keys'; import { useFocusableGroup, useFocusFinders } from '@fluentui/react-tabster'; @@ -68,24 +68,24 @@ export const useToastContainer_unstable = ( ignoreDefaultKeydown: { Tab: true, Escape: true, Enter: true }, }); - const { shouldRender, visible, ref: motionRef, motionState } = useMotionPresence(visibleProp); + const { active: visible, canRender: shouldRender, type, ref: motionRef } = useMotion(visibleProp); useIsomorphicLayoutEffect(() => { - if (motionState !== 'entering' || !toastRef.current) { + if (type !== 'entering' || !toastRef.current) { return; } const element = toastRef.current; element.style.setProperty('--fui-toast-height', `${element.scrollHeight}px`); - }, [motionState]); + }, [type]); useIsomorphicLayoutEffect(() => { - if (motionState !== 'exiting') { + if (type !== 'exited') { return; } remove(); - }, [motionState, remove]); + }, [type, remove]); const close = useEventCallback(() => { const activeElement = targetDocument?.activeElement; diff --git a/packages/react-components/react-toast/src/state/useToaster.ts b/packages/react-components/react-toast/src/state/useToaster.ts index ac01976ecb6f6..7ea007059b692 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 9847cb68adb10..9636881c48054 100644 --- a/packages/react-components/react-toast/stories/Toast/ToasterLimit.stories.tsx +++ b/packages/react-components/react-toast/stories/Toast/ToasterLimit.stories.tsx @@ -15,7 +15,7 @@ let count = 0; export const ToasterLimit = () => { const toasterId = useId('toaster'); const { dispatchToast } = useToastController(toasterId); - const [limit, setLimit] = React.useState(1); + const [limit, setLimit] = React.useState(3); const notify = () => dispatchToast(