Skip to content

Commit 1865fb7

Browse files
authored
feat(react-motion): add support to provide duration to useMotion hook (#29655)
1 parent 8381d2c commit 1865fb7

6 files changed

Lines changed: 89 additions & 34 deletions

File tree

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: add support to provide static duration to useMotion hook",
4+
"packageName": "@fluentui/react-motion-preview",
5+
"email": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type MotionClassNames = {
1717
// @public (undocumented)
1818
export type MotionOptions = {
1919
animateOnFirstMount?: boolean;
20+
duration?: number;
2021
};
2122

2223
// @public (undocumented)
@@ -33,6 +34,9 @@ export type MotionState<Element extends HTMLElement = HTMLElement> = {
3334
active: boolean;
3435
};
3536

37+
// @public (undocumented)
38+
export type MotionStylesKeys = 'default' | 'enter' | 'exit' | MotionType;
39+
3640
// @public (undocumented)
3741
export type MotionType = 'entering' | 'entered' | 'idle' | 'exiting' | 'exited' | 'unmounted';
3842

packages/react-components/react-motion-preview/src/hooks/useMotion.test.ts

Lines changed: 63 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { act, renderHook } from '@testing-library/react-hooks';
22

3-
import { useMotion, MotionOptions, MotionShorthand, getDefaultMotionState } from './useMotion';
3+
import { useMotion, MotionShorthand, MotionOptions, getDefaultMotionState } from './useMotion';
44

5-
const defaultDuration = 100;
5+
const cssDuration = 100;
66
const renderHookWithRef = (
77
initialMotion: MotionShorthand,
88
initialOptions?: MotionOptions,
9-
style: Record<string, string | undefined> = { 'transition-duration': `${defaultDuration}ms` },
9+
style: Record<string, string | undefined> = { 'transition-duration': `${cssDuration}ms` },
1010
) => {
1111
const refEl = document.createElement('div');
1212
const hook = renderHook(({ motion, options }) => useMotion(motion, options), {
@@ -45,7 +45,7 @@ const jumpAnimationFrame = () => {
4545
act(() => jest.advanceTimersToNextTimer());
4646
};
4747

48-
const jumpAnimationTimeout = (timeout: number = defaultDuration) => {
48+
const jumpAnimationTimeout = (timeout: number = cssDuration) => {
4949
// timeout + requestAnimationFrame
5050
act(() => {
5151
jest.advanceTimersByTime(timeout);
@@ -54,16 +54,23 @@ const jumpAnimationTimeout = (timeout: number = defaultDuration) => {
5454
};
5555

5656
describe('useMotion', () => {
57+
let computedStyleMock: jest.SpyInstance;
58+
5759
beforeEach(() => {
5860
jest.useFakeTimers();
61+
computedStyleMock = jest.spyOn(window, 'getComputedStyle').mockImplementation(() => {
62+
return {
63+
getPropertyValue: () => `${cssDuration}ms`,
64+
} as unknown as CSSStyleDeclaration;
65+
});
5966
});
6067

6168
afterEach(() => {
6269
jest.useRealTimers();
6370
jest.resetAllMocks();
6471
});
6572

66-
describe('when motion is received', () => {
73+
describe('when presence is boolean', () => {
6774
it('should sync presence value with canRender', () => {
6875
const { result, rerender } = renderHookWithRef(false);
6976

@@ -73,25 +80,6 @@ describe('useMotion', () => {
7380
expect(result.current.canRender).toBe(true);
7481
});
7582

76-
it('should return default values when presence is false', () => {
77-
const defaultState = getDefaultMotionState();
78-
const { result } = renderHookWithRef(getDefaultMotionState());
79-
80-
expect(result.current.type).toStrictEqual('unmounted');
81-
expect(result.current.ref).toStrictEqual(defaultState.ref);
82-
expect(result.current.active).toStrictEqual(false);
83-
});
84-
85-
it('should return default values when presence is true', () => {
86-
const defaultState = getDefaultMotionState();
87-
const { result } = renderHookWithRef({ ...getDefaultMotionState(), active: true });
88-
89-
expect(result.current.ref).toStrictEqual(defaultState.ref);
90-
expect(result.current.active).toStrictEqual(true);
91-
});
92-
});
93-
94-
describe('when presence is false by default', () => {
9583
it('should return default values when presence is false', () => {
9684
const { result } = renderHookWithRef(false);
9785

@@ -100,10 +88,8 @@ describe('useMotion', () => {
10088
expect(result.current.active).toBe(false);
10189
expect(result.current.canRender).toBe(false);
10290
});
103-
});
10491

105-
describe('when presence is true by default', () => {
106-
it('should return default values', () => {
92+
it('should return default values when presence is true', () => {
10793
const { result } = renderHookWithRef(true);
10894

10995
expect(typeof result.current.ref).toBe('function');
@@ -125,6 +111,54 @@ describe('useMotion', () => {
125111
});
126112
});
127113

114+
describe('when duration is provided', () => {
115+
it('should only call getComputedStyle when duration is not provided', () => {
116+
const { result, rerender } = renderHookWithRef(false, { duration: 300 });
117+
118+
expect(typeof result.current.ref).toBe('function');
119+
expect(result.current.type).toBe('unmounted');
120+
expect(result.current.active).toBe(false);
121+
expect(result.current.canRender).toBe(false);
122+
123+
rerender(true, { duration: 300 });
124+
125+
expect(result.current.canRender).toBe(true);
126+
expect(result.current.type).toBe('entering');
127+
expect(result.current.active).toBe(false);
128+
129+
jumpAnimationFrame();
130+
jumpAnimationTimeout();
131+
132+
expect(computedStyleMock).not.toHaveBeenCalled();
133+
134+
rerender(false);
135+
136+
jumpAnimationFrame();
137+
jumpAnimationTimeout();
138+
139+
expect(computedStyleMock).toHaveBeenCalledTimes(1);
140+
});
141+
});
142+
143+
describe('when presence is a MotionShorthand', () => {
144+
it('should return default values when presence is false', () => {
145+
const defaultState = getDefaultMotionState();
146+
const { result } = renderHookWithRef(defaultState);
147+
148+
expect(result.current.type).toStrictEqual('unmounted');
149+
expect(result.current.ref).toStrictEqual(defaultState.ref);
150+
expect(result.current.active).toStrictEqual(false);
151+
});
152+
153+
it('should return default values when presence is true', () => {
154+
const defaultState = getDefaultMotionState();
155+
const { result } = renderHookWithRef({ ...defaultState, active: true });
156+
157+
expect(result.current.ref).toStrictEqual(defaultState.ref);
158+
expect(result.current.active).toStrictEqual(true);
159+
});
160+
});
161+
128162
describe('when presence changes', () => {
129163
it('should toggle values starting with false', () => {
130164
const { result, rerender } = renderHookWithRef(false);
@@ -252,6 +286,7 @@ describe('useMotion', () => {
252286
jumpAnimationFrame();
253287
expect(result.current.active).toBe(true);
254288

289+
jumpAnimationFrame();
255290
jumpAnimationTimeout(0);
256291
expect(result.current.type).toBe('entered');
257292

@@ -265,6 +300,7 @@ describe('useMotion', () => {
265300
jumpAnimationFrame();
266301
expect(result.current.active).toBe(false);
267302

303+
jumpAnimationFrame();
268304
jumpAnimationTimeout(0);
269305
expect(result.current.type).toBe('exited');
270306

packages/react-components/react-motion-preview/src/hooks/useMotion.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ export type MotionOptions = {
1212
* @default false
1313
*/
1414
animateOnFirstMount?: boolean;
15+
16+
/**
17+
* Duration of the animation in milliseconds.
18+
* If not specified, the duration will be inferred from the CSS transition/animation duration.
19+
*
20+
* @default 0
21+
*/
22+
duration?: number;
1523
};
1624

1725
export type MotionType = 'entering' | 'entered' | 'idle' | 'exiting' | 'exited' | 'unmounted';
@@ -48,7 +56,6 @@ export type MotionState<Element extends HTMLElement = HTMLElement> = {
4856
};
4957

5058
export type MotionShorthandValue = boolean;
51-
5259
export type MotionShorthand<Element extends HTMLElement = HTMLElement> = MotionShorthandValue | MotionState<Element>;
5360

5461
/**
@@ -81,7 +88,7 @@ function useMotionPresence<Element extends HTMLElement>(
8188
presence: boolean,
8289
options: MotionOptions = {},
8390
): MotionState<Element> {
84-
const { animateOnFirstMount } = { animateOnFirstMount: false, ...options };
91+
const { animateOnFirstMount, duration } = { animateOnFirstMount: false, ...options };
8592

8693
const [type, setType] = React.useState<MotionType>(
8794
presence && animateOnFirstMount ? 'entering' : presence ? 'idle' : 'unmounted',
@@ -137,9 +144,9 @@ function useMotionPresence<Element extends HTMLElement>(
137144

138145
// Wait for the next frame to ensure the animation has started.
139146
setAnimationFrame(() => {
140-
const duration = getMotionDuration(currentElement);
147+
const finalDuration = duration || getMotionDuration(currentElement);
141148

142-
if (duration === 0) {
149+
if (finalDuration === 0) {
143150
onFinished();
144151
return;
145152
}
@@ -149,7 +156,7 @@ function useMotionPresence<Element extends HTMLElement>(
149156
* This is an alternative to using the `transitionend` event which can be unreliable as it fires multiple times
150157
* if the transition has multiple properties.
151158
*/
152-
setAnimationTimeout(() => onFinished(), duration);
159+
setAnimationTimeout(() => onFinished(), finalDuration);
153160
});
154161
});
155162

packages/react-components/react-motion-preview/src/hooks/useMotionClassNames.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { renderHook } from '@testing-library/react-hooks';
22

3-
import { getDefaultMotionState, MotionState } from './useMotion';
43
import { useMotionClassNames } from './useMotionClassNames';
54
import * as reducedMotionStyles from '../styles/useReducedMotionStyles.styles';
5+
import { getDefaultMotionState, MotionState } from './useMotion';
66

77
describe('useMotionClassNames', () => {
88
let useReducedMotionStylesMock: jest.SpyInstance;
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
export { getDefaultMotionState, useMotion, useMotionClassNames } from './hooks';
22
export type {
3+
MotionClassNames,
34
MotionOptions,
45
MotionShorthand,
56
MotionShorthandValue,
67
MotionState,
7-
MotionClassNames,
8+
MotionStylesKeys,
89
MotionType,
910
} from './hooks';

0 commit comments

Comments
 (0)