Skip to content

Commit 2cdbbe9

Browse files
feat(motion): add createMotionComponentVariant factory (microsoft#35149)
Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: robertpenner <[email protected]>
1 parent 2ee14c1 commit 2cdbbe9

16 files changed

+294
-16
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "feat(motion): add createMotionComponentVariant factory",
4+
"packageName": "@fluentui/react-components",
5+
"email": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "feat(motion): add createMotionComponentVariant factory",
4+
"packageName": "@fluentui/react-motion",
5+
"email": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}

packages/react-components/react-components/etc/react-components.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ import { CreateFocusOutlineStyleOptions } from '@fluentui/react-tabster';
284284
import { createHighContrastTheme } from '@fluentui/react-theme';
285285
import { createLightTheme } from '@fluentui/react-theme';
286286
import { createMotionComponent } from '@fluentui/react-motion';
287+
import { createMotionComponentVariant } from '@fluentui/react-motion';
287288
import { createPresenceComponent } from '@fluentui/react-motion';
288289
import { createPresenceComponentVariant } from '@fluentui/react-motion';
289290
import { createTableColumn } from '@fluentui/react-table';
@@ -2580,6 +2581,8 @@ export { createLightTheme }
25802581

25812582
export { createMotionComponent }
25822583

2584+
export { createMotionComponentVariant }
2585+
25832586
export { createPresenceComponent }
25842587

25852588
export { createPresenceComponentVariant }

packages/react-components/react-components/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1904,6 +1904,7 @@ export type {
19041904
export {
19051905
motionTokens,
19061906
createMotionComponent,
1907+
createMotionComponentVariant,
19071908
createPresenceComponent,
19081909
createPresenceComponentVariant,
19091910
PresenceGroup,

packages/react-components/react-motion-components-preview/library/src/testing/testUtils.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import type { PresenceComponent, PresenceMotionFn } from '@fluentui/react-motion';
22

3-
function getMotionFunction(component: PresenceComponent): PresenceMotionFn | null {
3+
function getPresenceMotionFunction(component: PresenceComponent): PresenceMotionFn | null {
44
const symbols = Object.getOwnPropertySymbols(component);
55

66
for (const symbol of symbols) {
7-
if (symbol.toString() === 'Symbol(MOTION_DEFINITION)') {
7+
if (symbol.toString() === 'Symbol(PRESENCE_MOTION_DEFINITION)') {
88
// @ts-expect-error symbol can't be used as an index there, type casting is also not possible
99
return component[symbol];
1010
}
@@ -14,7 +14,7 @@ function getMotionFunction(component: PresenceComponent): PresenceMotionFn | nul
1414
}
1515

1616
export function expectPresenceMotionObject(component: PresenceComponent): void {
17-
const presenceMotionFn = getMotionFunction(component);
17+
const presenceMotionFn = getPresenceMotionFunction(component);
1818

1919
expect(
2020
presenceMotionFn?.({
@@ -39,7 +39,7 @@ export function expectPresenceMotionObject(component: PresenceComponent): void {
3939
}
4040

4141
export function expectPresenceMotionArray(component: PresenceComponent): void {
42-
const presenceMotionFn = getMotionFunction(component);
42+
const presenceMotionFn = getPresenceMotionFunction(component);
4343

4444
// eslint-disable-next-line @nx/workspace-no-restricted-globals
4545
expect(presenceMotionFn?.({ element: document.createElement('div') })).toMatchObject({
@@ -63,7 +63,7 @@ export function expectPresenceMotionArray(component: PresenceComponent): void {
6363
}
6464

6565
export function expectPresenceMotionFunction(PresenceComponent: PresenceComponent): void {
66-
const presenceMotionFn = getMotionFunction(PresenceComponent);
66+
const presenceMotionFn = getPresenceMotionFunction(PresenceComponent);
6767

6868
expect(presenceMotionFn).toBeInstanceOf(Function);
6969
}

packages/react-components/react-motion/library/etc/react-motion.api.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ export type AtomMotionFn<MotionParams extends Record<string, MotionParam> = {}>
2121
} & MotionParams) => AtomMotion | AtomMotion[];
2222

2323
// @public
24-
export function createMotionComponent<MotionParams extends Record<string, MotionParam> = {}>(value: AtomMotion | AtomMotion[] | AtomMotionFn<MotionParams>): React_2.FC<MotionComponentProps & MotionParams>;
24+
export function createMotionComponent<MotionParams extends Record<string, MotionParam> = {}>(value: AtomMotion | AtomMotion[] | AtomMotionFn<MotionParams>): MotionComponent<MotionParams>;
25+
26+
// @public
27+
export function createMotionComponentVariant<MotionParams extends Record<string, MotionParam> = {}>(component: MotionComponent<MotionParams>, variantParams: Partial<MotionParams>): MotionComponent<MotionParams>;
2528

2629
// @public (undocumented)
2730
export function createPresenceComponent<MotionParams extends Record<string, MotionParam> = {}>(value: PresenceMotion | PresenceMotionFn<MotionParams>): PresenceComponent<MotionParams>;
@@ -57,6 +60,11 @@ export const durations: {
5760
// @public (undocumented)
5861
export const MotionBehaviourProvider: React_2.Provider<MotionBehaviourType | undefined>;
5962

63+
// @public (undocumented)
64+
export type MotionComponent<MotionParams extends Record<string, MotionParam> = {}> = React_2.FC<MotionComponentProps & MotionParams> & {
65+
[MOTION_DEFINITION]: AtomMotionFn<MotionParams>;
66+
};
67+
6068
// @public (undocumented)
6169
export type MotionComponentProps = {
6270
children: JSXElement;
@@ -99,7 +107,7 @@ export const motionTokens: {
99107
// @public (undocumented)
100108
export type PresenceComponent<MotionParams extends Record<string, MotionParam> = {}> = React_2.FC<PresenceComponentProps & MotionParams> & {
101109
(props: PresenceComponentProps & MotionParams): JSXElement | null;
102-
[MOTION_DEFINITION]: PresenceMotionFn<MotionParams>;
110+
[PRESENCE_MOTION_DEFINITION]: PresenceMotionFn<MotionParams>;
103111
In: React_2.FC<MotionComponentProps & MotionParams>;
104112
Out: React_2.FC<MotionComponentProps & MotionParams>;
105113
};

packages/react-components/react-motion/library/src/factories/createMotionComponent.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import { useChildElement } from '../utils/useChildElement';
99
import type { AtomMotion, AtomMotionFn, MotionParam, MotionImperativeRef } from '../types';
1010
import { useMotionBehaviourContext } from '../contexts/MotionBehaviourContext';
1111

12+
/**
13+
* @internal A private symbol to store the motion definition on the component for variants.
14+
*/
15+
export const MOTION_DEFINITION = Symbol('MOTION_DEFINITION');
16+
1217
export type MotionComponentProps = {
1318
children: JSXElement;
1419

@@ -44,14 +49,20 @@ export type MotionComponentProps = {
4449
onMotionStart?: (ev: null) => void;
4550
};
4651

52+
export type MotionComponent<MotionParams extends Record<string, MotionParam> = {}> = React.FC<
53+
MotionComponentProps & MotionParams
54+
> & {
55+
[MOTION_DEFINITION]: AtomMotionFn<MotionParams>;
56+
};
57+
4758
/**
4859
* Creates a component that will animate the children using the provided motion.
4960
*
5061
* @param value - A motion definition.
5162
*/
5263
export function createMotionComponent<MotionParams extends Record<string, MotionParam> = {}>(
5364
value: AtomMotion | AtomMotion[] | AtomMotionFn<MotionParams>,
54-
): React.FC<MotionComponentProps & MotionParams> {
65+
): MotionComponent<MotionParams> {
5566
const Atom: React.FC<MotionComponentProps & MotionParams> = props => {
5667
'use no memo';
5768

@@ -118,5 +129,9 @@ export function createMotionComponent<MotionParams extends Record<string, Motion
118129
return child;
119130
};
120131

121-
return Atom;
132+
return Object.assign(Atom, {
133+
// Heads up!
134+
// Always normalize it to a function to simplify types
135+
[MOTION_DEFINITION]: typeof value === 'function' ? value : () => value,
136+
});
122137
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import * as React from 'react';
2+
import { render } from '@testing-library/react';
3+
4+
import type { AtomMotionFn } from '../types';
5+
import { createMotionComponent } from './createMotionComponent';
6+
import { createMotionComponentVariant } from './createMotionComponentVariant';
7+
8+
jest.mock('./createMotionComponent', () => {
9+
const module = jest.requireActual('./createMotionComponent');
10+
11+
return {
12+
...module,
13+
createMotionComponent: jest.fn().mockImplementation(module.createMotionComponent),
14+
};
15+
});
16+
17+
const MOTION_FUNCTION: AtomMotionFn<{ opacity?: number; duration?: number; easing?: string }> = ({
18+
opacity = 0,
19+
duration = 1000,
20+
easing = 'linear',
21+
}) => ({
22+
keyframes: [{ opacity }, { opacity: 1 }],
23+
duration,
24+
easing,
25+
});
26+
const MOTION_COMPONENT = createMotionComponent(MOTION_FUNCTION);
27+
28+
const MOTION_PARAMS = {
29+
element: document.createElement('div'),
30+
};
31+
32+
describe('createMotionComponentVariant', () => {
33+
it('overrides motion parameters used within motion function', () => {
34+
// variant params overriding the default motion params
35+
const opacity = 0.3;
36+
const duration = 500;
37+
const easing = 'ease-in-out';
38+
39+
const MotionVariant = createMotionComponentVariant(MOTION_COMPONENT, {
40+
opacity,
41+
duration,
42+
easing,
43+
});
44+
const overrideFn = (createMotionComponent as jest.Mock).mock.calls[0][0];
45+
46+
const { getByText } = render(
47+
<MotionVariant>
48+
<div>Hello world!</div>
49+
</MotionVariant>,
50+
);
51+
52+
expect(MotionVariant).not.toBe(MOTION_COMPONENT);
53+
expect(getByText('Hello world!')).toBeInTheDocument();
54+
55+
expect(createMotionComponent).toHaveBeenCalledTimes(1);
56+
expect(createMotionComponent).toHaveBeenCalledWith(expect.any(Function));
57+
58+
expect(overrideFn).toBeInstanceOf(Function);
59+
expect(overrideFn(MOTION_PARAMS)).toEqual({
60+
keyframes: [{ opacity }, { opacity: 1 }],
61+
duration,
62+
easing,
63+
});
64+
});
65+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { MotionParam, AtomMotionFn } from '../types';
2+
import { MOTION_DEFINITION, createMotionComponent, MotionComponent } from './createMotionComponent';
3+
4+
/**
5+
* @internal
6+
*
7+
* Create a variant function that wraps a motion function to customize it.
8+
* The new motion function has the supplied variant params as defaults,
9+
* but these can still be overridden by runtime params when the new function is called.
10+
*/
11+
export function createMotionFnVariant<MotionParams extends Record<string, MotionParam> = {}>(
12+
motionFn: AtomMotionFn<MotionParams>,
13+
variantParams: Partial<MotionParams>,
14+
): typeof motionFn {
15+
const variantFn: typeof motionFn = runtimeParams => motionFn({ ...variantParams, ...runtimeParams });
16+
return variantFn;
17+
}
18+
19+
/**
20+
* Create a new motion component based on another motion component,
21+
* using the provided variant parameters as defaults.
22+
*
23+
* @param component - A component created by `createMotionComponent`.
24+
* @param variantParams - An object containing the variant parameters to be used as defaults.
25+
* The variant parameters should match the type of the component's motion parameters.
26+
* @returns A new motion component that uses the provided variant parameters as defaults.
27+
* The new component can still accept runtime parameters that override the defaults.
28+
*/
29+
export function createMotionComponentVariant<MotionParams extends Record<string, MotionParam> = {}>(
30+
component: MotionComponent<MotionParams>,
31+
variantParams: Partial<MotionParams>,
32+
): MotionComponent<MotionParams> {
33+
const originalFn = component[MOTION_DEFINITION];
34+
// The variant params become new defaults, but they can still be overridden by runtime params.
35+
const variantFn = createMotionFnVariant(originalFn, variantParams);
36+
return createMotionComponent(variantFn);
37+
}

packages/react-components/react-motion/library/src/factories/createPresenceComponent.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { createMotionComponent, MotionComponentProps } from './createMotionCompo
2222
/**
2323
* @internal A private symbol to store the motion definition on the component for variants.
2424
*/
25-
export const MOTION_DEFINITION = Symbol('MOTION_DEFINITION');
25+
export const PRESENCE_MOTION_DEFINITION = Symbol('PRESENCE_MOTION_DEFINITION');
2626

2727
export type PresenceComponentProps = {
2828
/**
@@ -80,7 +80,7 @@ export type PresenceComponent<MotionParams extends Record<string, MotionParam> =
8080
PresenceComponentProps & MotionParams
8181
> & {
8282
(props: PresenceComponentProps & MotionParams): JSXElement | null;
83-
[MOTION_DEFINITION]: PresenceMotionFn<MotionParams>;
83+
[PRESENCE_MOTION_DEFINITION]: PresenceMotionFn<MotionParams>;
8484
In: React.FC<MotionComponentProps & MotionParams>;
8585
Out: React.FC<MotionComponentProps & MotionParams>;
8686
};
@@ -248,7 +248,7 @@ export function createPresenceComponent<MotionParams extends Record<string, Moti
248248
{
249249
// Heads up!
250250
// Always normalize it to a function to simplify types
251-
[MOTION_DEFINITION]: typeof value === 'function' ? value : () => value,
251+
[PRESENCE_MOTION_DEFINITION]: typeof value === 'function' ? value : () => value,
252252
},
253253
{
254254
// Wrap `enter` in its own motion component as a static method, e.g. <Fade.In>

0 commit comments

Comments
 (0)