Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(motion): add extended support for reduced motion #33353

Merged
merged 2 commits into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "feat: add extended support for reduced motion",
"packageName": "@fluentui/react-motion",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import { SlotComponentType } from '@fluentui/react-utilities';
import { SlotRenderFunction } from '@fluentui/react-utilities';

// @public (undocumented)
export type AtomMotion = {
keyframes: Keyframe[];
} & KeyframeEffectOptions;
export type AtomMotion = AtomCore & {
reducedMotion?: Partial<AtomCore>;
};

// @public (undocumented)
export type AtomMotionFn<MotionParams extends Record<string, MotionParam> = {}> = (params: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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 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 });
});

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, easing: 'linear' },
};

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

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

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

// A motion atom's default reduced motion is a simple 1 ms duration.
// But an atom can define a custom reduced motion, overriding keyframes and/or params like duration, easing, iterations, etc.
const DEFAULT_REDUCED_MOTION_ATOM: NonNullable<AtomMotion['reducedMotion']> = {
duration: 1,
};

function useAnimateAtomsInSupportedEnvironment() {
// eslint-disable-next-line @nx/workspace-no-restricted-globals
const SUPPORTS_PERSIST = typeof window !== 'undefined' && typeof window.Animation?.prototype.persist === 'function';
Expand All @@ -17,18 +27,26 @@ function useAnimateAtomsInSupportedEnvironment() {
const { isReducedMotion } = options;

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

// Grab the custom reduced motion definition if it exists, or fall back to the default reduced motion.
const { keyframes: motionKeyframes, reducedMotion = DEFAULT_REDUCED_MOTION_ATOM, ...params } = motion;
// Grab the reduced motion keyframes if they exist, or fall back to the regular keyframes.
const { keyframes: reducedMotionKeyframes = motionKeyframes, ...reducedMotionParams } = reducedMotion;

const animationKeyframes: Keyframe[] = isReducedMotion ? reducedMotionKeyframes : motionKeyframes;
const animationParams: KeyframeEffectOptions = {
...DEFAULT_ANIMATION_OPTIONS,
...params,
...(isReducedMotion && { duration: 1 }),
});

layershifter marked this conversation as resolved.
Show resolved Hide resolved
// Use reduced motion overrides (e.g. duration, easing) when reduced motion is enabled
...(isReducedMotion && reducedMotionParams),
layershifter marked this conversation as resolved.
Show resolved Hide resolved
};

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

if (SUPPORTS_PERSIST) {
animation.persist();
} else {
const resultKeyframe = keyframes[keyframes.length - 1];
const resultKeyframe = animationKeyframes[animationKeyframes.length - 1];
Object.assign(element.style ?? {}, resultKeyframe);
}

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;
type AtomCore = { keyframes: Keyframe[] } & KeyframeEffectOptions;

export type AtomMotion = AtomCore & {
/**
* 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 (i.e `prefers-reduced-motion` media query is
* active). If not provided, the duration of the animation will be overridden to be 1ms.
*
* Note, if `keyframes` are provided, they will be used instead of the regular `keyframes`.
*/
reducedMotion?: Partial<AtomCore>;
};

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,
layershifter marked this conversation as resolved.
Show resolved Hide resolved
},
});

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.
Loading
Loading