-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(react-motions): add createTransition() factory (#29839)
* feat(react-motions): add createTransition() factory * use transition definitions * apply review suggestions
- Loading branch information
1 parent
abb2dc5
commit 0f56cc9
Showing
7 changed files
with
327 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
99 changes: 99 additions & 0 deletions
99
packages/react-components/react-motions-preview/src/factories/createTransition.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
import { useEventCallback, useIsomorphicLayoutEffect, useMergedRefs } from '@fluentui/react-utilities'; | ||
import * as React from 'react'; | ||
|
||
import { useIsReducedMotion } from '../hooks/useIsReducedMotion'; | ||
import type { MotionTransition } from '../types'; | ||
|
||
type TransitionProps = { | ||
children: React.ReactElement; | ||
|
||
appear?: boolean; | ||
visible?: boolean; | ||
|
||
unmountOnExit?: boolean; | ||
}; | ||
|
||
export function createTransition(transition: MotionTransition) { | ||
const Transition: React.FC<TransitionProps> = props => { | ||
const { appear, children, visible, unmountOnExit } = props; | ||
|
||
const child = React.Children.only(children) as React.ReactElement & { ref: React.Ref<HTMLElement> }; | ||
|
||
const elementRef = React.useRef<HTMLElement>(); | ||
const ref = useMergedRefs(elementRef, child.ref); | ||
|
||
const [mounted, setMounted] = React.useState(() => (unmountOnExit ? visible : true)); | ||
|
||
const isFirstMount = React.useRef<boolean>(true); | ||
const isReducedMotion = useIsReducedMotion(); | ||
|
||
const onExitFinish = useEventCallback(() => { | ||
if (unmountOnExit) { | ||
setMounted(false); | ||
} | ||
}); | ||
|
||
useIsomorphicLayoutEffect(() => { | ||
if (visible) { | ||
setMounted(true); | ||
return; | ||
} | ||
|
||
if (elementRef.current) { | ||
const animation = elementRef.current.animate(transition.exit.keyframes, { | ||
fill: 'forwards', | ||
|
||
...transition.exit.options, | ||
...(isReducedMotion() && { duration: 1 }), | ||
}); | ||
|
||
if (isFirstMount.current) { | ||
// Heads up! | ||
// .finish() is used there to skip animation on first mount, but apply animation styles | ||
animation.finish(); | ||
return; | ||
} | ||
|
||
animation.onfinish = onExitFinish; | ||
|
||
return () => { | ||
// TODO: should we set unmount there? | ||
animation.cancel(); | ||
}; | ||
} | ||
}, [isReducedMotion, onExitFinish, visible]); | ||
|
||
useIsomorphicLayoutEffect(() => { | ||
if (!elementRef.current) { | ||
return; | ||
} | ||
|
||
const shouldEnter = isFirstMount.current ? appear && visible : mounted && visible; | ||
|
||
if (shouldEnter) { | ||
const animation = elementRef.current.animate(transition.enter.keyframes, { | ||
fill: 'forwards', | ||
|
||
...transition.enter.options, | ||
...(isReducedMotion() && { duration: 1 }), | ||
}); | ||
|
||
return () => { | ||
animation.cancel(); | ||
}; | ||
} | ||
}, [isReducedMotion, mounted, visible, appear]); | ||
|
||
useIsomorphicLayoutEffect(() => { | ||
isFirstMount.current = false; | ||
}, []); | ||
|
||
if (mounted) { | ||
return React.cloneElement(child, { ref }); | ||
} | ||
|
||
return null; | ||
}; | ||
|
||
return Transition; | ||
} |
122 changes: 122 additions & 0 deletions
122
packages/react-components/react-motions-preview/src/factories/createTransitionTest.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,122 @@ | ||
import { render } from '@testing-library/react'; | ||
import * as React from 'react'; | ||
|
||
import type { MotionTransition } from '../types'; | ||
import { createTransition } from './createTransition'; | ||
|
||
const transition: MotionTransition = { | ||
enter: { | ||
keyframes: [{ opacity: 0 }, { opacity: 1 }], | ||
options: { | ||
duration: 500, | ||
fill: 'forwards', | ||
}, | ||
}, | ||
exit: { | ||
keyframes: [{ opacity: 0 }, { opacity: 1 }], | ||
options: { | ||
duration: 500, | ||
fill: 'forwards', | ||
}, | ||
}, | ||
}; | ||
|
||
function createElementMock() { | ||
const animateMock = jest.fn().mockImplementation(() => ({ | ||
cancel: jest.fn(), | ||
set onfinish(callback: Function) { | ||
callback(); | ||
return; | ||
}, | ||
})); | ||
const ElementMock = React.forwardRef((props, ref) => { | ||
React.useImperativeHandle(ref, () => ({ | ||
animate: animateMock, | ||
})); | ||
|
||
return <div>ElementMock</div>; | ||
}); | ||
|
||
return { | ||
animateMock, | ||
ElementMock, | ||
}; | ||
} | ||
|
||
describe('createTransition', () => { | ||
describe('appear', () => { | ||
it('does not animate by default', () => { | ||
const TestAtom = createTransition(transition); | ||
const { animateMock, ElementMock } = createElementMock(); | ||
|
||
render( | ||
<TestAtom visible> | ||
<ElementMock /> | ||
</TestAtom>, | ||
); | ||
|
||
expect(animateMock).not.toHaveBeenCalled(); | ||
}); | ||
|
||
it('animates when is "true"', () => { | ||
const TestAtom = createTransition(transition); | ||
const { animateMock, ElementMock } = createElementMock(); | ||
|
||
render( | ||
<TestAtom appear visible> | ||
<ElementMock /> | ||
</TestAtom>, | ||
); | ||
|
||
expect(animateMock).toHaveBeenCalledWith(transition.enter.keyframes, transition.enter.options); | ||
}); | ||
}); | ||
|
||
describe('visible', () => { | ||
it('animates when state changes', () => { | ||
const TestAtom = createTransition(transition); | ||
const { animateMock, ElementMock } = createElementMock(); | ||
|
||
const { rerender } = render( | ||
<TestAtom visible> | ||
<ElementMock /> | ||
</TestAtom>, | ||
); | ||
|
||
expect(animateMock).not.toHaveBeenCalled(); | ||
|
||
rerender( | ||
<TestAtom visible={false}> | ||
<ElementMock /> | ||
</TestAtom>, | ||
); | ||
|
||
expect(animateMock).toHaveBeenCalledWith(transition.exit.keyframes, transition.exit.options); | ||
}); | ||
}); | ||
|
||
describe('unmountOnExit', () => { | ||
it('unmounts when state changes', () => { | ||
const TestAtom = createTransition(transition); | ||
const { animateMock, ElementMock } = createElementMock(); | ||
|
||
const { rerender, queryByText } = render( | ||
<TestAtom visible unmountOnExit> | ||
<ElementMock /> | ||
</TestAtom>, | ||
); | ||
|
||
expect(queryByText('ElementMock')).toBeTruthy(); | ||
expect(animateMock).not.toHaveBeenCalled(); | ||
|
||
rerender( | ||
<TestAtom visible={false} unmountOnExit> | ||
<ElementMock /> | ||
</TestAtom>, | ||
); | ||
|
||
expect(queryByText('ElementMock')).toBe(null); | ||
expect(animateMock).toHaveBeenCalledWith(transition.exit.keyframes, transition.exit.options); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
91 changes: 91 additions & 0 deletions
91
...ct-components/react-motions-preview/stories/CreateTransition/CreateTransition.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
import { makeStyles, shorthands, tokens, Label, Slider, useId, Checkbox } from '@fluentui/react-components'; | ||
import { createTransition, transitions } from '@fluentui/react-motions-preview'; | ||
import * as React from 'react'; | ||
|
||
const useClasses = makeStyles({ | ||
container: { | ||
display: 'grid', | ||
gridTemplateColumns: '1fr', | ||
...shorthands.gap('10px'), | ||
}, | ||
card: { | ||
display: 'flex', | ||
flexDirection: 'column', | ||
|
||
...shorthands.border('3px', 'solid', tokens.colorNeutralForeground3), | ||
...shorthands.borderRadius(tokens.borderRadiusMedium), | ||
...shorthands.padding('10px'), | ||
|
||
alignItems: 'center', | ||
}, | ||
item: { | ||
backgroundColor: tokens.colorBrandBackground, | ||
...shorthands.borderRadius('50%'), | ||
|
||
width: '100px', | ||
height: '100px', | ||
}, | ||
description: { | ||
...shorthands.margin('5px'), | ||
}, | ||
controls: { | ||
display: 'flex', | ||
flexDirection: 'column', | ||
|
||
marginTop: '20px', | ||
|
||
...shorthands.border('3px', 'solid', tokens.colorNeutralForeground3), | ||
...shorthands.borderRadius(tokens.borderRadiusMedium), | ||
...shorthands.padding('10px'), | ||
}, | ||
}); | ||
|
||
const Fade = createTransition(transitions.fade.slow()); | ||
|
||
export const CreateTransition = () => { | ||
const classes = useClasses(); | ||
const sliderId = useId(); | ||
|
||
const [playbackRate, setPlaybackRate] = React.useState<number>(30); | ||
const [visible, setVisible] = React.useState<boolean>(false); | ||
|
||
React.useEffect(() => { | ||
document.getAnimations().forEach(animation => { | ||
animation.playbackRate = playbackRate / 100; | ||
}); | ||
}, [playbackRate, visible]); | ||
|
||
return ( | ||
<> | ||
<div className={classes.container}> | ||
<div className={classes.card}> | ||
<Fade visible={visible}> | ||
<div className={classes.item} /> | ||
</Fade> | ||
|
||
<code className={classes.description}>fadeSlow</code> | ||
</div> | ||
</div> | ||
|
||
<div className={classes.controls}> | ||
<div> | ||
<Checkbox label={<code>visible</code>} checked={visible} onChange={() => setVisible(v => !v)} /> | ||
</div> | ||
<div> | ||
<Label htmlFor={sliderId}> | ||
<code>playbackRate</code>: {playbackRate}% | ||
</Label> | ||
<Slider | ||
aria-valuetext={`Value is ${playbackRate}%`} | ||
value={playbackRate} | ||
onChange={(ev, data) => setPlaybackRate(data.value)} | ||
min={0} | ||
id={sliderId} | ||
max={100} | ||
step={10} | ||
/> | ||
</div> | ||
</div> | ||
</> | ||
); | ||
}; |
9 changes: 9 additions & 0 deletions
9
packages/react-components/react-motions-preview/stories/CreateTransition/index.stories.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
export { CreateTransition as createTransition } from './CreateTransition.stories'; | ||
|
||
export default { | ||
title: 'Utilities/Motions (Preview)', | ||
component: null, | ||
parameters: { | ||
docs: {}, | ||
}, | ||
}; |