Skip to content

Commit

Permalink
feat(motion): add Collapse motion component (#31982)
Browse files Browse the repository at this point in the history
Co-authored-by: Oleksandr Fediashov <[email protected]>
  • Loading branch information
pixel-perfectionist and layershifter authored Jul 15, 2024
1 parent 8070f26 commit 8082468
Show file tree
Hide file tree
Showing 17 changed files with 517 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@
```ts

import { PresenceComponent } from '@fluentui/react-motion';

// @public
export const Collapse: PresenceComponent< {
animateOpacity?: boolean | undefined;
}>;

// @public (undocumented)
export const CollapseExaggerated: PresenceComponent< {
animateOpacity?: boolean | undefined;
}>;

// @public (undocumented)
export const CollapseSnappy: PresenceComponent< {
animateOpacity?: boolean | undefined;
}>;

// (No @packageDocumentation comment for this package)

```
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@fluentui/scripts-tasks": "*"
},
"dependencies": {
"@fluentui/react-motion": "*",
"@swc/helpers": "^0.5.1"
},
"peerDependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { expectPresenceMotionFunction, expectPresenceMotionObject } from '../../testing/testUtils';
import { Collapse } from './Collapse';

describe('Collapse', () => {
it('stores its motion definition as a static function', () => {
expectPresenceMotionFunction(Collapse);
});

it('generates a motion definition from the static function', () => {
expectPresenceMotionObject(Collapse);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
motionTokens,
type PresenceMotionFn,
createPresenceComponent,
createPresenceComponentVariant,
} from '@fluentui/react-motion';

/** Define a presence motion for collapse/expand */
const collapseMotion: PresenceMotionFn<{ animateOpacity?: boolean }> = ({ element, animateOpacity = true }) => {
const fromOpacity = animateOpacity ? 0 : 1;
const toOpacity = 1;
const fromHeight = '0'; // Could be a custom param in the future: start partially expanded
const toHeight = `${element.scrollHeight}px`;
const overflow = 'hidden';

const duration = motionTokens.durationNormal;
const easing = motionTokens.curveEasyEaseMax;

const enterKeyframes = [
{ opacity: fromOpacity, maxHeight: fromHeight, overflow },
// Transition to the height of the content, at 99.99% of the duration.
{ opacity: toOpacity, maxHeight: toHeight, offset: 0.9999, overflow },
// On completion, remove the maxHeight because the content might need to expand later.
// This extra keyframe is simpler than firing a callback on completion.
{ opacity: toOpacity, maxHeight: 'unset', overflow },
];

const exitKeyframes = [
{ opacity: toOpacity, maxHeight: toHeight, overflow },
{ opacity: fromOpacity, maxHeight: fromHeight, overflow },
];

return {
enter: { duration, easing, keyframes: enterKeyframes },
exit: { duration, easing, keyframes: exitKeyframes },
};
};

/** A React component that applies collapse/expand transitions to its children. */
export const Collapse = createPresenceComponent(collapseMotion);

export const CollapseSnappy = createPresenceComponentVariant(Collapse, {
all: { duration: motionTokens.durationUltraFast },
});

export const CollapseExaggerated = createPresenceComponentVariant(Collapse, {
enter: { duration: motionTokens.durationSlow, easing: motionTokens.curveEasyEaseMax },
exit: { duration: motionTokens.durationNormal, easing: motionTokens.curveEasyEaseMax },
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Collapse';
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export {};
export { Collapse, CollapseSnappy, CollapseExaggerated } from './components/Collapse';
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { PresenceComponent, PresenceMotionFn } from '@fluentui/react-motion';

function getMotionFunction(component: PresenceComponent): PresenceMotionFn | null {
const symbols = Object.getOwnPropertySymbols(component);

for (const symbol of symbols) {
if (symbol.toString() === 'Symbol(MOTION_DEFINITION)') {
// @ts-expect-error symbol can't be used as an index there, type casting is also not possible
return component[symbol];
}
}

return null;
}

export function expectPresenceMotionObject(component: PresenceComponent) {
const presenceMotionFn = getMotionFunction(component);

// eslint-disable-next-line no-restricted-globals
expect(presenceMotionFn?.({ element: document.createElement('div') })).toMatchObject({
enter: expect.objectContaining({
duration: expect.any(Number),
easing: expect.any(String),
keyframes: expect.any(Array),
}),
exit: expect.objectContaining({
duration: expect.any(Number),
easing: expect.any(String),
keyframes: expect.any(Array),
}),
});
}

export function expectPresenceMotionFunction(PresenceComponent: PresenceComponent) {
const presenceMotionFn = getMotionFunction(PresenceComponent);

expect(presenceMotionFn).toBeInstanceOf(Function);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"test-ssr": "test-ssr \"./src/**/*.stories.tsx\""
},
"devDependencies": {
"@fluentui/react-components": "*",
"@fluentui/react-motion-components-preview": "*",
"@fluentui/react-storybook-addon": "*",
"@fluentui/react-storybook-addon-export-to-sandbox": "*",
"@fluentui/scripts-storybook": "*",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
- `duration` and `easing` can be customized for each transition separately using `createPresenceComponentVariant()`.
- The predefined fade transition can be disabled by setting `animateOpacity` to `false`.

```tsx
import { motionTokens, createPresenceComponentVariant } from '@fluentui/react-components';
import { Collapse } from '@fluentui/react-motion-components-preview';

const CustomCollapseVariant = createPresenceComponentVariant(Collapse, {
enter: { duration: motionTokens.durationSlow, easing: motionTokens.curveEasyEaseMax },
exit: { duration: motionTokens.durationNormal, easing: motionTokens.curveEasyEaseMax },
});

const CustomCollapse = ({ visible }) => (
<CustomCollapseVariant animateOpacity={false} unmountOnExit visible={visible}>
{/* Content */}
</CustomCollapseVariant>
);
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import * as React from 'react';
import {
createPresenceComponentVariant,
Field,
makeStyles,
mergeClasses,
type MotionImperativeRef,
motionTokens,
Slider,
Switch,
tokens,
} from '@fluentui/react-components';
import { Collapse } from '@fluentui/react-motion-components-preview';

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

const { curveEasyEaseMax, durationSlow, durationNormal } = motionTokens;

const useClasses = makeStyles({
container: {
display: 'grid',
gridTemplate: `"controls ." "card card" / 1fr 1fr`,
gap: '20px 10px',
},
card: {
gridArea: 'card',
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}`,
borderRadius: '50%',

width: '100px',
height: '100px',
},
});

const CustomCollapseVariant = createPresenceComponentVariant(Collapse, {
enter: { duration: durationSlow, easing: curveEasyEaseMax },
exit: { duration: durationNormal, easing: curveEasyEaseMax },
});

const LoremIpsum = () => (
<>
{'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. '.repeat(
10,
)}
</>
);

export const Customization = () => {
const classes = useClasses();
const motionRef = React.useRef<MotionImperativeRef>();

const [animateOpacity, setAnimateOpacity] = React.useState(true);
const [playbackRate, setPlaybackRate] = React.useState<number>(30);
const [visible, setVisible] = React.useState<boolean>(true);
const [unmountOnExit, setUnmountOnExit] = React.useState<boolean>(false);

// 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.controls}>
<Field className={classes.field}>
<Switch label="Visible" checked={visible} onChange={() => setVisible(v => !v)} />
</Field>
<Field className={classes.field}>
<Switch
label={<code>animateOpacity</code>}
checked={animateOpacity}
onChange={() => setAnimateOpacity(v => !v)}
/>
</Field>
<Field className={classes.field}>
<Switch
label={<code>unmountOnExit</code>}
checked={unmountOnExit}
onChange={() => setUnmountOnExit(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>

<CustomCollapseVariant
animateOpacity={animateOpacity}
imperativeRef={motionRef}
visible={visible}
unmountOnExit={unmountOnExit}
>
<div className={classes.card}>
<LoremIpsum />
</div>
</CustomCollapseVariant>
</div>
);
};

Customization.parameters = {
docs: {
description: {
story: description,
},
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Field, makeStyles, tokens, Switch } from '@fluentui/react-components';
import { Collapse } from '@fluentui/react-motion-components-preview';
import * as React from 'react';

const useClasses = makeStyles({
container: {
display: 'grid',
gridTemplate: `"controls ." "card card" / 1fr 1fr`,
gap: '20px 10px',
},
card: {
gridArea: 'card',
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,
},
});

const LoremIpsum = () => (
<>
{'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. '.repeat(
10,
)}
</>
);

export const Default = () => {
const classes = useClasses();
const [visible, setVisible] = React.useState<boolean>(false);

return (
<div className={classes.container}>
<div className={classes.controls}>
<Field className={classes.field}>
<Switch label="Visible" checked={visible} onChange={() => setVisible(v => !v)} />
</Field>
</div>

<Collapse visible={visible}>
<div className={classes.card}>
<LoremIpsum />
</div>
</Collapse>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
The `Collapse` component manages content presence, using a height expand/collapse motion.

> **⚠️ Preview components are considered unstable**
```tsx
import { Collapse } from '@fluentui/react-motion-components-preview';

function Component({ visible }) {
return (
<Collapse visible={visible}>
<div style={{ background: 'lightblue' }}>Content</div>
</Collapse>
);
}
```
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The exaggerated variant of `Collapse` is available as `CollapseExaggerated` component.
Loading

0 comments on commit 8082468

Please sign in to comment.