Skip to content

Commit d8c546d

Browse files
committed
Update README and examples
1 parent d342ca9 commit d8c546d

File tree

9 files changed

+198
-3
lines changed

9 files changed

+198
-3
lines changed

README.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,83 @@ it('prints the size of the div', () => {
227227

228228
Triggers all resize observer callbacks for all observers that observe the passed elements
229229

230+
## Mock Web Animations API
231+
232+
_Warning: **experimental**, bug reports, tests and feedback are greatly appreciated_
233+
234+
Mocks WAAPI functionality using `requestAnimationFrame`. With one important limitation — there are no style interpolations. Each frame applies the closest keyframe from list of passed keyframes or a generated "initial keyframe" if only one keyframe is passed (initial keyframe removes/restores all the properties set by the one keyframe passed). As the implementation is based on the [official spec](https://www.w3.org/TR/web-animations-1/) it should support the majority of cases, but the test suite is far from complete, so _here be dragons_
235+
236+
Example, using `React Testing Library`:
237+
238+
```jsx
239+
import { mockAnimationsApi } from 'jsdom-testing-mocks';
240+
241+
const TestComponent = () => {
242+
const [isShown, setIsShown] = useState(false);
243+
244+
return (
245+
<div>
246+
{/* AnimatePresence is a component that adds its children in the dom
247+
and fades it in using WAAPI, with 2 keyframes: [{ opacity: 0 }, { opacity: 1 }],
248+
also adding a div with the word "Done!" after the animation has finished
249+
You can find implementation in examples
250+
*/}
251+
<AnimatePresence>{isShown && <div>Hehey!</div>}</AnimatePresence>
252+
<button
253+
onClick={() => {
254+
setIsShown(true);
255+
}}
256+
>
257+
Show
258+
</button>
259+
</div>
260+
);
261+
};
262+
263+
mockAnimationsApi();
264+
265+
it('adds an element into the dom and fades it in', async () => {
266+
render(<TestComponent />);
267+
268+
expect(screen.queryByText('Hehey!')).not.toBeInTheDocument();
269+
270+
await userEvent.click(screen.getByText('Show'));
271+
272+
// assume there's only one animation present in the document at this point
273+
// in practice it's better to get the running animation from the element itself
274+
const element = screen.getByText('Hehey!');
275+
const animation = document.getAnimations()[0];
276+
277+
// our AnimatePresence implementation has 2 keyframes: opacity: 0 and opacity: 1
278+
// which allows us to test the visibility of the element, the first keyframe
279+
// is applied right after the animation is ready
280+
await animation.ready;
281+
282+
expect(element).not.toBeVisible();
283+
284+
// this test will pass right after 50% of the animation is complete
285+
// because this mock doesn't interpolate keyframes values,
286+
// but chooses the closest one at each frame
287+
await waitFor(() => {
288+
expect(element).toBeVisible();
289+
});
290+
291+
// AnimatePresence will also add a div with the text 'Done!' after animation is complete
292+
await waitFor(() => {
293+
expect(screen.getByText('Done!')).toBeInTheDocument();
294+
});
295+
});
296+
```
297+
298+
### Using with fake timers
299+
300+
It's perfectly usable with fake timers, except for the [issue with promises](https://github.com/facebook/jest/issues/2157). Also note that you would need to manually advance timers by the duration of the animation taking frame duration (which currently is set to 16ms in `jest`/`sinon.js`) into account. So if you, say, have an animation with a duration of `300ms`, you will need to advance your timers by the value that is at least the closest multiple of the frame duration, which in this case is `304ms` (`19` frames \* `16ms`). Otherwise the last frame may not fire and the animation won't finish.
301+
302+
### Current issues
303+
304+
- No support for `steps` easings
305+
- Needs more tests
306+
230307
<!-- prettier-ignore-start -->
231308

232309
[version-badge]: https://img.shields.io/npm/v/jsdom-testing-mocks.svg?style=flat-square

examples/src/App.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import PrintMySize from './components/resize-observer/print-my-size/PrintMySize'
88
import CustomUseMedia from './components/viewport/custom-use-media/CustomUseMedia';
99
import DeprecatedCustomUseMedia from './components/viewport/deprecated-use-media/DeprecatedUseMedia';
1010
import { Layout } from './components/animations/Layout';
11-
import AnimationsInView from './components/animations/examples/InView';
11+
import AnimationsInView from './components/animations/examples/inview/InView';
12+
import AnimationsAnimatePresence from './components/animations/examples/animate-presence/AnimatePresence';
1213
import AnimationsIndex from './components/animations';
1314

1415
function Index() {
@@ -33,6 +34,10 @@ function App() {
3334
/>
3435
<Route path="/animations" element={<Layout />}>
3536
<Route path="in-view" element={<AnimationsInView />} />
37+
<Route
38+
path="animate-presence"
39+
element={<AnimationsAnimatePresence />}
40+
/>
3641
<Route index element={<AnimationsIndex />} />
3742
</Route>
3843
<Route path="/" element={<Index />} />

examples/src/components/animations/Nav.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ const Nav = () => {
77
<li>
88
<a href="/animations/in-view">Motion One InView</a>
99
</li>
10+
<li>
11+
<a href="/animations/animate-presence">AnimatePresence</a>
12+
</li>
1013
</ul>
1114
</nav>
1215
);
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { render, screen, waitFor } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
4+
import { mockAnimationsApi } from '../../../../../../dist';
5+
6+
import Readme1 from './AnimatePresence';
7+
8+
mockAnimationsApi();
9+
10+
describe('Animations/Readme1', () => {
11+
it('adds an element into the dom and fades it in', async () => {
12+
render(<Readme1 />);
13+
14+
expect(screen.queryByText('Hehey!')).not.toBeInTheDocument();
15+
16+
await userEvent.click(screen.getByText('Show'));
17+
18+
// assume there's only one animation present in the document at this point
19+
// in practice it's better to get the running animation from the element itself
20+
const element = screen.getByText('Hehey!');
21+
const animation = document.getAnimations()[0];
22+
23+
// our AnimatePresence implementation has 2 keyframes: opacity: 0 and opacity: 1
24+
// which allows us to test the visibility of the element, the first keyframe
25+
// is applied right after the animation is ready
26+
await animation.ready;
27+
28+
expect(element).not.toBeVisible();
29+
30+
// this test will pass right after 50% of the animation is complete
31+
// because this mock doesn't interpolate keyframes values,
32+
// but chooses the closest one
33+
await waitFor(() => {
34+
expect(element).toBeVisible();
35+
});
36+
37+
// AnimatePresence will also add a div with the text 'Done!' after animation is complete
38+
await waitFor(() => {
39+
expect(screen.getByText('Done!')).toBeInTheDocument();
40+
});
41+
});
42+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import {
2+
useEffect,
3+
useLayoutEffect,
4+
useRef,
5+
useState,
6+
type ReactNode,
7+
} from 'react';
8+
9+
enum Presence {
10+
HIDDEN,
11+
IN_DOM,
12+
VISIBLE,
13+
}
14+
15+
const AnimatePresence = ({ children }: { children: ReactNode | undefined }) => {
16+
const [presence, setPresence] = useState(Presence.HIDDEN);
17+
const ref = useRef<HTMLDivElement | null>(null);
18+
19+
useLayoutEffect(() => {
20+
if (!ref.current) {
21+
return;
22+
}
23+
24+
if (presence === Presence.IN_DOM) {
25+
const animation = ref.current.animate(
26+
[{ opacity: 0 }, { opacity: 1 }],
27+
500
28+
);
29+
30+
animation.addEventListener('finish', () => {
31+
setPresence(Presence.VISIBLE);
32+
});
33+
}
34+
}, [presence]);
35+
36+
useEffect(() => {
37+
if (presence === Presence.HIDDEN && children) {
38+
setPresence(Presence.IN_DOM);
39+
}
40+
}, [presence, children]);
41+
42+
return presence !== Presence.HIDDEN ? (
43+
<div ref={ref}>
44+
{children}
45+
{presence === Presence.VISIBLE && <div>Done!</div>}
46+
</div>
47+
) : null;
48+
};
49+
50+
const AnimationsReadme1 = () => {
51+
const [isShown, setIsShown] = useState(false);
52+
53+
return (
54+
<div>
55+
<AnimatePresence>{isShown && <div>Hehey!</div>}</AnimatePresence>
56+
<button
57+
onClick={() => {
58+
setIsShown(true);
59+
}}
60+
>
61+
Show
62+
</button>
63+
</div>
64+
);
65+
};
66+
67+
export default AnimationsReadme1;

examples/src/components/animations/examples/inView.test.tsx renamed to examples/src/components/animations/examples/inview/inView.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { render, act, screen, waitFor } from '@testing-library/react';
33
import {
44
mockIntersectionObserver,
55
mockAnimationsApi,
6-
} from '../../../../../dist';
6+
} from '../../../../../../dist';
77

88
import InView from './InView';
99

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "jsdom-testing-mocks",
3-
"version": "1.5.0-beta.7",
3+
"version": "1.5.0-beta.8",
44
"author": "Ivan Galiatin",
55
"license": "MIT",
66
"description": "A set of tools for emulating browser behavior in jsdom environment",
@@ -16,6 +16,7 @@
1616
"Resize Observer API",
1717
"Intersection Observer API",
1818
"Web Animations API",
19+
"WAAPI",
1920
"matchMedia",
2021
"viewport",
2122
"react"

0 commit comments

Comments
 (0)