Skip to content

Commit

Permalink
feat(react-motions): add onMotionFinish() to a component created by…
Browse files Browse the repository at this point in the history
… `createPresenceMotion()` (#30529)
  • Loading branch information
layershifter authored Feb 19, 2024
1 parent 2f30e14 commit d8b88c2
Show file tree
Hide file tree
Showing 8 changed files with 205 additions and 12 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "feat: add onMotionFinish() callback to createPresenceComponent()",
"packageName": "@fluentui/react-motions-preview",
"email": "olfedias@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
```ts

import type { EventData } from '@fluentui/react-utilities';
import type { EventHandler } from '@fluentui/react-utilities';
import * as React_2 from 'react';

// @public (undocumented)
Expand All @@ -15,10 +17,10 @@ export type AtomMotion = {
export type AtomMotionFn = (element: HTMLElement) => AtomMotion;

// @public
export function createMotionComponent(motion: AtomMotion | AtomMotionFn): React_2.FC<AtomProps>;
export function createMotionComponent(motion: AtomMotion | AtomMotionFn): React_2.FC<MotionComponentProps>;

// @public (undocumented)
export function createPresenceComponent(motion: PresenceMotion | PresenceMotionFn): React_2.FC<PresenceProps>;
export function createPresenceComponent(motion: PresenceMotion | PresenceMotionFn): React_2.FC<PresenceComponentProps>;

// @public (undocumented)
const durationFast = 150;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useMotionImperativeRef } from '../hooks/useMotionImperativeRef';
import { getChildElement } from '../utils/getChildElement';
import type { AtomMotion, AtomMotionFn, MotionImperativeRef } from '../types';

export type AtomProps = {
type MotionComponentProps = {
children: React.ReactElement;

/** Provides imperative controls for the animation. */
Expand All @@ -21,7 +21,7 @@ export type AtomProps = {
* @param motion - A motion definition.
*/
export function createMotionComponent(motion: AtomMotion | AtomMotionFn) {
const Atom: React.FC<AtomProps> = props => {
const Atom: React.FC<MotionComponentProps> = props => {
const { children, iterations = 1, imperativeRef } = props;

const child = getChildElement(children);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,44 @@
import { useEventCallback, useIsomorphicLayoutEffect, useMergedRefs } from '@fluentui/react-utilities';
import type { EventData, EventHandler } from '@fluentui/react-utilities';
import * as React from 'react';

import { useIsReducedMotion } from '../hooks/useIsReducedMotion';
import { useMotionImperativeRef } from '../hooks/useMotionImperativeRef';
import { getChildElement } from '../utils/getChildElement';
import type { PresenceMotion, MotionImperativeRef, PresenceMotionFn } from '../types';

type PresenceProps = {
type PresenceMotionEventData = EventData<'animation', AnimationPlaybackEvent> & {
direction: 'enter' | 'exit';
};

type PresenceComponentProps = {
/**
* By default, the child component won't execute the "enter" motion when it initially mounts, regardless of the value
* of "visible". If you desire this behavior, ensure both "appear" and "visible" are set to "true".
*/
appear?: boolean;

/** A React element that will be cloned and will have motion effects applied to it. */
children: React.ReactElement;

/** Provides imperative controls for the animation. */
imperativeRef?: React.Ref<MotionImperativeRef | undefined>;

appear?: boolean;
onMotionFinish?: EventHandler<PresenceMotionEventData>;

/** Defines whether a component is visible; triggers the "enter" or "exit" motions. */
visible?: boolean;

/**
* By default, the child component remains mounted after it reaches the "finished" state. Set "unmountOnExit" if
* you prefer to unmount the component after it finishes exiting.
*/
unmountOnExit?: boolean;
};

export function createPresenceComponent(motion: PresenceMotion | PresenceMotionFn) {
const Presence: React.FC<PresenceProps> = props => {
const { appear, children, imperativeRef, visible, unmountOnExit } = props;
const Presence: React.FC<PresenceComponentProps> = props => {
const { appear, children, imperativeRef, onMotionFinish, visible, unmountOnExit } = props;

const child = getChildElement(children);

Expand All @@ -33,7 +51,12 @@ export function createPresenceComponent(motion: PresenceMotion | PresenceMotionF
const isFirstMount = React.useRef<boolean>(true);
const isReducedMotion = useIsReducedMotion();

const onExitFinish = useEventCallback(() => {
const onEnterFinish = useEventCallback((event: AnimationPlaybackEvent) => {
onMotionFinish?.(event, { event, type: 'animation', direction: 'enter' });
});
const onExitFinish = useEventCallback((event: AnimationPlaybackEvent) => {
onMotionFinish?.(event, { event, type: 'animation', direction: 'exit' });

if (unmountOnExit) {
setMounted(false);
}
Expand Down Expand Up @@ -92,12 +115,13 @@ export function createPresenceComponent(motion: PresenceMotion | PresenceMotionF
});

animationRef.current = animation;
animation.onfinish = onEnterFinish;

return () => {
animation.cancel();
};
}
}, [animationRef, isReducedMotion, mounted, visible, appear]);
}, [animationRef, appear, isReducedMotion, mounted, onEnterFinish, visible]);

useIsomorphicLayoutEffect(() => {
isFirstMount.current = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,14 @@ export const CreateMotionComponent = () => {
<div className={classes.item} />
</FadeEnter>

<code className={classes.description}>fadeEnterUltraSlow</code>
<code className={classes.description}>fadeEnter</code>
</div>
<div className={classes.card}>
<FadeExit iterations={Infinity} imperativeRef={motionExitRef}>
<div className={classes.item} />
</FadeExit>

<div className={classes.description}>fadeExitUltraSlow</div>
<div className={classes.description}>fadeExit</div>
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A React component created with `createPresenceMotion()` has an `onFinishMotion` prop. This callback will be called when a motion is finished and is useful for orchestrating motions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { makeStyles, shorthands, tokens, Label, Slider, useId, Checkbox, Text } from '@fluentui/react-components';
import { createPresenceComponent, motionTokens } from '@fluentui/react-motions-preview';
import type { MotionImperativeRef } from '@fluentui/react-motions-preview';
import * as React from 'react';

import description from './PresenceOnMotionFinish.stories.md';

const useClasses = makeStyles({
container: {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
...shorthands.gap('10px'),
},
card: {
display: 'flex',
flexDirection: 'column',

...shorthands.border('3px', 'solid', tokens.colorNeutralForeground3),
...shorthands.borderRadius(tokens.borderRadiusMedium),
...shorthands.padding('10px'),

alignItems: 'center',
justifyContent: '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'),
},

logContainer: {
display: 'flex',
flexDirection: 'column',
},
logLabel: {
color: tokens.colorNeutralForegroundOnBrand,
backgroundColor: tokens.colorNeutralForeground3,
width: 'fit-content',
alignSelf: 'end',
fontWeight: tokens.fontWeightBold,
...shorthands.padding('2px', '12px'),
...shorthands.borderRadius(tokens.borderRadiusMedium, tokens.borderRadiusMedium, 0, 0),
},
log: {
overflowY: 'auto',
position: 'relative',
height: '200px',
...shorthands.border('3px', 'solid', tokens.colorNeutralForeground3),
...shorthands.borderRadius(tokens.borderRadiusMedium),
borderTopRightRadius: 0,
...shorthands.padding('10px'),
},
});

const Fade = createPresenceComponent({
enter: {
keyframes: [{ opacity: 0 }, { opacity: 1 }],
duration: motionTokens.durationSlow,
},
exit: {
keyframes: [{ opacity: 1 }, { opacity: 0 }],
duration: motionTokens.durationSlow,
},
});

export const PresenceOnMotionFinish = () => {
const classes = useClasses();
const sliderId = useId();
const logLabelId = useId();

const motionRef = React.useRef<MotionImperativeRef>();
const [statusLog, setStatusLog] = React.useState<[number, string][]>([]);

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}>
<Fade
imperativeRef={motionRef}
onMotionFinish={(ev, data) => {
setStatusLog(entries => [[Date.now(), data.direction], ...entries]);
}}
visible={visible}
>
<div className={classes.item} />
</Fade>

<code className={classes.description}>fade</code>
</div>

<div className={classes.logContainer}>
<div className={classes.logLabel} id={logLabelId}>
Status log
</div>
<div role="log" aria-labelledby={logLabelId} className={classes.log}>
{statusLog.map(([time, direction], i) => (
<div key={i}>
{new Date(time).toLocaleTimeString()} <Text weight="bold">onMotionFinish</Text> (direction: {direction})
</div>
))}
</div>
</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>
</>
);
};

PresenceOnMotionFinish.parameters = {
docs: {
description: {
story: description,
},
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export { CreatePresenceComponent as createPresenceComponent } from './CreatePres

export { PresenceAppear as appear } from './PresenceAppear.stories';
export { PresenceUnmountOnExit as unmountOnExit } from './PresenceUnmountOnExit.stories';
export { PresenceOnMotionFinish as onMotionFinish } from './PresenceOnMotionFinish.stories';

export { ImperativeRefPlayState as setPlayState } from './ImperativeRefPlayState.stories';

Expand Down

0 comments on commit d8b88c2

Please sign in to comment.