Skip to content

Commit 7649e34

Browse files
committed
feat(motion): add extended support for reduced motion
1 parent 883151b commit 7649e34

13 files changed

Lines changed: 316 additions & 5 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "feat: add extended support for reduced motion",
4+
"packageName": "@fluentui/react-motion",
5+
"email": "olfedias@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { renderHook } from '@testing-library/react-hooks';
2+
3+
import type { AtomMotion } from '../types';
4+
import { DEFAULT_ANIMATION_OPTIONS, useAnimateAtoms } from './useAnimateAtoms';
5+
6+
function createElementMock() {
7+
const animate = jest.fn().mockReturnValue({
8+
persist: jest.fn(),
9+
});
10+
11+
return [{ animate } as unknown as HTMLElement, animate] as const;
12+
}
13+
14+
const DEFAULT_KEYFRAMES = [{ transform: 'rotate(0)' }, { transform: 'rotate(180deg)' }];
15+
const REDUCED_MOTION_KEYFRAMES = [{ opacity: 0 }, { opacity: 1 }];
16+
17+
describe('useAnimateAtoms', () => {
18+
beforeEach(() => {
19+
// We set production environment to avoid testing the mock implementation
20+
process.env.NODE_ENV = 'production';
21+
});
22+
23+
it('should return a function', () => {
24+
const { result } = renderHook(() => useAnimateAtoms());
25+
26+
expect(result.current).toBeInstanceOf(Function);
27+
});
28+
29+
describe('reduce motion', () => {
30+
it('calls ".animate()" with regular motion', () => {
31+
const { result } = renderHook(() => useAnimateAtoms());
32+
33+
const [element, animateMock] = createElementMock();
34+
const motion: AtomMotion = { keyframes: DEFAULT_KEYFRAMES };
35+
36+
result.current(element, motion, { isReducedMotion: false });
37+
38+
expect(animateMock).toHaveBeenCalledTimes(1);
39+
expect(animateMock).toHaveBeenCalledWith(DEFAULT_KEYFRAMES, { ...DEFAULT_ANIMATION_OPTIONS });
40+
});
41+
42+
it('calls ".animate()" with shortened duration (1ms) when reduced motion is enabled', () => {
43+
const { result } = renderHook(() => useAnimateAtoms());
44+
45+
const [element, animateMock] = createElementMock();
46+
const motion: AtomMotion = { keyframes: DEFAULT_KEYFRAMES };
47+
48+
result.current(element, motion, { isReducedMotion: true });
49+
50+
expect(animateMock).toHaveBeenCalledTimes(1);
51+
expect(animateMock).toHaveBeenCalledWith(DEFAULT_KEYFRAMES, { ...DEFAULT_ANIMATION_OPTIONS, duration: 1 });
52+
});
53+
54+
it('calls ".animate()" with specified reduced motion params when reduced motion is enabled', () => {
55+
const { result } = renderHook(() => useAnimateAtoms());
56+
57+
const [element, animateMock] = createElementMock();
58+
const motion: AtomMotion = {
59+
keyframes: DEFAULT_KEYFRAMES,
60+
reducedMotion: { duration: 100 },
61+
};
62+
63+
result.current(element, motion, { isReducedMotion: true });
64+
65+
expect(animateMock).toHaveBeenCalledTimes(1);
66+
expect(animateMock).toHaveBeenCalledWith(DEFAULT_KEYFRAMES, { ...DEFAULT_ANIMATION_OPTIONS, duration: 100 });
67+
});
68+
69+
it('calls ".animate()" with specified reduced motion keyframes when reduced motion is enabled', () => {
70+
const { result } = renderHook(() => useAnimateAtoms());
71+
72+
const [element, animateMock] = createElementMock();
73+
const motion: AtomMotion = {
74+
keyframes: DEFAULT_KEYFRAMES,
75+
reducedMotion: { keyframes: REDUCED_MOTION_KEYFRAMES },
76+
};
77+
78+
result.current(element, motion, { isReducedMotion: true });
79+
80+
expect(animateMock).toHaveBeenCalledTimes(1);
81+
expect(animateMock).toHaveBeenCalledWith(REDUCED_MOTION_KEYFRAMES, { ...DEFAULT_ANIMATION_OPTIONS, duration: 1 });
82+
});
83+
});
84+
});

packages/react-components/react-motion/library/src/hooks/useAnimateAtoms.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import * as React from 'react';
22
import type { AnimationHandle, AtomMotion } from '../types';
33

4+
export const DEFAULT_ANIMATION_OPTIONS: KeyframeEffectOptions = {
5+
fill: 'forwards',
6+
};
7+
48
function useAnimateAtomsInSupportedEnvironment() {
59
return React.useCallback(
610
(
@@ -14,13 +18,19 @@ function useAnimateAtomsInSupportedEnvironment() {
1418
const { isReducedMotion } = options;
1519

1620
const animations = atoms.map(motion => {
17-
const { keyframes, ...params } = motion;
18-
const animation = element.animate(keyframes, {
19-
fill: 'forwards',
21+
const { keyframes, reducedMotion, ...params } = motion;
22+
const { keyframes: reducedMotionKeyframes = keyframes, ...reducedMotionParams } = reducedMotion ?? {};
2023

24+
const animationKeyframes: Keyframe[] = isReducedMotion ? reducedMotionKeyframes : keyframes;
25+
const animationParams: KeyframeEffectOptions = {
26+
...DEFAULT_ANIMATION_OPTIONS,
2127
...params,
28+
2229
...(isReducedMotion && { duration: 1 }),
23-
});
30+
...(isReducedMotion && reducedMotionParams),
31+
};
32+
33+
const animation = element.animate(animationKeyframes, animationParams);
2434

2535
animation.persist();
2636

packages/react-components/react-motion/library/src/types.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1-
export type AtomMotion = { keyframes: Keyframe[] } & KeyframeEffectOptions;
1+
export type AtomMotion = { keyframes: Keyframe[] } & KeyframeEffectOptions & {
2+
/**
3+
* Allows to specify a reduced motion version of the animation. If provided, the settings will be used when the
4+
* user has enabled the reduced motion setting in the operating system. By default, the duration of the animation is
5+
* set to 1ms.
6+
*
7+
* Note, if keyframes are provided, they will be used instead of the regular keyframes.
8+
*
9+
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion
10+
*/
11+
reducedMotion?: { keyframes?: Keyframe[] } & KeyframeEffectOptions;
12+
};
213

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

packages/react-components/react-motion/stories/src/CreateMotionComponent/CreateMotionComponentDefault.stories.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ const FadeEnter = createMotionComponent({
4141
keyframes: [{ opacity: 0 }, { opacity: 1 }],
4242
duration: motionTokens.durationSlow,
4343
iterations: Infinity,
44+
45+
reducedMotion: {
46+
iterations: 1,
47+
},
4448
});
4549

4650
export const CreateMotionComponentDefault = (props: MotionComponentProps) => {

packages/react-components/react-motion/stories/src/CreateMotionComponent/CreateMotionComponentFactory.stories.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ const DropIn = createMotionComponent({
4545
],
4646
duration: 4000,
4747
iterations: Infinity,
48+
49+
reducedMotion: {
50+
iterations: 1,
51+
},
4852
});
4953

5054
export const CreateMotionComponentFactory = () => {

packages/react-components/react-motion/stories/src/CreateMotionComponent/CreateMotionComponentFunctionParams.stories.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ const Scale = createMotionComponent<{ startFrom?: number }>(({ startFrom = 0.5 }
8181
],
8282
duration: motionTokens.durationUltraSlow,
8383
iterations: Infinity,
84+
85+
reducedMotion: {
86+
iterations: 1,
87+
},
8488
};
8589
});
8690

packages/react-components/react-motion/stories/src/CreateMotionComponent/CreateMotionComponentFunctions.stories.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ const Grow = createMotionComponent(({ element }) => ({
6868
{ opacity: 0, maxHeight: `${element.scrollHeight / 2}px` },
6969
],
7070
iterations: Infinity,
71+
72+
reducedMotion: {
73+
iterations: 1,
74+
},
7175
}));
7276

7377
export const CreateMotionComponentFunctions = () => {

packages/react-components/react-motion/stories/src/CreateMotionComponent/CreateMotionComponentImperativeRefPlayState.stories.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ const FadeEnter = createMotionComponent({
6666
keyframes: [{ opacity: 0 }, { opacity: 1 }],
6767
duration: motionTokens.durationSlow,
6868
iterations: Infinity,
69+
70+
reducedMotion: {
71+
iterations: 1,
72+
},
6973
});
7074

7175
export const CreateMotionComponentImperativeRefPlayState = () => {

packages/react-components/react-motion/stories/src/CreateMotionComponent/CreateMotionComponentTokensUsage.stories.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ const BackgroundChange = createMotionComponent({
4545
],
4646
duration: 3000,
4747
iterations: Infinity,
48+
49+
reducedMotion: {
50+
iterations: 1,
51+
},
4852
});
4953

5054
export const CreateMotionComponentTokensUsage = () => {

0 commit comments

Comments
 (0)