Skip to content

Commit

Permalink
feat(react-motions): add createTransition() factory (#29839)
Browse files Browse the repository at this point in the history
* feat(react-motions): add createTransition() factory

* use transition definitions

* apply review suggestions
  • Loading branch information
layershifter authored Nov 16, 2023
1 parent abb2dc5 commit 0f56cc9
Show file tree
Hide file tree
Showing 7 changed files with 327 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export { atoms }
// @public
export function createAtom(motion: MotionAtom): React_2.FC<AtomProps>;

// @public (undocumented)
export function createTransition(transition: MotionTransition): React_2.FC<TransitionProps>;

// @public (undocumented)
const downEnterFast: ({ fromValue }?: SlideParams) => MotionAtom;

Expand Down
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;
}
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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as atoms from './atoms';
import * as transitions from './transitions';

export { createAtom } from './factories/createAtom';
export { createTransition } from './factories/createTransition';

export { atoms, transitions };
export type { MotionAtom, MotionTransition } from './types';
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ const useClasses = makeStyles({
},
});

const FadeEnter = createAtom(atoms.fade.enterUltraSlow({}));
const FadeExit = createAtom(atoms.fade.exitUltraSlow({}));
const FadeEnter = createAtom(atoms.fade.enterUltraSlow());
const FadeExit = createAtom(atoms.fade.exitUltraSlow());

export const CreateAtom = () => {
const classes = useClasses();
Expand Down
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>
</>
);
};
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: {},
},
};

0 comments on commit 0f56cc9

Please sign in to comment.