-
Notifications
You must be signed in to change notification settings - Fork 2.8k
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
---|---|---|
@@ -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,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. 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. | ||
* | ||
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion | ||
*/ | ||
reducedMotion?: { keyframes?: Keyframe[] } & KeyframeEffectOptions; | ||
}; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it looks to me that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe something similar to griffel fallback properties?! const Motion = createPresenceComponent([
// default motion
{
enter: {
keyframes: [
{ opacity: 0, transform: 'scale(0)' },
{ opacity: 1, transform: 'scale(1)' },
],
duration: 300,
},
exit: {
/* ... */
},
},
{/* reduced motion goes here */}
]); There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @bsunderhus I also think a middle type would be good, to DRY up There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Do you mean a new type to extract
@bsunderhus I updated the PR description with two options and added some arguments against them. Please check and let me know WDYT. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Is it necessary to make There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I don't think that we need to force consumers there, especially after the duration change (#33353 (comment)). Also, the media query itself (https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) does not put any restrictions. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just throwing out another idea, instead of making the reduced motion options a recursion of the parent motion declaration we use a second argument in the factory and enforce the SRP for motion declarations? createPresentionComponent(atom, reducedMotionAtom?) This way we'll avoid TS recursion, and decouple the original motion and its reduced motion alternative. From what I understand even the current There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's not recursion though; it's just leaf reuse. There is no atom inside an atom, nor keyframe inside a keyframe. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @ling1726 please check "rejected options" in the PR's description, the order issue will be the same here. |
||
|
||
export type PresenceDirection = 'enter' | 'exit'; | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -66,6 +66,10 @@ const FadeEnter = createMotionComponent({ | |
keyframes: [{ opacity: 0 }, { opacity: 1 }], | ||
duration: motionTokens.durationSlow, | ||
iterations: Infinity, | ||
|
||
reducedMotion: { | ||
iterations: 1, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's the value of having a single iteration here? Is it just that this property has a clearer presence in documentation? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. With infinite iterations, the reduced motion with 1 ms was looping and flashing. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👆 Exactly, either this or we need to complicate examples significantly. |
||
}, | ||
}); | ||
|
||
export const CreateMotionComponentImperativeRefPlayState = () => { | ||
|
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, | ||
}, | ||
}, | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If a user has provided reducedMotion params, I think we should stop applying the default
duration: 1
value.It might sound stupid to pass no duration and it defaults to 0, but we should still the user have the full override power in this case
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wanted to avoid breaks, that's why I kept it. A user will be still able to override
duration
, so I don't see any harm with the current behavior.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ling1726 @layershifter What if we forced them to override
duration
forreducedMotion
? After all, if the duration stays at 1 ms, new keyframes aren't going to accomplish much, in most use cases. And there might be someone who addsreducedMotion
keyframes, then is puzzled when they don't seem to work, because they don't realize the duration is at 1 ms and they need to change it.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's valid concern. As both you (@robertpenner) and @ling1726 raised it, I updated the implementation:
reducedMotion
isundefined
=>{ duration: 1ms }
reducedMotion
is defined => we will use whatever user provided