Skip to content

Commit

Permalink
feat(react-motion): add support for params (#31566)
Browse files Browse the repository at this point in the history
  • Loading branch information
layershifter authored Jun 11, 2024
1 parent 46befc0 commit 1b681fc
Show file tree
Hide file tree
Showing 11 changed files with 405 additions and 21 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: add support for params",
"packageName": "@fluentui/react-motion",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ export type AtomMotion = {
} & KeyframeEffectOptions;

// @public (undocumented)
export type AtomMotionFn = (params: {
export type AtomMotionFn<MotionParams extends Record<string, MotionParam> = {}> = (params: {
element: HTMLElement;
}) => AtomMotion | AtomMotion[];
} & MotionParams) => AtomMotion | AtomMotion[];

// @public
export function createMotionComponent(value: AtomMotion | AtomMotion[] | AtomMotionFn): React_2.FC<MotionComponentProps>;
export function createMotionComponent<MotionParams extends Record<string, MotionParam> = {}>(value: AtomMotion | AtomMotion[] | AtomMotionFn<MotionParams>): React_2.FC<MotionComponentProps & MotionParams>;

// @public (undocumented)
export function createPresenceComponent(value: PresenceMotion | PresenceMotionFn): React_2.FC<PresenceComponentProps>;
export function createPresenceComponent<MotionParams extends Record<string, MotionParam> = {}>(value: PresenceMotion | PresenceMotionFn<MotionParams>): React_2.FC<PresenceComponentProps & MotionParams>;

// @public (undocumented)
export const curves: {
Expand Down Expand Up @@ -115,9 +115,9 @@ export type PresenceMotion = {
};

// @public (undocumented)
export type PresenceMotionFn = (params: {
export type PresenceMotionFn<MotionParams extends Record<string, MotionParam> = {}> = (params: {
element: HTMLElement;
}) => PresenceMotion;
} & MotionParams) => PresenceMotion;

// (No @packageDocumentation comment for this package)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,21 +19,31 @@ export type MotionComponentProps = {
*
* @param value - A motion definition.
*/
export function createMotionComponent(value: AtomMotion | AtomMotion[] | AtomMotionFn) {
const Atom: React.FC<MotionComponentProps> = props => {
const { children, imperativeRef } = props;
export function createMotionComponent<MotionParams extends Record<string, MotionParam> = {}>(
value: AtomMotion | AtomMotion[] | AtomMotionFn<MotionParams>,
) {
const Atom: React.FC<MotionComponentProps & MotionParams> = props => {
const { children, imperativeRef, ..._rest } = props;
const params = _rest as Exclude<typeof props, MotionComponentProps>;
const child = getChildElement(children);

const handleRef = useMotionImperativeRef(imperativeRef);
const elementRef = React.useRef<HTMLElement>();
const paramsRef = React.useRef<MotionParams>(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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
/**
Expand Down Expand Up @@ -45,18 +45,23 @@ function shouldSkipAnimation(appear: boolean | undefined, isFirstMount: boolean,
return !appear && isFirstMount && visible;
}

export function createPresenceComponent(value: PresenceMotion | PresenceMotionFn) {
const Presence: React.FC<PresenceComponentProps> = props => {
export function createPresenceComponent<MotionParams extends Record<string, MotionParam> = {}>(
value: PresenceMotion | PresenceMotionFn<MotionParams>,
) {
const Presence: React.FC<PresenceComponentProps & MotionParams> = 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<typeof merged, PresenceComponentProps | typeof itemContext>;

const [mounted, setMounted] = useMountedState(visible, unmountOnExit);
const child = getChildElement(children);

const handleRef = useMotionImperativeRef(imperativeRef);
const elementRef = React.useRef<HTMLElement>();
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();
Expand All @@ -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(
Expand All @@ -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() });
Expand Down
16 changes: 14 additions & 2 deletions packages/react-components/react-motion/library/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MotionParams extends Record<string, MotionParam> = {}> = (
params: { element: HTMLElement } & MotionParams,
) => AtomMotion | AtomMotion[];
export type PresenceMotionFn<MotionParams extends Record<string, MotionParam> = {}> = (
params: { element: HTMLElement } & MotionParams,
) => PresenceMotion;

// ---

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
}));
```
Original file line number Diff line number Diff line change
@@ -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<MotionImperativeRef>();
const motionARef = React.useRef<MotionImperativeRef>();

const [playbackRate, setPlaybackRate] = React.useState<number>(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 (
<div className={classes.container}>
<div className={mergeClasses(classes.card, classes.cardA)}>
<Scale imperativeRef={motionARef} startFrom={0.1}>
<div className={classes.item} />
</Scale>
<div className={classes.description}>startFrom=0.1</div>
</div>
<div className={mergeClasses(classes.card, classes.cardB)}>
<Scale imperativeRef={motionBRef} startFrom={0.8}>
<div className={classes.item} />
</Scale>
<div className={classes.description}>startFrom=0.8</div>
</div>

<div className={classes.controls}>
<Field
className={mergeClasses(classes.field, classes.sliderField)}
label={{
children: (
<>
<code>playbackRate</code>: {playbackRate}%
</>
),
className: classes.sliderLabel,
}}
orientation="horizontal"
>
<Slider
aria-valuetext={`Value is ${playbackRate}%`}
className={mergeClasses(classes.field, classes.sliderField)}
value={playbackRate}
onChange={(ev, data) => setPlaybackRate(data.value)}
min={0}
max={100}
step={5}
/>
</Field>
</div>
</div>
);
};

MotionFunctionParams.parameters = {
docs: {
description: {
story: description,
},
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
},
};
});
```
Loading

0 comments on commit 1b681fc

Please sign in to comment.