Skip to content

Commit

Permalink
feat(react-motions): add imperativeRef() prop (#29897)
Browse files Browse the repository at this point in the history
  • Loading branch information
layershifter authored Nov 22, 2023
1 parent 4c1cd4b commit 5f05726
Show file tree
Hide file tree
Showing 17 changed files with 159 additions and 69 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,12 @@ export type MotionAtom = {
options: KeyframeEffectOptions;
};

// @public (undocumented)
export type MotionImperativeRef = {
setPlaybackRate: (rate: number) => void;
setPlayState: (state: 'running' | 'paused') => void;
};

// @public (undocumented)
export type MotionTransition = {
enter: MotionAtom;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ import { useIsomorphicLayoutEffect, useMergedRefs } from '@fluentui/react-utilit
import * as React from 'react';

import { useIsReducedMotion } from '../hooks/useIsReducedMotion';
import { useMotionImperativeRef } from '../hooks/useMotionImperativeRef';
import { getChildElement } from '../utils/getChildElement';
import type { MotionAtom } from '../types';
import type { MotionAtom, MotionImperativeRef } from '../types';

export type AtomProps = {
children: React.ReactElement;

/** Provides imperative controls for the animation. */
imperativeRef?: React.Ref<MotionImperativeRef | undefined>;

iterations?: number;
playState?: 'running' | 'paused';
};

/**
Expand All @@ -19,11 +22,11 @@ export type AtomProps = {
*/
export function createAtom(motion: MotionAtom) {
const Atom: React.FC<AtomProps> = props => {
const { children, iterations = 1, playState = 'running' } = props;
const { children, iterations = 1, imperativeRef } = props;

const child = getChildElement(children);

const animationRef = React.useRef<Animation | undefined>();
const animationRef = useMotionImperativeRef(imperativeRef);
const elementRef = React.useRef<HTMLElement>();

const isReducedMotion = useIsReducedMotion();
Expand All @@ -47,22 +50,7 @@ export function createAtom(motion: MotionAtom) {
animation.cancel();
};
}
}, [iterations, isReducedMotion]);

// TODO: Find a way to avoid this effect/refactor as currently it will call .play() on initial render
useIsomorphicLayoutEffect(() => {
const animation = animationRef.current;

if (animation) {
if (playState === 'running') {
animation.play();
}

if (playState === 'paused') {
animation.pause();
}
}
}, [playState]);
}, [animationRef, iterations, isReducedMotion]);

return React.cloneElement(children, { ref: useMergedRefs(elementRef, child.ref) });
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ import { useEventCallback, useIsomorphicLayoutEffect, useMergedRefs } from '@flu
import * as React from 'react';

import { useIsReducedMotion } from '../hooks/useIsReducedMotion';
import { useMotionImperativeRef } from '../hooks/useMotionImperativeRef';
import { getChildElement } from '../utils/getChildElement';
import type { MotionTransition } from '../types';
import type { MotionTransition, MotionImperativeRef } from '../types';

type TransitionProps = {
children: React.ReactElement;

/** Provides imperative controls for the animation. */
imperativeRef?: React.Ref<MotionImperativeRef | undefined>;

appear?: boolean;
visible?: boolean;

Expand All @@ -16,10 +20,11 @@ type TransitionProps = {

export function createTransition(transition: MotionTransition) {
const Transition: React.FC<TransitionProps> = props => {
const { appear, children, visible, unmountOnExit } = props;
const { appear, children, imperativeRef, visible, unmountOnExit } = props;

const child = getChildElement(children);

const animationRef = useMotionImperativeRef(imperativeRef);
const elementRef = React.useRef<HTMLElement>();
const ref = useMergedRefs(elementRef, child.ref);

Expand Down Expand Up @@ -55,14 +60,15 @@ export function createTransition(transition: MotionTransition) {
return;
}

animationRef.current = animation;
animation.onfinish = onExitFinish;

return () => {
// TODO: should we set unmount there?
animation.cancel();
};
}
}, [isReducedMotion, onExitFinish, visible]);
}, [animationRef, isReducedMotion, onExitFinish, visible]);

useIsomorphicLayoutEffect(() => {
if (!elementRef.current) {
Expand All @@ -79,11 +85,13 @@ export function createTransition(transition: MotionTransition) {
...(isReducedMotion() && { duration: 1 }),
});

animationRef.current = animation;

return () => {
animation.cancel();
};
}
}, [isReducedMotion, mounted, visible, appear]);
}, [animationRef, isReducedMotion, mounted, visible, appear]);

useIsomorphicLayoutEffect(() => {
isFirstMount.current = false;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { renderHook } from '@testing-library/react-hooks';
import * as React from 'react';

import type { MotionImperativeRef } from '../types';
import { useMotionImperativeRef } from './useMotionImperativeRef';

describe('useMotionImperativeRef', () => {
it('exposes methods to control motions on a passed ref', () => {
const imperativeRef = React.createRef<MotionImperativeRef>();
const { result } = renderHook(() => useMotionImperativeRef(imperativeRef));

expect(result.current).toMatchObject({ current: undefined });
expect(imperativeRef.current).toMatchObject({
setPlayState: expect.any(Function),
setPlaybackRate: expect.any(Function),
});
});

it('exposed methods control a web animation', () => {
const animationMock = {
play: jest.fn(),
pause: jest.fn(),
} as Partial<Animation> as Animation;

const setPlaybackRate = jest.fn();
Object.defineProperty(animationMock, 'playbackRate', {
set: setPlaybackRate,
});

const imperativeRef = React.createRef<MotionImperativeRef>();
const { result } = renderHook(() => useMotionImperativeRef(imperativeRef));

result.current.current = animationMock;

imperativeRef.current?.setPlayState('running');
expect(animationMock.play).toHaveBeenCalled();

imperativeRef.current?.setPlayState('paused');
expect(animationMock.pause).toHaveBeenCalled();

imperativeRef.current?.setPlaybackRate(0.5);
expect(setPlaybackRate).toHaveBeenCalledWith(0.5);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as React from 'react';
import type { MotionImperativeRef } from '../types';

export function useMotionImperativeRef(imperativeRef: React.Ref<MotionImperativeRef | undefined> | undefined) {
const animationRef = React.useRef<Animation | undefined>();

React.useImperativeHandle(imperativeRef, () => ({
setPlayState: state => {
if (state === 'running') {
animationRef.current?.play();
}

if (state === 'paused') {
animationRef.current?.pause();
}
},
setPlaybackRate: rate => {
if (animationRef.current) {
animationRef.current.playbackRate = rate;
}
},
}));

return animationRef;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ export { createAtom } from './factories/createAtom';
export { createTransition } from './factories/createTransition';

export { atoms, transitions };
export type { MotionAtom, MotionTransition } from './types';
export type { MotionAtom, MotionTransition, MotionImperativeRef } from './types';
8 changes: 8 additions & 0 deletions packages/react-components/react-motions-preview/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,11 @@ export type MotionTransition = {
enter: MotionAtom;
exit: MotionAtom;
};

export type MotionImperativeRef = {
/** Sets the playback rate of the animation, where 1 is normal speed. */
setPlaybackRate: (rate: number) => void;

/** Sets the state of the animation to running or paused. */
setPlayState: (state: 'running' | 'paused') => void;
};

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { makeStyles, shorthands, tokens, Label, Slider, useId } from '@fluentui/react-components';
import { atoms, createAtom } from '@fluentui/react-motions-preview';
import type { MotionImperativeRef } from '@fluentui/react-motions-preview';
import * as React from 'react';

import description from './CreateAtom.stories.md';
Expand Down Expand Up @@ -59,27 +60,28 @@ export const CreateAtom = () => {
const classes = useClasses();
const sliderId = useId();

const containerRef = React.useRef<HTMLDivElement>(null);
const motionEnterRef = React.useRef<MotionImperativeRef>();
const motionExitRef = React.useRef<MotionImperativeRef>();

const [playbackRate, setPlaybackRate] = React.useState<number>(30);

React.useEffect(() => {
containerRef.current?.getAnimations({ subtree: true }).forEach(animation => {
animation.playbackRate = playbackRate / 100;
});
motionEnterRef.current?.setPlaybackRate(playbackRate / 100);
motionExitRef.current?.setPlaybackRate(playbackRate / 100);
}, [playbackRate]);

return (
<>
<div className={classes.container} ref={containerRef}>
<div className={classes.container}>
<div className={classes.card}>
<FadeEnter iterations={Infinity}>
<FadeEnter iterations={Infinity} imperativeRef={motionEnterRef}>
<div className={classes.item} />
</FadeEnter>

<code className={classes.description}>fadeEnterUltraSlow</code>
</div>
<div className={classes.card}>
<FadeExit iterations={Infinity}>
<FadeExit iterations={Infinity} imperativeRef={motionExitRef}>
<div className={classes.item} />
</FadeExit>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { makeStyles, shorthands, tokens, Label, Slider, useId, Checkbox } from '@fluentui/react-components';
import { createTransition, transitions } from '@fluentui/react-motions-preview';
import type { MotionImperativeRef } from '@fluentui/react-motions-preview';
import * as React from 'react';

import description from './CreateTransition.stories.md';
Expand Down Expand Up @@ -48,22 +49,21 @@ export const CreateTransition = () => {
const classes = useClasses();
const sliderId = useId();

const elementRef = React.useRef<HTMLDivElement>(null);
const motionRef = React.useRef<MotionImperativeRef>();

const [playbackRate, setPlaybackRate] = React.useState<number>(30);
const [visible, setVisible] = React.useState<boolean>(false);

React.useEffect(() => {
elementRef.current?.getAnimations().forEach(animation => {
animation.playbackRate = playbackRate / 100;
});
motionRef.current?.setPlaybackRate(playbackRate / 100);
}, [playbackRate, visible]);

return (
<>
<div className={classes.container}>
<div className={classes.card}>
<Fade visible={visible}>
<div className={classes.item} ref={elementRef} />
<Fade imperativeRef={motionRef} visible={visible}>
<div className={classes.item} />
</Fade>

<code className={classes.description}>fadeSlow</code>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
By default, the child component will be animated when it first mounts. The state of a motion can be controlled using `setPlayState()` via `imperativeRef` prop.

`imperativeRef` works with both `createAtom` and `createTransition` factories.
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { makeStyles, shorthands, tokens, Label, Slider, useId, Checkbox } from '@fluentui/react-components';
import { atoms, createAtom } from '@fluentui/react-motions-preview';
import type { MotionImperativeRef } from '@fluentui/react-motions-preview';
import * as React from 'react';

import description from './TransitionUnmountOnExit.stories.md';
import description from './ImperativeRefPlayState.stories.md';

const useClasses = makeStyles({
container: {
Expand Down Expand Up @@ -44,26 +45,29 @@ const useClasses = makeStyles({

const FadeEnter = createAtom(atoms.fade.enterUltraSlow());

export const AtomPlayState = () => {
export const ImperativeRefPlayState = () => {
const classes = useClasses();
const sliderId = useId();

const elementRef = React.useRef<HTMLDivElement>(null);
const motionRef = React.useRef<MotionImperativeRef>();

const [playbackRate, setPlaybackRate] = React.useState<number>(30);
const [isRunning, setIsRunning] = React.useState<boolean>(false);

React.useEffect(() => {
elementRef.current?.getAnimations().forEach(animation => {
animation.playbackRate = playbackRate / 100;
});
}, [playbackRate, isRunning]);
motionRef.current?.setPlaybackRate(playbackRate / 100);
}, [playbackRate]);

React.useEffect(() => {
motionRef.current?.setPlayState(isRunning ? 'running' : 'paused');
}, [isRunning]);

return (
<>
<div className={classes.container}>
<div className={classes.card}>
<FadeEnter playState={isRunning ? 'running' : 'paused'} iterations={Infinity}>
<div className={classes.item} ref={elementRef} />
<FadeEnter iterations={Infinity} imperativeRef={motionRef}>
<div className={classes.item} />
</FadeEnter>

<code className={classes.description}>fadeEnterSlow</code>
Expand Down Expand Up @@ -97,7 +101,7 @@ export const AtomPlayState = () => {
);
};

AtomPlayState.parameters = {
ImperativeRefPlayState.parameters = {
docs: {
description: {
story: description,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { makeStyles, shorthands, tokens } from '@fluentui/react-components';
import { atoms, createAtom } from '@fluentui/react-motions-preview';
import type { MotionImperativeRef } from '@fluentui/react-motions-preview';
import * as React from 'react';

const useClasses = makeStyles({
Expand All @@ -22,19 +23,20 @@ const useClasses = makeStyles({
},
});

const motionAtom = atoms.fade.enterUltraSlow();
const FadeEnter = createAtom({
keyframes: motionAtom.keyframes,
options: { ...motionAtom.options, duration: 2000 },
});
const FadeEnter = createAtom(atoms.fade.enterUltraSlow());

export const MotionDefault = () => {
const classes = useClasses();
const motionRef = React.useRef<MotionImperativeRef>();

React.useEffect(() => {
motionRef.current?.setPlaybackRate(0.3);
}, []);

return (
<div className={classes.container}>
<div className={classes.card}>
<FadeEnter iterations={Infinity}>
<FadeEnter iterations={Infinity} imperativeRef={motionRef}>
<div className={classes.item} />
</FadeEnter>
</div>
Expand Down
Loading

0 comments on commit 5f05726

Please sign in to comment.