Skip to content

Commit

Permalink
feat(motion): add extended support for reduced motion
Browse files Browse the repository at this point in the history
  • Loading branch information
layershifter committed Nov 27, 2024
1 parent 883151b commit 07ad3b3
Show file tree
Hide file tree
Showing 12 changed files with 309 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { renderHook } from '@testing-library/react-hooks';

import type { AtomMotion } from '../types';
import { DEFAULT_ANIMATION_OPTIONS, useAnimateAtoms } from './useAnimateAtoms';

function createElementMock() {
const animate = jest.fn().mockReturnValue({
persist: jest.fn(),
});

return [{ animate } as unknown as HTMLElement, animate] as const;
}

const DEFAULT_KEYFRAMES = [{ transform: 'rotate(0)' }, { transform: 'rotate(180deg)' }];
const REDUCED_MOTION_KEYFRAMES = [{ opacity: 0 }, { opacity: 1 }];

describe('useAnimateAtoms', () => {
beforeEach(() => {
// We set production environment to avoid testing the mock implementation
process.env.NODE_ENV = 'production';
});

it('should return a function', () => {
const { result } = renderHook(() => useAnimateAtoms());

expect(result.current).toBeInstanceOf(Function);
});

describe('reduce motion', () => {
it('calls ".animate()" with regular motion', () => {
const { result } = renderHook(() => useAnimateAtoms());

const [element, animateMock] = createElementMock();
const motion: AtomMotion = { keyframes: DEFAULT_KEYFRAMES };

result.current(element, motion, { isReducedMotion: false });

expect(animateMock).toHaveBeenCalledTimes(1);
expect(animateMock).toHaveBeenCalledWith(DEFAULT_KEYFRAMES, { ...DEFAULT_ANIMATION_OPTIONS });
});

it('calls ".animate()" with shortened duration (1ms) when reduced motion is enabled', () => {
const { result } = renderHook(() => useAnimateAtoms());

const [element, animateMock] = createElementMock();
const motion: AtomMotion = { keyframes: DEFAULT_KEYFRAMES };

result.current(element, motion, { isReducedMotion: true });

expect(animateMock).toHaveBeenCalledTimes(1);
expect(animateMock).toHaveBeenCalledWith(DEFAULT_KEYFRAMES, { ...DEFAULT_ANIMATION_OPTIONS, duration: 1 });
});

it('calls ".animate()" with specified reduced motion params when reduced motion is enabled', () => {
const { result } = renderHook(() => useAnimateAtoms());

const [element, animateMock] = createElementMock();
const motion: AtomMotion = {
keyframes: DEFAULT_KEYFRAMES,
reducedMotion: { duration: 100 },
};

result.current(element, motion, { isReducedMotion: true });

expect(animateMock).toHaveBeenCalledTimes(1);
expect(animateMock).toHaveBeenCalledWith(DEFAULT_KEYFRAMES, { ...DEFAULT_ANIMATION_OPTIONS, duration: 100 });
});

it('calls ".animate()" with specified reduced motion keyframes when reduced motion is enabled', () => {
const { result } = renderHook(() => useAnimateAtoms());

const [element, animateMock] = createElementMock();
const motion: AtomMotion = {
keyframes: DEFAULT_KEYFRAMES,
reducedMotion: { keyframes: REDUCED_MOTION_KEYFRAMES },
};

result.current(element, motion, { isReducedMotion: true });

expect(animateMock).toHaveBeenCalledTimes(1);
expect(animateMock).toHaveBeenCalledWith(REDUCED_MOTION_KEYFRAMES, { ...DEFAULT_ANIMATION_OPTIONS, duration: 1 });
});
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import * as React from 'react';
import type { AnimationHandle, AtomMotion } from '../types';

export const DEFAULT_ANIMATION_OPTIONS: KeyframeEffectOptions = {
fill: 'forwards',
};

function useAnimateAtomsInSupportedEnvironment() {
return React.useCallback(
(
Expand All @@ -14,13 +18,19 @@ function useAnimateAtomsInSupportedEnvironment() {
const { isReducedMotion } = options;

const animations = atoms.map(motion => {
const { keyframes, ...params } = motion;
const animation = element.animate(keyframes, {
fill: 'forwards',
const { keyframes, reducedMotion, ...params } = motion;
const { keyframes: reducedMotionKeyframes = keyframes, ...reducedMotionParams } = reducedMotion ?? {};

const animationKeyframes: Keyframe[] = isReducedMotion ? reducedMotionKeyframes : keyframes;
const animationParams: KeyframeEffectOptions = {
...DEFAULT_ANIMATION_OPTIONS,
...params,

...(isReducedMotion && { duration: 1 }),
});
...(isReducedMotion && reducedMotionParams),
};

const animation = element.animate(animationKeyframes, animationParams);

animation.persist();

Expand Down
13 changes: 12 additions & 1 deletion packages/react-components/react-motion/library/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
export type AtomMotion = { keyframes: Keyframe[] } & KeyframeEffectOptions;
export type AtomMotion = { keyframes: Keyframe[] } & KeyframeEffectOptions & {
/**
* Allows to specify a reduced motion version of the animation. If provided, the settings will be used when the
* user has enabled the reduced motion setting in the operating system. By default, the duration of the animation is
* set to 1ms.
*
* Note, if keyframes are provided, they will be used instead of the regular keyframes.
*
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion
*/
reducedMotion?: { keyframes?: Keyframe[] } & KeyframeEffectOptions;
};

export type PresenceDirection = 'enter' | 'exit';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ const FadeEnter = createMotionComponent({
keyframes: [{ opacity: 0 }, { opacity: 1 }],
duration: motionTokens.durationSlow,
iterations: Infinity,

reducedMotion: {
iterations: 1,
},
});

export const CreateMotionComponentDefault = (props: MotionComponentProps) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ const DropIn = createMotionComponent({
],
duration: 4000,
iterations: Infinity,

reducedMotion: {
iterations: 1,
},
});

export const CreateMotionComponentFactory = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ const Scale = createMotionComponent<{ startFrom?: number }>(({ startFrom = 0.5 }
],
duration: motionTokens.durationUltraSlow,
iterations: Infinity,

reducedMotion: {
iterations: 1,
},
};
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ const Grow = createMotionComponent(({ element }) => ({
{ opacity: 0, maxHeight: `${element.scrollHeight / 2}px` },
],
iterations: Infinity,

reducedMotion: {
iterations: 1,
},
}));

export const CreateMotionComponentFunctions = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ const FadeEnter = createMotionComponent({
keyframes: [{ opacity: 0 }, { opacity: 1 }],
duration: motionTokens.durationSlow,
iterations: Infinity,

reducedMotion: {
iterations: 1,
},
});

export const CreateMotionComponentImperativeRefPlayState = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ const BackgroundChange = createMotionComponent({
],
duration: 3000,
iterations: Infinity,

reducedMotion: {
iterations: 1,
},
});

export const CreateMotionComponentTokensUsage = () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
By default, when [reduced motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) is enabled the duration of the animation is set to `1ms`. `reducedMotion` allows to customize a reduced motion version of the animation:

```ts
const Motion = createPresenceComponent({
enter: {
keyframes: [
{ opacity: 0, transform: 'scale(0)' },
{ opacity: 1, transform: 'scale(1)' },
],
duration: 300,

/* 💡reduced motion will not have scale animation */
reducedMotion: {
keyframes: [{ opacity: 0 }, { opacity: 1 }],
duration: 1000,
},
},
exit: {
/* ... */
},
});
```

> 💡Note, if `keyframes` are provided, they will be used instead of the regular keyframes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {
createPresenceComponent,
Field,
makeStyles,
mergeClasses,
type MotionImperativeRef,
motionTokens,
Slider,
Switch,
tokens,
} from '@fluentui/react-components';
import * as React from 'react';

import description from './CreatePresenceComponentReducedMotion.stories.md';

const useClasses = makeStyles({
container: {
display: 'grid',
gridTemplate: `"card card" "controls ." / 1fr 1fr`,
gap: '20px 10px',
},
card: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'end',
gridArea: 'card',

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',
},

item: {
backgroundColor: tokens.colorBrandBackground,
border: `${tokens.strokeWidthThicker} solid ${tokens.colorTransparentStroke}`,

width: '100px',
height: '100px',
},
});

const FadeAndScale = createPresenceComponent({
enter: {
keyframes: [
{ opacity: 0, transform: 'rotate(0)' },
{ transform: 'rotate(90deg) scale(1.5)' },
{ opacity: 1, transform: 'rotate(0)' },
],
duration: motionTokens.durationGentle,

reducedMotion: {
keyframes: [{ opacity: 0 }, { opacity: 1 }],
duration: motionTokens.durationUltraSlow,
},
},
exit: {
keyframes: [
{ opacity: 1, transform: 'rotate(0)' },
{ transform: 'rotate(-90deg) scale(1.5)' },
{ opacity: 0, transform: 'rotate(0)' },
],
duration: motionTokens.durationGentle,

reducedMotion: {
keyframes: [{ opacity: 1 }, { opacity: 0 }],
duration: motionTokens.durationUltraSlow,
},
},
});

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

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

// Heads up!
// This is optional and is intended solely to slow down the animations, making motions more visible in the examples.
React.useEffect(() => {
motionRef.current?.setPlaybackRate(playbackRate / 100);
}, [playbackRate, visible]);

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

<div className={classes.controls}>
<Field className={classes.field}>
<Switch label="Visible" checked={visible} onChange={() => setVisible(v => !v)} />
</Field>
<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>
);
};

CreatePresenceComponentReducedMotion.parameters = {
docs: {
description: {
story: description,
},
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export { CreatePresenceComponentDefault as Default } from './CreatePresenceCompo
export { CreatePresenceComponentFactory as createPresenceComponent } from './CreatePresenceComponentFactory.stories';

export { CreatePresenceComponentAppear as appear } from './CreatePresenceComponentAppear.stories';
export { CreatePresenceComponentReducedMotion as reducedMotion } from './CreatePresenceComponentReducedMotion.stories';
export { CreatePresenceComponentUnmountOnExit as unmountOnExit } from './CreatePresenceComponentUnmountOnExit.stories';
export { CreatePresenceComponentLifecycleCallbacks as LifecycleCallbacks } from './CreatePresenceComponentLifecycleCallbacks.stories';

Expand Down

0 comments on commit 07ad3b3

Please sign in to comment.