From 0f56cc96b5cc41ad58353d628a5418fa6217a162 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Thu, 16 Nov 2023 18:16:15 +0100 Subject: [PATCH] feat(react-motions): add createTransition() factory (#29839) * feat(react-motions): add createTransition() factory * use transition definitions * apply review suggestions --- .../etc/react-motions-preview.api.md | 3 + .../src/factories/createTransition.ts | 99 ++++++++++++++ .../factories/createTransitionTest.test.tsx | 122 ++++++++++++++++++ .../react-motions-preview/src/index.ts | 1 + .../stories/CreateAtom/CreateAtom.stories.tsx | 4 +- .../CreateTransition.stories.tsx | 91 +++++++++++++ .../stories/CreateTransition/index.stories.ts | 9 ++ 7 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 packages/react-components/react-motions-preview/src/factories/createTransition.ts create mode 100644 packages/react-components/react-motions-preview/src/factories/createTransitionTest.test.tsx create mode 100644 packages/react-components/react-motions-preview/stories/CreateTransition/CreateTransition.stories.tsx create mode 100644 packages/react-components/react-motions-preview/stories/CreateTransition/index.stories.ts diff --git a/packages/react-components/react-motions-preview/etc/react-motions-preview.api.md b/packages/react-components/react-motions-preview/etc/react-motions-preview.api.md index 986d3aeb0045e5..f973d94a9b83fa 100644 --- a/packages/react-components/react-motions-preview/etc/react-motions-preview.api.md +++ b/packages/react-components/react-motions-preview/etc/react-motions-preview.api.md @@ -18,6 +18,9 @@ export { atoms } // @public export function createAtom(motion: MotionAtom): React_2.FC; +// @public (undocumented) +export function createTransition(transition: MotionTransition): React_2.FC; + // @public (undocumented) const downEnterFast: ({ fromValue }?: SlideParams) => MotionAtom; diff --git a/packages/react-components/react-motions-preview/src/factories/createTransition.ts b/packages/react-components/react-motions-preview/src/factories/createTransition.ts new file mode 100644 index 00000000000000..fd88352d9c6fb2 --- /dev/null +++ b/packages/react-components/react-motions-preview/src/factories/createTransition.ts @@ -0,0 +1,99 @@ +import { useEventCallback, useIsomorphicLayoutEffect, useMergedRefs } from '@fluentui/react-utilities'; +import * as React from 'react'; + +import { useIsReducedMotion } from '../hooks/useIsReducedMotion'; +import type { MotionTransition } from '../types'; + +type TransitionProps = { + children: React.ReactElement; + + appear?: boolean; + visible?: boolean; + + unmountOnExit?: boolean; +}; + +export function createTransition(transition: MotionTransition) { + const Transition: React.FC = props => { + const { appear, children, visible, unmountOnExit } = props; + + const child = React.Children.only(children) as React.ReactElement & { ref: React.Ref }; + + const elementRef = React.useRef(); + const ref = useMergedRefs(elementRef, child.ref); + + const [mounted, setMounted] = React.useState(() => (unmountOnExit ? visible : true)); + + const isFirstMount = React.useRef(true); + const isReducedMotion = useIsReducedMotion(); + + const onExitFinish = useEventCallback(() => { + if (unmountOnExit) { + setMounted(false); + } + }); + + useIsomorphicLayoutEffect(() => { + if (visible) { + setMounted(true); + return; + } + + if (elementRef.current) { + const animation = elementRef.current.animate(transition.exit.keyframes, { + fill: 'forwards', + + ...transition.exit.options, + ...(isReducedMotion() && { duration: 1 }), + }); + + if (isFirstMount.current) { + // Heads up! + // .finish() is used there to skip animation on first mount, but apply animation styles + animation.finish(); + return; + } + + animation.onfinish = onExitFinish; + + return () => { + // TODO: should we set unmount there? + animation.cancel(); + }; + } + }, [isReducedMotion, onExitFinish, visible]); + + useIsomorphicLayoutEffect(() => { + if (!elementRef.current) { + return; + } + + const shouldEnter = isFirstMount.current ? appear && visible : mounted && visible; + + if (shouldEnter) { + const animation = elementRef.current.animate(transition.enter.keyframes, { + fill: 'forwards', + + ...transition.enter.options, + ...(isReducedMotion() && { duration: 1 }), + }); + + return () => { + animation.cancel(); + }; + } + }, [isReducedMotion, mounted, visible, appear]); + + useIsomorphicLayoutEffect(() => { + isFirstMount.current = false; + }, []); + + if (mounted) { + return React.cloneElement(child, { ref }); + } + + return null; + }; + + return Transition; +} diff --git a/packages/react-components/react-motions-preview/src/factories/createTransitionTest.test.tsx b/packages/react-components/react-motions-preview/src/factories/createTransitionTest.test.tsx new file mode 100644 index 00000000000000..2fa1f241b132f8 --- /dev/null +++ b/packages/react-components/react-motions-preview/src/factories/createTransitionTest.test.tsx @@ -0,0 +1,122 @@ +import { render } from '@testing-library/react'; +import * as React from 'react'; + +import type { MotionTransition } from '../types'; +import { createTransition } from './createTransition'; + +const transition: MotionTransition = { + enter: { + keyframes: [{ opacity: 0 }, { opacity: 1 }], + options: { + duration: 500, + fill: 'forwards', + }, + }, + exit: { + keyframes: [{ opacity: 0 }, { opacity: 1 }], + options: { + duration: 500, + fill: 'forwards', + }, + }, +}; + +function createElementMock() { + const animateMock = jest.fn().mockImplementation(() => ({ + cancel: jest.fn(), + set onfinish(callback: Function) { + callback(); + return; + }, + })); + const ElementMock = React.forwardRef((props, ref) => { + React.useImperativeHandle(ref, () => ({ + animate: animateMock, + })); + + return
ElementMock
; + }); + + return { + animateMock, + ElementMock, + }; +} + +describe('createTransition', () => { + describe('appear', () => { + it('does not animate by default', () => { + const TestAtom = createTransition(transition); + const { animateMock, ElementMock } = createElementMock(); + + render( + + + , + ); + + expect(animateMock).not.toHaveBeenCalled(); + }); + + it('animates when is "true"', () => { + const TestAtom = createTransition(transition); + const { animateMock, ElementMock } = createElementMock(); + + render( + + + , + ); + + expect(animateMock).toHaveBeenCalledWith(transition.enter.keyframes, transition.enter.options); + }); + }); + + describe('visible', () => { + it('animates when state changes', () => { + const TestAtom = createTransition(transition); + const { animateMock, ElementMock } = createElementMock(); + + const { rerender } = render( + + + , + ); + + expect(animateMock).not.toHaveBeenCalled(); + + rerender( + + + , + ); + + expect(animateMock).toHaveBeenCalledWith(transition.exit.keyframes, transition.exit.options); + }); + }); + + describe('unmountOnExit', () => { + it('unmounts when state changes', () => { + const TestAtom = createTransition(transition); + const { animateMock, ElementMock } = createElementMock(); + + const { rerender, queryByText } = render( + + + , + ); + + expect(queryByText('ElementMock')).toBeTruthy(); + expect(animateMock).not.toHaveBeenCalled(); + + rerender( + + + , + ); + + expect(queryByText('ElementMock')).toBe(null); + expect(animateMock).toHaveBeenCalledWith(transition.exit.keyframes, transition.exit.options); + }); + }); +}); diff --git a/packages/react-components/react-motions-preview/src/index.ts b/packages/react-components/react-motions-preview/src/index.ts index dfaf78387f1a01..db05ac5a782858 100644 --- a/packages/react-components/react-motions-preview/src/index.ts +++ b/packages/react-components/react-motions-preview/src/index.ts @@ -2,6 +2,7 @@ import * as atoms from './atoms'; import * as transitions from './transitions'; export { createAtom } from './factories/createAtom'; +export { createTransition } from './factories/createTransition'; export { atoms, transitions }; export type { MotionAtom, MotionTransition } from './types'; diff --git a/packages/react-components/react-motions-preview/stories/CreateAtom/CreateAtom.stories.tsx b/packages/react-components/react-motions-preview/stories/CreateAtom/CreateAtom.stories.tsx index 7c2bd4719f172f..7a3e91aa6d56fe 100644 --- a/packages/react-components/react-motions-preview/stories/CreateAtom/CreateAtom.stories.tsx +++ b/packages/react-components/react-motions-preview/stories/CreateAtom/CreateAtom.stories.tsx @@ -50,8 +50,8 @@ const useClasses = makeStyles({ }, }); -const FadeEnter = createAtom(atoms.fade.enterUltraSlow({})); -const FadeExit = createAtom(atoms.fade.exitUltraSlow({})); +const FadeEnter = createAtom(atoms.fade.enterUltraSlow()); +const FadeExit = createAtom(atoms.fade.exitUltraSlow()); export const CreateAtom = () => { const classes = useClasses(); diff --git a/packages/react-components/react-motions-preview/stories/CreateTransition/CreateTransition.stories.tsx b/packages/react-components/react-motions-preview/stories/CreateTransition/CreateTransition.stories.tsx new file mode 100644 index 00000000000000..bb044a3900adb2 --- /dev/null +++ b/packages/react-components/react-motions-preview/stories/CreateTransition/CreateTransition.stories.tsx @@ -0,0 +1,91 @@ +import { makeStyles, shorthands, tokens, Label, Slider, useId, Checkbox } from '@fluentui/react-components'; +import { createTransition, transitions } from '@fluentui/react-motions-preview'; +import * as React from 'react'; + +const useClasses = makeStyles({ + container: { + display: 'grid', + gridTemplateColumns: '1fr', + ...shorthands.gap('10px'), + }, + card: { + display: 'flex', + flexDirection: 'column', + + ...shorthands.border('3px', 'solid', tokens.colorNeutralForeground3), + ...shorthands.borderRadius(tokens.borderRadiusMedium), + ...shorthands.padding('10px'), + + alignItems: 'center', + }, + item: { + backgroundColor: tokens.colorBrandBackground, + ...shorthands.borderRadius('50%'), + + width: '100px', + height: '100px', + }, + description: { + ...shorthands.margin('5px'), + }, + controls: { + display: 'flex', + flexDirection: 'column', + + marginTop: '20px', + + ...shorthands.border('3px', 'solid', tokens.colorNeutralForeground3), + ...shorthands.borderRadius(tokens.borderRadiusMedium), + ...shorthands.padding('10px'), + }, +}); + +const Fade = createTransition(transitions.fade.slow()); + +export const CreateTransition = () => { + const classes = useClasses(); + const sliderId = useId(); + + const [playbackRate, setPlaybackRate] = React.useState(30); + const [visible, setVisible] = React.useState(false); + + React.useEffect(() => { + document.getAnimations().forEach(animation => { + animation.playbackRate = playbackRate / 100; + }); + }, [playbackRate, visible]); + + return ( + <> +
+
+ +
+ + + fadeSlow +
+
+ +
+
+ visible} checked={visible} onChange={() => setVisible(v => !v)} /> +
+
+ + setPlaybackRate(data.value)} + min={0} + id={sliderId} + max={100} + step={10} + /> +
+
+ + ); +}; diff --git a/packages/react-components/react-motions-preview/stories/CreateTransition/index.stories.ts b/packages/react-components/react-motions-preview/stories/CreateTransition/index.stories.ts new file mode 100644 index 00000000000000..a1f77a14411115 --- /dev/null +++ b/packages/react-components/react-motions-preview/stories/CreateTransition/index.stories.ts @@ -0,0 +1,9 @@ +export { CreateTransition as createTransition } from './CreateTransition.stories'; + +export default { + title: 'Utilities/Motions (Preview)', + component: null, + parameters: { + docs: {}, + }, +};