From 1b681fce5c79e8489895dd0a6d50e2844e9ae115 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Tue, 11 Jun 2024 08:49:15 +0200 Subject: [PATCH] feat(react-motion): add support for params (#31566) --- ...-74754f46-cdfd-42ee-8838-8389f353b5c6.json | 7 + .../library/etc/react-motion.api.md | 12 +- .../src/factories/createMotionComponent.ts | 20 ++- .../src/factories/createPresenceComponent.ts | 23 ++- .../react-motion/library/src/types.ts | 16 +- .../MotionFunctionParams.stories.md | 13 ++ .../MotionFunctionParams.stories.tsx | 151 ++++++++++++++++ .../CreateMotionComponent/index.stories.ts | 1 + .../PresenceMotionFunctionParams.stories.md | 21 +++ .../PresenceMotionFunctionParams.stories.tsx | 161 ++++++++++++++++++ .../CreatePresenceComponent/index.stories.ts | 1 + 11 files changed, 405 insertions(+), 21 deletions(-) create mode 100644 change/@fluentui-react-motion-74754f46-cdfd-42ee-8838-8389f353b5c6.json create mode 100644 packages/react-components/react-motion/stories/src/CreateMotionComponent/MotionFunctionParams.stories.md create mode 100644 packages/react-components/react-motion/stories/src/CreateMotionComponent/MotionFunctionParams.stories.tsx create mode 100644 packages/react-components/react-motion/stories/src/CreatePresenceComponent/PresenceMotionFunctionParams.stories.md create mode 100644 packages/react-components/react-motion/stories/src/CreatePresenceComponent/PresenceMotionFunctionParams.stories.tsx diff --git a/change/@fluentui-react-motion-74754f46-cdfd-42ee-8838-8389f353b5c6.json b/change/@fluentui-react-motion-74754f46-cdfd-42ee-8838-8389f353b5c6.json new file mode 100644 index 0000000000000..8769a8a165295 --- /dev/null +++ b/change/@fluentui-react-motion-74754f46-cdfd-42ee-8838-8389f353b5c6.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add support for params", + "packageName": "@fluentui/react-motion", + "email": "olfedias@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-motion/library/etc/react-motion.api.md b/packages/react-components/react-motion/library/etc/react-motion.api.md index 4a352979f9dec..641a8293b1736 100644 --- a/packages/react-components/react-motion/library/etc/react-motion.api.md +++ b/packages/react-components/react-motion/library/etc/react-motion.api.md @@ -12,15 +12,15 @@ export type AtomMotion = { } & KeyframeEffectOptions; // @public (undocumented) -export type AtomMotionFn = (params: { +export type AtomMotionFn = {}> = (params: { element: HTMLElement; -}) => AtomMotion | AtomMotion[]; +} & MotionParams) => AtomMotion | AtomMotion[]; // @public -export function createMotionComponent(value: AtomMotion | AtomMotion[] | AtomMotionFn): React_2.FC; +export function createMotionComponent = {}>(value: AtomMotion | AtomMotion[] | AtomMotionFn): React_2.FC; // @public (undocumented) -export function createPresenceComponent(value: PresenceMotion | PresenceMotionFn): React_2.FC; +export function createPresenceComponent = {}>(value: PresenceMotion | PresenceMotionFn): React_2.FC; // @public (undocumented) export const curves: { @@ -115,9 +115,9 @@ export type PresenceMotion = { }; // @public (undocumented) -export type PresenceMotionFn = (params: { +export type PresenceMotionFn = {}> = (params: { element: HTMLElement; -}) => PresenceMotion; +} & MotionParams) => PresenceMotion; // (No @packageDocumentation comment for this package) diff --git a/packages/react-components/react-motion/library/src/factories/createMotionComponent.ts b/packages/react-components/react-motion/library/src/factories/createMotionComponent.ts index 7c07ccdebfacb..52d3f818859a2 100644 --- a/packages/react-components/react-motion/library/src/factories/createMotionComponent.ts +++ b/packages/react-components/react-motion/library/src/factories/createMotionComponent.ts @@ -5,7 +5,7 @@ import { useIsReducedMotion } from '../hooks/useIsReducedMotion'; import { useMotionImperativeRef } from '../hooks/useMotionImperativeRef'; import { animateAtoms } from '../utils/animateAtoms'; import { getChildElement } from '../utils/getChildElement'; -import type { AtomMotion, AtomMotionFn, MotionImperativeRef } from '../types'; +import type { AtomMotion, AtomMotionFn, MotionParam, MotionImperativeRef } from '../types'; export type MotionComponentProps = { children: React.ReactElement; @@ -19,21 +19,31 @@ export type MotionComponentProps = { * * @param value - A motion definition. */ -export function createMotionComponent(value: AtomMotion | AtomMotion[] | AtomMotionFn) { - const Atom: React.FC = props => { - const { children, imperativeRef } = props; +export function createMotionComponent = {}>( + value: AtomMotion | AtomMotion[] | AtomMotionFn, +) { + const Atom: React.FC = props => { + const { children, imperativeRef, ..._rest } = props; + const params = _rest as Exclude; const child = getChildElement(children); const handleRef = useMotionImperativeRef(imperativeRef); const elementRef = React.useRef(); + const paramsRef = React.useRef(params); const isReducedMotion = useIsReducedMotion(); + useIsomorphicLayoutEffect(() => { + // Heads up! + // We store the params in a ref to avoid re-rendering the component when the params change. + paramsRef.current = params; + }); + useIsomorphicLayoutEffect(() => { const element = elementRef.current; if (element) { - const atoms = typeof value === 'function' ? value({ element }) : value; + const atoms = typeof value === 'function' ? value({ element, ...paramsRef.current }) : value; const handle = animateAtoms(element, atoms, { isReducedMotion: isReducedMotion() }); handleRef.current = handle; diff --git a/packages/react-components/react-motion/library/src/factories/createPresenceComponent.ts b/packages/react-components/react-motion/library/src/factories/createPresenceComponent.ts index f234188d0f39a..b308571d8d178 100644 --- a/packages/react-components/react-motion/library/src/factories/createPresenceComponent.ts +++ b/packages/react-components/react-motion/library/src/factories/createPresenceComponent.ts @@ -7,7 +7,7 @@ import { useMotionImperativeRef } from '../hooks/useMotionImperativeRef'; import { useMountedState } from '../hooks/useMountedState'; import { animateAtoms } from '../utils/animateAtoms'; import { getChildElement } from '../utils/getChildElement'; -import type { PresenceMotion, MotionImperativeRef, PresenceMotionFn } from '../types'; +import type { MotionParam, PresenceMotion, MotionImperativeRef, PresenceMotionFn } from '../types'; export type PresenceComponentProps = { /** @@ -45,10 +45,15 @@ function shouldSkipAnimation(appear: boolean | undefined, isFirstMount: boolean, return !appear && isFirstMount && visible; } -export function createPresenceComponent(value: PresenceMotion | PresenceMotionFn) { - const Presence: React.FC = props => { +export function createPresenceComponent = {}>( + value: PresenceMotion | PresenceMotionFn, +) { + const Presence: React.FC = props => { const itemContext = React.useContext(PresenceGroupChildContext); - const { appear, children, imperativeRef, onMotionFinish, visible, unmountOnExit } = { ...itemContext, ...props }; + const merged = { ...itemContext, ...props }; + + const { appear, children, imperativeRef, onExit, onMotionFinish, visible, unmountOnExit, ..._rest } = merged; + const params = _rest as Exclude; const [mounted, setMounted] = useMountedState(visible, unmountOnExit); const child = getChildElement(children); @@ -56,7 +61,7 @@ export function createPresenceComponent(value: PresenceMotion | PresenceMotionFn const handleRef = useMotionImperativeRef(imperativeRef); const elementRef = React.useRef(); const ref = useMergedRefs(elementRef, child.ref); - const optionsRef = React.useRef<{ appear?: boolean }>({}); + const optionsRef = React.useRef<{ appear?: boolean; params: MotionParams }>({ appear, params }); const isFirstMount = useFirstMount(); const isReducedMotion = useIsReducedMotion(); @@ -69,12 +74,14 @@ export function createPresenceComponent(value: PresenceMotion | PresenceMotionFn if (unmountOnExit) { setMounted(false); - itemContext?.onExit(); + onExit?.(); } }); useIsomorphicLayoutEffect(() => { - optionsRef.current = { appear }; + // Heads up! + // We store the params in a ref to avoid re-rendering the component when the params change. + optionsRef.current = { appear, params }; }); useIsomorphicLayoutEffect( @@ -85,7 +92,7 @@ export function createPresenceComponent(value: PresenceMotion | PresenceMotionFn return; } - const presenceMotion = typeof value === 'function' ? value({ element }) : value; + const presenceMotion = typeof value === 'function' ? value({ element, ...optionsRef.current.params }) : value; const atoms = visible ? presenceMotion.enter : presenceMotion.exit; const handle = animateAtoms(element, atoms, { isReducedMotion: isReducedMotion() }); diff --git a/packages/react-components/react-motion/library/src/types.ts b/packages/react-components/react-motion/library/src/types.ts index 4e8d9135005a2..8651a706cfb47 100644 --- a/packages/react-components/react-motion/library/src/types.ts +++ b/packages/react-components/react-motion/library/src/types.ts @@ -5,8 +5,20 @@ export type PresenceMotion = { exit: AtomMotion | AtomMotion[]; }; -export type AtomMotionFn = (params: { element: HTMLElement }) => AtomMotion | AtomMotion[]; -export type PresenceMotionFn = (params: { element: HTMLElement }) => PresenceMotion; +/** + * @internal + * + * A motion param should be a primitive value that can be serialized to JSON and could be potentially used a plain + * dependency for React hooks. + */ +export type MotionParam = boolean | number | string; + +export type AtomMotionFn = {}> = ( + params: { element: HTMLElement } & MotionParams, +) => AtomMotion | AtomMotion[]; +export type PresenceMotionFn = {}> = ( + params: { element: HTMLElement } & MotionParams, +) => PresenceMotion; // --- diff --git a/packages/react-components/react-motion/stories/src/CreateMotionComponent/MotionFunctionParams.stories.md b/packages/react-components/react-motion/stories/src/CreateMotionComponent/MotionFunctionParams.stories.md new file mode 100644 index 0000000000000..da9b540ab0754 --- /dev/null +++ b/packages/react-components/react-motion/stories/src/CreateMotionComponent/MotionFunctionParams.stories.md @@ -0,0 +1,13 @@ +Atoms definitions can be also defined as functions that accept an animated element as an argument. This allows to define more complex animations that depend on the animated element's properties, for example: + +```ts +const Grow = createMotionComponent(({ element }) => ({ + duration: 300, + keyframes: [ + { opacity: 0, maxHeight: `${element.scrollHeight / 2}px` }, + { opacity: 1, maxHeight: `${element.scrollHeight}px` }, + { opacity: 0, maxHeight: `${element.scrollHeight / 2}px` }, + ], + iterations: Infinity, +})); +``` diff --git a/packages/react-components/react-motion/stories/src/CreateMotionComponent/MotionFunctionParams.stories.tsx b/packages/react-components/react-motion/stories/src/CreateMotionComponent/MotionFunctionParams.stories.tsx new file mode 100644 index 0000000000000..1d85e3f556380 --- /dev/null +++ b/packages/react-components/react-motion/stories/src/CreateMotionComponent/MotionFunctionParams.stories.tsx @@ -0,0 +1,151 @@ +import { + createMotionComponent, + Field, + makeStyles, + mergeClasses, + type MotionImperativeRef, + motionTokens, + Slider, + tokens, +} from '@fluentui/react-components'; +import * as React from 'react'; + +import description from './MotionFunctionParams.stories.md'; + +const useClasses = makeStyles({ + container: { + display: 'grid', + gridTemplate: `"cardA cardB" "controls ." / 1fr 1fr`, + gap: '20px 10px', + }, + card: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'end', + + border: `${tokens.strokeWidthThicker} solid ${tokens.colorNeutralForeground3}`, + borderRadius: tokens.borderRadiusMedium, + boxShadow: tokens.shadow16, + padding: '10px', + }, + controls: { + display: 'flex', + flexDirection: 'column', + gridArea: 'controls', + + border: `${tokens.strokeWidthThicker} solid ${tokens.colorNeutralForeground3}`, + borderRadius: tokens.borderRadiusMedium, + boxShadow: tokens.shadow16, + padding: '10px', + }, + field: { + flex: 1, + }, + sliderField: { + gridTemplateColumns: 'min-content 1fr', + }, + sliderLabel: { + textWrap: 'nowrap', + }, + + cardA: { + gridArea: 'cardA', + }, + cardB: { + gridArea: 'cardB', + }, + item: { + backgroundColor: tokens.colorBrandBackground, + border: `${tokens.strokeWidthThicker} solid ${tokens.colorTransparentStroke}`, + borderRadius: '50%', + + width: '100px', + height: '100px', + }, + description: { + fontFamily: tokens.fontFamilyMonospace, + borderRadius: tokens.borderRadiusMedium, + marginTop: '10px', + padding: '5px 10px', + backgroundColor: tokens.colorNeutralBackground1Pressed, + }, +}); + +const Scale = createMotionComponent<{ startFrom?: number }>(({ startFrom = 0.5 }) => { + return { + keyframes: [ + { opacity: 0, transform: `scale(${startFrom})` }, + { opacity: 1, transform: 'scale(1)' }, + { opacity: 0, transform: `scale(${startFrom})` }, + ], + duration: motionTokens.durationUltraSlow, + iterations: Infinity, + }; +}); + +export const MotionFunctionParams = () => { + const classes = useClasses(); + + const motionBRef = React.useRef(); + const motionARef = React.useRef(); + + const [playbackRate, setPlaybackRate] = React.useState(20); + + // Heads up! + // This is optional and is intended solely to slow down the animations, making motions more visible in the examples. + React.useEffect(() => { + motionARef.current?.setPlaybackRate(playbackRate / 100); + motionBRef.current?.setPlaybackRate(playbackRate / 100); + }, [playbackRate]); + + return ( +
+
+ +
+ +
startFrom=0.1
+
+
+ +
+ +
startFrom=0.8
+
+ +
+ + playbackRate: {playbackRate}% + + ), + className: classes.sliderLabel, + }} + orientation="horizontal" + > + setPlaybackRate(data.value)} + min={0} + max={100} + step={5} + /> + +
+
+ ); +}; + +MotionFunctionParams.parameters = { + docs: { + description: { + story: description, + }, + }, +}; diff --git a/packages/react-components/react-motion/stories/src/CreateMotionComponent/index.stories.ts b/packages/react-components/react-motion/stories/src/CreateMotionComponent/index.stories.ts index 3389d7a56f86f..36fdda6eaafca 100644 --- a/packages/react-components/react-motion/stories/src/CreateMotionComponent/index.stories.ts +++ b/packages/react-components/react-motion/stories/src/CreateMotionComponent/index.stories.ts @@ -9,6 +9,7 @@ export { TokensUsage as tokens } from './TokensUsage.stories'; export { MotionArrays as arrays } from './MotionArrays.stories'; export { MotionFunctions as functions } from './MotionFunctions.stories'; +export { MotionFunctionParams as functionParams } from './MotionFunctionParams.stories'; export default { title: 'Utilities/Web Motions/createMotionComponent', diff --git a/packages/react-components/react-motion/stories/src/CreatePresenceComponent/PresenceMotionFunctionParams.stories.md b/packages/react-components/react-motion/stories/src/CreatePresenceComponent/PresenceMotionFunctionParams.stories.md new file mode 100644 index 0000000000000..47dd6ac06345e --- /dev/null +++ b/packages/react-components/react-motion/stories/src/CreatePresenceComponent/PresenceMotionFunctionParams.stories.md @@ -0,0 +1,21 @@ +Functions in presence definitions also can be used to define motion parameters, this is useful when motion has different variations. + +```tsx +const Scale = createPresenceComponent<{ startFrom?: number }>(({ startFrom = 0.5 }) => { + const keyframes = [ + { opacity: 0, transform: `scale(${startFrom})` }, + { opacity: 1, transform: 'scale(1)' }, + ]; + + return { + enter: { + keyframes, + duration: motionTokens.durationUltraSlow, + }, + exit: { + keyframes: [...keyframes].reverse(), + duration: motionTokens.durationSlow, + }, + }; +}); +``` diff --git a/packages/react-components/react-motion/stories/src/CreatePresenceComponent/PresenceMotionFunctionParams.stories.tsx b/packages/react-components/react-motion/stories/src/CreatePresenceComponent/PresenceMotionFunctionParams.stories.tsx new file mode 100644 index 0000000000000..c52ed2e8fd377 --- /dev/null +++ b/packages/react-components/react-motion/stories/src/CreatePresenceComponent/PresenceMotionFunctionParams.stories.tsx @@ -0,0 +1,161 @@ +import { + createPresenceComponent, + Field, + makeStyles, + mergeClasses, + type MotionImperativeRef, + motionTokens, + Slider, + Switch, + tokens, +} from '@fluentui/react-components'; +import * as React from 'react'; + +import description from './PresenceMotionFunctionParams.stories.md'; + +const useClasses = makeStyles({ + container: { + display: 'grid', + gridTemplate: `"cardA cardB" "controls ." / 1fr 1fr`, + gap: '20px 10px', + }, + card: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'end', + + border: `${tokens.strokeWidthThicker} solid ${tokens.colorNeutralForeground3}`, + borderRadius: tokens.borderRadiusMedium, + boxShadow: tokens.shadow16, + padding: '10px', + }, + controls: { + display: 'flex', + flexDirection: 'column', + gridArea: 'controls', + + border: `${tokens.strokeWidthThicker} solid ${tokens.colorNeutralForeground3}`, + borderRadius: tokens.borderRadiusMedium, + boxShadow: tokens.shadow16, + padding: '10px', + }, + field: { + flex: 1, + }, + sliderField: { + gridTemplateColumns: 'min-content 1fr', + }, + sliderLabel: { + textWrap: 'nowrap', + }, + + cardA: { + gridArea: 'cardA', + }, + cardB: { + gridArea: 'cardB', + }, + item: { + backgroundColor: tokens.colorBrandBackground, + border: `${tokens.strokeWidthThicker} solid ${tokens.colorTransparentStroke}`, + borderRadius: '50%', + + width: '100px', + height: '100px', + }, + description: { + fontFamily: tokens.fontFamilyMonospace, + borderRadius: tokens.borderRadiusMedium, + marginTop: '10px', + padding: '5px 10px', + backgroundColor: tokens.colorNeutralBackground1Pressed, + }, +}); + +const Scale = createPresenceComponent<{ startFrom?: number }>(({ startFrom = 0.5 }) => { + const keyframes = [ + { opacity: 0, transform: `scale(${startFrom})` }, + { opacity: 1, transform: 'scale(1)' }, + ]; + + return { + enter: { + keyframes, + duration: motionTokens.durationUltraSlow, + }, + exit: { + keyframes: [...keyframes].reverse(), + duration: motionTokens.durationSlow, + }, + }; +}); + +export const PresenceMotionFunctionParams = () => { + const classes = useClasses(); + + const motionBRef = React.useRef(); + const motionARef = React.useRef(); + + const [playbackRate, setPlaybackRate] = React.useState(30); + const [visible, setVisible] = React.useState(true); + + // Heads up! + // This is optional and is intended solely to slow down the animations, making motions more visible in the examples. + React.useEffect(() => { + motionARef.current?.setPlaybackRate(playbackRate / 100); + motionBRef.current?.setPlaybackRate(playbackRate / 100); + }, [playbackRate, visible]); + + return ( +
+
+ +
+ +
startFrom=0.1
+
+
+ +
+ +
startFrom=0.8
+
+ +
+ + setVisible(v => !v)} /> + + + playbackRate: {playbackRate}% + + ), + className: classes.sliderLabel, + }} + orientation="horizontal" + > + setPlaybackRate(data.value)} + min={0} + max={100} + step={5} + /> + +
+
+ ); +}; + +PresenceMotionFunctionParams.parameters = { + docs: { + description: { + story: description, + }, + }, +}; diff --git a/packages/react-components/react-motion/stories/src/CreatePresenceComponent/index.stories.ts b/packages/react-components/react-motion/stories/src/CreatePresenceComponent/index.stories.ts index bae8636b3169a..ef12782a64298 100644 --- a/packages/react-components/react-motion/stories/src/CreatePresenceComponent/index.stories.ts +++ b/packages/react-components/react-motion/stories/src/CreatePresenceComponent/index.stories.ts @@ -10,6 +10,7 @@ export { PresenceOnMotionFinish as onMotionFinish } from './PresenceOnMotionFini export { PresenceMotionArrays as arrays } from './PresenceMotionArrays.stories'; export { PresenceMotionFunctions as functions } from './PresenceMotionFunctions.stories'; +export { PresenceMotionFunctionParams as functionParams } from './PresenceMotionFunctionParams.stories'; export default { title: 'Utilities/Web Motions/createPresenceComponent',