├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ ├── bump.yml │ ├── main.yml │ └── publish.yml ├── .gitignore ├── .prettierignore ├── .vscode └── settings.json ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── examples ├── .gitignore ├── README.md ├── global.d.ts ├── package-lock.json ├── package.json ├── public │ └── index.html ├── src │ ├── App.tsx │ ├── components │ │ ├── Nav.tsx │ │ ├── animations │ │ │ ├── Layout.module.css │ │ │ ├── Layout.tsx │ │ │ ├── Nav.module.css │ │ │ ├── Nav.tsx │ │ │ ├── examples │ │ │ │ ├── animate-presence │ │ │ │ │ ├── AnimatePresence.test.tsx │ │ │ │ │ ├── AnimatePresence.tsx │ │ │ │ │ └── AnimatePresence.vitest.tsx │ │ │ │ └── inview │ │ │ │ │ ├── InView.tsx │ │ │ │ │ ├── inView.test.tsx │ │ │ │ │ └── inview.module.css │ │ │ └── index.tsx │ │ ├── intersection-observer │ │ │ ├── global-observer │ │ │ │ ├── GlobalObserver.tsx │ │ │ │ └── useIntersection.ts │ │ │ └── intersection-observer.test.tsx │ │ ├── resize-observer │ │ │ ├── measure-parent │ │ │ │ ├── MeasureParent.tsx │ │ │ │ └── useDoIFit.ts │ │ │ ├── print-my-size │ │ │ │ └── PrintMySize.tsx │ │ │ ├── resize-observer.test.tsx │ │ │ └── useResizeObserver.ts │ │ └── viewport │ │ │ ├── custom-use-media │ │ │ ├── CustomUseMedia.tsx │ │ │ └── useMedia.ts │ │ │ ├── deprecated-use-media │ │ │ └── DeprecatedUseMedia.tsx │ │ │ └── viewport.test.tsx │ ├── index.css │ ├── index.tsx │ ├── react-app-env.d.ts │ ├── setupTests.ts │ └── setupVitests.ts ├── swcjest.config.js ├── tsconfig.json └── vitest.config.ts ├── global.d.ts ├── jest-setup.ts ├── jest.config.ts ├── package-lock.json ├── package.json ├── src ├── helper.ts ├── index.ts ├── mocks │ ├── MediaQueryListEvent.ts │ ├── intersection-observer.env.test.ts │ ├── intersection-observer.test.ts │ ├── intersection-observer.ts │ ├── resize-observer.env.test.ts │ ├── resize-observer.ts │ ├── size │ │ ├── DOMRect.env.test.ts │ │ ├── DOMRect.test.ts │ │ ├── DOMRect.ts │ │ ├── index.ts │ │ ├── size.test.ts │ │ └── size.ts │ ├── testTools.ts │ ├── viewport.env.test.tsx │ ├── viewport.ts │ └── web-animations-api │ │ ├── Animation.ts │ │ ├── AnimationEffect.ts │ │ ├── AnimationPlaybackEvent.ts │ │ ├── AnimationTimeline.ts │ │ ├── DocumentTimeline.ts │ │ ├── KeyframeEffect.ts │ │ ├── __tests__ │ │ ├── AnimationEffect.test.ts │ │ ├── AnimationTimeline.test.ts │ │ ├── DocumentTimeline.test.ts │ │ ├── KeyframeEffect.test.ts │ │ ├── cancel.test.ts │ │ ├── commitStyles.test.ts │ │ ├── easingFunctions.test.ts │ │ ├── effect.test.ts │ │ ├── events.test.ts │ │ ├── index.env.test.ts │ │ ├── index.test.ts │ │ ├── main.test.ts │ │ ├── pause.test.ts │ │ └── reverse.test.ts │ │ ├── easingFunctions.ts │ │ ├── elementAnimations.ts │ │ └── index.ts └── tools.ts ├── swcjest.config.ts ├── tsconfig.json ├── vitest-setup.ts └── vitest.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | module.exports = { 4 | root: true, 5 | parser: '@typescript-eslint/parser', 6 | plugins: ['@typescript-eslint', 'jsx-a11y'], 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:react/recommended', 11 | 'prettier', 12 | ], 13 | rules: { 14 | 'no-unused-vars': 'off', 15 | '@typescript-eslint/no-unused-vars': [ 16 | 'error', 17 | { ignoreRestSiblings: true }, 18 | ], 19 | 'react/jsx-uses-react': 'off', 20 | 'react/react-in-jsx-scope': 'off', 21 | '@typescript-eslint/ban-ts-comment': 'off', 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /.github/workflows/bump.yml: -------------------------------------------------------------------------------- 1 | name: Bump version 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'Semver type of new version (major / minor / patch)' 8 | required: true 9 | 10 | jobs: 11 | bump-version: 12 | name: Bump version 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check out source 16 | uses: actions/checkout@v2 17 | 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v2 20 | with: 21 | node-version: '16' 22 | cache: 'npm' 23 | 24 | - name: Install dependencies 25 | uses: bahmutov/npm-install@v1 26 | 27 | - name: Setup Git 28 | run: | 29 | git config user.name 'Ivan Galiatin' 30 | git config user.email 'arxcaeli@gmail.com' 31 | 32 | - name: bump version 33 | run: npm version ${{ github.event.inputs.version }} 34 | 35 | - name: Push latest version 36 | run: git push origin master --follow-tags 37 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 8 | 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | node: ['16.x', '18.x', '20.x'] 13 | os: [ubuntu-latest, windows-latest, macOS-latest] 14 | 15 | steps: 16 | - name: Checkout repo 17 | uses: actions/checkout@v2 18 | 19 | - name: Use Node ${{ matrix.node }} 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: ${{ matrix.node }} 23 | 24 | - name: Install deps and build (with cache) 25 | uses: bahmutov/npm-install@v1 26 | with: 27 | working-directory: | 28 | . 29 | examples 30 | 31 | - name: Lint 32 | run: npm run lint 33 | 34 | - name: Test 35 | run: npm run test:all --ci --coverage --maxWorkers=2 36 | 37 | - name: Build 38 | run: npm run build 39 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | # This specifies that the build will be triggered when we publish a release 6 | types: [published] 7 | 8 | jobs: 9 | publish: 10 | name: 'Publish' 11 | runs-on: ubuntu-latest 12 | if: "!contains(github.ref_name, 'beta')" 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - uses: actions/setup-node@v2 17 | with: 18 | node-version: 'lts/*' 19 | registry-url: https://registry.npmjs.org/ 20 | 21 | - name: Install deps and build (with cache) 22 | uses: bahmutov/npm-install@v1 23 | 24 | - run: npm run build 25 | 26 | - run: npm publish --access public 27 | env: 28 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 29 | 30 | publish_beta: 31 | name: 'Publish beta' 32 | runs-on: ubuntu-latest 33 | if: contains(github.ref_name, 'beta') 34 | steps: 35 | - uses: actions/checkout@v2 36 | 37 | - uses: actions/setup-node@v2 38 | with: 39 | node-version: 'lts/*' 40 | registry-url: https://registry.npmjs.org/ 41 | 42 | - name: Install deps and build (with cache) 43 | uses: bahmutov/npm-install@v1 44 | 45 | - run: npm run build 46 | 47 | - run: npm publish --tag beta --access public 48 | env: 49 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | .parcel-cache 6 | dist 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at arxcaeli@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ivan Galiatin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /examples/global.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | declare global { 4 | var runner: { 5 | name: 'vi' | 'jest'; 6 | useFakeTimers: () => void; 7 | useRealTimers: () => void; 8 | advanceTimersByTime: (time: number) => Promise; 9 | fn: () => jest.Mock; 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "examples", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.16.4", 7 | "@testing-library/react": "^13.3.0", 8 | "@testing-library/user-event": "^13.5.0", 9 | "@types/jest": "^27.5.2", 10 | "@types/node": "^16.11.43", 11 | "@types/react": "^18.2.0", 12 | "@types/react-dom": "^18.2.1", 13 | "motion": "^10.15.5", 14 | "react": "^18.2.0", 15 | "react-dom": "^18.2.0", 16 | "react-router-dom": "^6.3.0", 17 | "react-scripts": "5.0.1", 18 | "typescript": "^4.7.4" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "test:jest": "react-scripts test --watchAll=false", 24 | "test:swc": "jest --config ./swcjest.config.js", 25 | "test:vi": "vitest --config ./vitest.config.ts run", 26 | "test:all": "npm run test:jest && npm run test:vi && npm run test:swc", 27 | "eject": "react-scripts eject" 28 | }, 29 | "eslintConfig": { 30 | "extends": [ 31 | "react-app", 32 | "react-app/jest" 33 | ] 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.2%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | }, 47 | "devDependencies": { 48 | "@vitejs/plugin-react": "^4.0.0", 49 | "identity-obj-proxy": "^3.0.0", 50 | "vitest": "^0.30.1" 51 | }, 52 | "jest": { 53 | "moduleNameMapper": { 54 | "\\.(css|less)$": "identity-obj-proxy" 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /examples/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | jsdom testing mocks 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Routes, Route } from 'react-router-dom'; 2 | 3 | import Nav from './components/Nav'; 4 | 5 | import GlobalObserver from './components/intersection-observer/global-observer/GlobalObserver'; 6 | import MeasureParent from './components/resize-observer/measure-parent/MeasureParent'; 7 | import PrintMySize from './components/resize-observer/print-my-size/PrintMySize'; 8 | import CustomUseMedia from './components/viewport/custom-use-media/CustomUseMedia'; 9 | import DeprecatedCustomUseMedia from './components/viewport/deprecated-use-media/DeprecatedUseMedia'; 10 | import { Layout } from './components/animations/Layout'; 11 | import AnimationsInView from './components/animations/examples/inview/InView'; 12 | import AnimationsAnimatePresence from './components/animations/examples/animate-presence/AnimatePresence'; 13 | import AnimationsIndex from './components/animations'; 14 | 15 | function Index() { 16 | return <>; 17 | } 18 | 19 | function App() { 20 | return ( 21 |
22 |
46 | ); 47 | } 48 | 49 | export default App; 50 | -------------------------------------------------------------------------------- /examples/src/components/Nav.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | const Nav = (): React.ReactElement => ( 5 | 36 | ); 37 | 38 | export default Nav; 39 | -------------------------------------------------------------------------------- /examples/src/components/animations/Layout.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: grid; 3 | grid-template-columns: 200px 1fr; 4 | } 5 | -------------------------------------------------------------------------------- /examples/src/components/animations/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom'; 2 | import Nav from './Nav'; 3 | 4 | import styles from './Layout.module.css'; 5 | 6 | export const Layout = () => { 7 | return ( 8 |
9 | 12 |
13 | 14 |
15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /examples/src/components/animations/Nav.module.css: -------------------------------------------------------------------------------- 1 | .nav { 2 | position: fixed; 3 | } 4 | 5 | .list { 6 | list-style-type: none; 7 | padding: 0 0 0 1rem; 8 | } 9 | -------------------------------------------------------------------------------- /examples/src/components/animations/Nav.tsx: -------------------------------------------------------------------------------- 1 | import styles from './Nav.module.css'; 2 | 3 | const Nav = () => { 4 | return ( 5 | 15 | ); 16 | }; 17 | 18 | export default Nav; 19 | -------------------------------------------------------------------------------- /examples/src/components/animations/examples/animate-presence/AnimatePresence.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor, act } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | 4 | import { mockAnimationsApi, configMocks } 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(); 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 | 43 | it('should not generate act warnings, if callbacks update state', async () => { 44 | configMocks({ act }); 45 | 46 | render(); 47 | 48 | expect(screen.queryByText('Hehey!')).not.toBeInTheDocument(); 49 | 50 | await userEvent.click(screen.getByText('Show')); 51 | 52 | // assume there's only one animation present in the document at this point 53 | // in practice it's better to get the running animation from the element itself 54 | const element = screen.getByText('Hehey!'); 55 | const animation = document.getAnimations()[0]; 56 | 57 | // our AnimatePresence implementation has 2 keyframes: opacity: 0 and opacity: 1 58 | // which allows us to test the visibility of the element, the first keyframe 59 | // is applied right after the animation is ready 60 | await animation.ready; 61 | 62 | expect(element).not.toBeVisible(); 63 | 64 | // wait for the animation to complete 65 | // and check that it doesn't generate a 66 | // console.error about not being wrapped in act 67 | await animation.finished; 68 | 69 | configMocks({ act: undefined }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /examples/src/components/animations/examples/animate-presence/AnimatePresence.tsx: -------------------------------------------------------------------------------- 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(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 |
44 | {children} 45 | {presence === Presence.VISIBLE &&
Done!
} 46 |
47 | ) : null; 48 | }; 49 | 50 | const AnimationsReadme1 = () => { 51 | const [isShown, setIsShown] = useState(false); 52 | 53 | return ( 54 |
55 | {isShown &&
Hehey!
}
56 | 63 |
64 | ); 65 | }; 66 | 67 | export default AnimationsReadme1; 68 | -------------------------------------------------------------------------------- /examples/src/components/animations/examples/animate-presence/AnimatePresence.vitest.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor, act } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | 4 | import { mockAnimationsApi, configMocks } 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(); 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 | 43 | it('should not generate act warnings, if callbacks update state', async () => { 44 | configMocks({ act }); 45 | 46 | render(); 47 | 48 | expect(screen.queryByText('Hehey!')).not.toBeInTheDocument(); 49 | 50 | await userEvent.click(screen.getByText('Show')); 51 | 52 | // assume there's only one animation present in the document at this point 53 | // in practice it's better to get the running animation from the element itself 54 | const element = screen.getByText('Hehey!'); 55 | const animation = document.getAnimations()[0]; 56 | 57 | // our AnimatePresence implementation has 2 keyframes: opacity: 0 and opacity: 1 58 | // which allows us to test the visibility of the element, the first keyframe 59 | // is applied right after the animation is ready 60 | await animation.ready; 61 | 62 | expect(element).not.toBeVisible(); 63 | 64 | // wait for the animation to complete 65 | // and check that it doesn't generate a 66 | // console.error about not being wrapped in act 67 | await animation.finished; 68 | 69 | configMocks({ act: undefined }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /examples/src/components/animations/examples/inview/InView.tsx: -------------------------------------------------------------------------------- 1 | import styles from './inview.module.css'; 2 | import { inView, animate } from 'motion'; 3 | import { useEffect, useRef } from 'react'; 4 | 5 | const AnimationsInView = () => { 6 | const ref = useRef(null); 7 | 8 | useEffect(() => { 9 | if (!ref.current) { 10 | return; 11 | } 12 | 13 | const stop = inView('.inview-section', ({ target }) => { 14 | const span = target.querySelector('span'); 15 | 16 | if (span) { 17 | animate( 18 | span, 19 | { opacity: 1, transform: 'none' }, 20 | { delay: 0.2, duration: 0.9, easing: [0.17, 0.55, 0.55, 1] } 21 | ); 22 | } 23 | }); 24 | 25 | return () => { 26 | stop(); 27 | }; 28 | }, []); 29 | 30 | return ( 31 |
32 |
33 | 34 | Scroll 35 | 36 |
37 |
38 | to 39 |
40 |
41 | 42 | trigger 43 | 44 |
45 |
46 | 47 | animations! 48 | 49 |
50 |
51 | ); 52 | }; 53 | 54 | export default AnimationsInView; 55 | -------------------------------------------------------------------------------- /examples/src/components/animations/examples/inview/inView.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, act, screen, waitFor } from '@testing-library/react'; 2 | 3 | import { 4 | mockIntersectionObserver, 5 | mockAnimationsApi, 6 | } from '../../../../../../dist'; 7 | 8 | import InView from './InView'; 9 | 10 | const io = mockIntersectionObserver(); 11 | mockAnimationsApi(); 12 | 13 | describe('Animations/InView', () => { 14 | it('works with real timers', async () => { 15 | render(); 16 | 17 | // first section 18 | 19 | expect(screen.getByText('Scroll')).not.toBeVisible(); 20 | 21 | act(() => { 22 | io.enterNode(screen.getByTestId('section1')); 23 | }); 24 | 25 | await waitFor(() => { 26 | expect(screen.getByText('Scroll')).toBeVisible(); 27 | }); 28 | 29 | // second section 30 | expect(screen.getByText('to')).not.toBeVisible(); 31 | 32 | act(() => { 33 | io.enterNode(screen.getByTestId('section2')); 34 | }); 35 | 36 | await waitFor(() => { 37 | expect(screen.getByText('to')).toBeVisible(); 38 | }); 39 | 40 | // third section 41 | expect(screen.getByText('trigger')).not.toBeVisible(); 42 | 43 | act(() => { 44 | io.enterNode(screen.getByTestId('section3')); 45 | }); 46 | 47 | await waitFor(() => { 48 | expect(screen.getByText('trigger')).toBeVisible(); 49 | }); 50 | 51 | // fourth section 52 | expect(screen.getByText('animations!')).not.toBeVisible(); 53 | 54 | act(() => { 55 | io.enterNode(screen.getByTestId('section4')); 56 | }); 57 | 58 | await waitFor(() => { 59 | expect(screen.getByText('animations!')).toBeVisible(); 60 | }); 61 | }); 62 | 63 | it('works with fake timers', async () => { 64 | runner.useFakeTimers(); 65 | 66 | render(); 67 | 68 | // first section 69 | expect(screen.getByText('Scroll')).not.toBeVisible(); 70 | 71 | act(() => { 72 | io.enterNode(screen.getByTestId('section1')); 73 | }); 74 | 75 | runner.advanceTimersByTime(1000); 76 | 77 | await waitFor(() => { 78 | expect(screen.getByText('Scroll')).toBeVisible(); 79 | }); 80 | 81 | // second section 82 | expect(screen.getByText('to')).not.toBeVisible(); 83 | 84 | act(() => { 85 | io.enterNode(screen.getByTestId('section2')); 86 | }); 87 | 88 | runner.advanceTimersByTime(1000); 89 | 90 | await waitFor(() => { 91 | expect(screen.getByText('to')).toBeVisible(); 92 | }); 93 | 94 | // third section 95 | expect(screen.getByText('trigger')).not.toBeVisible(); 96 | 97 | act(() => { 98 | io.enterNode(screen.getByTestId('section3')); 99 | }); 100 | 101 | runner.advanceTimersByTime(1000); 102 | 103 | await waitFor(() => { 104 | expect(screen.getByText('trigger')).not.toBeVisible(); 105 | }); 106 | 107 | // fourth section 108 | expect(screen.getByText('animations!')).not.toBeVisible(); 109 | 110 | act(() => { 111 | io.enterNode(screen.getByTestId('section4')); 112 | }); 113 | 114 | runner.advanceTimersByTime(1000); 115 | 116 | await waitFor(() => { 117 | expect(screen.getByText('animations!')).toBeVisible(); 118 | }); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /examples/src/components/animations/examples/inview/inview.module.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Inter'; 3 | font-style: normal; 4 | font-weight: 700; 5 | font-display: swap; 6 | src: url(https://fonts.gstatic.com/s/inter/v3/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa25L7W0Q5n-wU.woff2) 7 | format('woff2'); 8 | unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, 9 | U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 10 | } 11 | 12 | .container { 13 | --white: #f5f5f5; 14 | --black: #0f1115; 15 | --yellow: #ffeb0e; 16 | --strong-blue: #0d63f8; 17 | --blue: #31a6fa; 18 | --green: #57eb64; 19 | --pink: #ff2965; 20 | --red: #ff1231; 21 | --splash: #00ffdb; 22 | display: flex; 23 | flex-direction: column; 24 | margin: 0; 25 | padding: 0; 26 | min-height: 90vh; 27 | } 28 | 29 | .container section { 30 | box-sizing: border-box; 31 | width: 100%; 32 | height: 101vh; 33 | display: flex; 34 | justify-content: flex-start; 35 | overflow: hidden; 36 | padding: 50px; 37 | background: var(--green); 38 | } 39 | 40 | .container section:nth-child(2) { 41 | background: var(--splash); 42 | } 43 | 44 | .container section:nth-child(3) { 45 | background: var(--pink); 46 | } 47 | 48 | .container section:nth-child(4) { 49 | background: var(--yellow); 50 | } 51 | 52 | .container section span { 53 | display: block; 54 | } 55 | 56 | .container * { 57 | font-weight: 700; 58 | font-family: 'Inter-Bold', 'Inter', sans-serif; 59 | color: rgba(0, 0, 0, 0.9); 60 | font-size: 48px; 61 | letter-spacing: -2px; 62 | } 63 | -------------------------------------------------------------------------------- /examples/src/components/animations/index.tsx: -------------------------------------------------------------------------------- 1 | const AnimationsIndex = () => { 2 | return <>; 3 | }; 4 | 5 | export default AnimationsIndex; 6 | -------------------------------------------------------------------------------- /examples/src/components/intersection-observer/global-observer/GlobalObserver.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, type ReactElement } from 'react'; 2 | import useIntersection from './useIntersection'; 3 | 4 | export const Section = ({ 5 | number, 6 | callback, 7 | }: { 8 | number: number; 9 | callback?: IntersectionObserverCallback; 10 | }): ReactElement => { 11 | const ref = useRef(null); 12 | const isIntersecting = useIntersection(ref, callback); 13 | 14 | return ( 15 |
22 | A section {number} -{' '} 23 | {isIntersecting ? 'intersecting' : 'not intersecting'} 24 |
25 | ); 26 | }; 27 | 28 | function GlobalObserver() { 29 | const sections = 10; 30 | 31 | return ( 32 | <> 33 | {[...new Array(sections)].map((_, index) => ( 34 |
35 | ))} 36 |
51 | Intersection zone 52 |
53 | 54 | ); 55 | } 56 | 57 | export default GlobalObserver; 58 | -------------------------------------------------------------------------------- /examples/src/components/intersection-observer/global-observer/useIntersection.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, MutableRefObject } from 'react'; 2 | 3 | const entryCallbacks: { 4 | [key: string]: (entry: IntersectionObserverEntry) => void; 5 | } = {}; 6 | let id = 0; 7 | let observer: IntersectionObserver; 8 | 9 | const generateId = () => { 10 | id++; 11 | return id; 12 | }; 13 | 14 | function createObserver() { 15 | observer = new IntersectionObserver( 16 | (entries) => 17 | entries.forEach((entry) => { 18 | entryCallbacks[(entry.target as HTMLElement).dataset._ioid as string]( 19 | entry 20 | ); 21 | }), 22 | { 23 | rootMargin: '-30% 0% -30% 0%', 24 | } 25 | ); 26 | } 27 | 28 | const useIntersection = ( 29 | ref: MutableRefObject, 30 | callback?: IntersectionObserverCallback 31 | ) => { 32 | const [isIntersecting, setIsIntersecting] = useState(false); 33 | 34 | useEffect(() => { 35 | const node = ref.current; 36 | 37 | if (!node) { 38 | return; 39 | } 40 | 41 | const domId = generateId(); 42 | 43 | entryCallbacks[domId.toString()] = (entry) => { 44 | setIsIntersecting(entry.isIntersecting); 45 | callback?.([entry], observer); 46 | }; 47 | 48 | node.dataset._ioid = domId.toString(); 49 | 50 | if (!observer) { 51 | createObserver(); 52 | } 53 | 54 | observer.observe(node); 55 | 56 | return () => { 57 | delete entryCallbacks[domId]; 58 | observer.unobserve(node); 59 | }; 60 | }, [callback, ref]); 61 | 62 | return isIntersecting; 63 | }; 64 | 65 | export default useIntersection; 66 | -------------------------------------------------------------------------------- /examples/src/components/intersection-observer/intersection-observer.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, act, screen } from '@testing-library/react'; 2 | 3 | import { 4 | mockIntersectionObserver, 5 | MockedIntersectionObserver, 6 | type IntersectionDescription, 7 | configMocks, 8 | } from '../../../../dist'; 9 | 10 | import App, { Section } from './global-observer/GlobalObserver'; 11 | 12 | const io = mockIntersectionObserver(); 13 | 14 | configMocks({ act }); 15 | 16 | describe('Section is intersecting', () => { 17 | it('should render the initial state correctly', () => { 18 | render(
); 19 | 20 | expect( 21 | screen.getByText('A section 1 - not intersecting') 22 | ).toBeInTheDocument(); 23 | }); 24 | 25 | it('should work correctly when entering and leaving an intersection zone', () => { 26 | const cb = runner.fn(); 27 | render(
); 28 | 29 | io.enterNode(screen.getByText('A section 1 - not intersecting')); 30 | 31 | expect(screen.getByText('A section 1 - intersecting')).toBeInTheDocument(); 32 | const [entries1, observer1] = cb.mock.calls[0]; 33 | 34 | expect(cb).toHaveBeenCalledTimes(1); 35 | expect(entries1).toHaveLength(1); 36 | expect(entries1[0]).toEqual( 37 | expect.objectContaining({ 38 | intersectionRatio: 1, 39 | isIntersecting: true, 40 | }) 41 | ); 42 | expect(entries1[0].target).toBe( 43 | screen.getByText('A section 1 - intersecting') 44 | ); 45 | expect(observer1).toBeInstanceOf(MockedIntersectionObserver); 46 | 47 | io.leaveNode(screen.getByText('A section 1 - intersecting')); 48 | 49 | expect( 50 | screen.getByText('A section 1 - not intersecting') 51 | ).toBeInTheDocument(); 52 | 53 | const [entries2, observer2] = cb.mock.calls[1]; 54 | expect(cb).toHaveBeenCalledTimes(2); 55 | expect(entries2).toHaveLength(1); // Number of entries 56 | expect(entries2[0]).toEqual( 57 | expect.objectContaining({ 58 | intersectionRatio: 0, 59 | isIntersecting: false, 60 | }) 61 | ); 62 | expect(observer2).toBeInstanceOf(MockedIntersectionObserver); 63 | }); 64 | 65 | it('should not override isIntersected, but allow overriding other params', () => { 66 | const cb = runner.fn(); 67 | render(
); 68 | 69 | io.enterNode(screen.getByText('A section 1 - not intersecting'), { 70 | isIntersecting: false, 71 | intersectionRatio: 0.5, 72 | }); 73 | 74 | const [entries1] = cb.mock.calls[0]; 75 | 76 | expect(entries1[0]).toEqual( 77 | expect.objectContaining({ 78 | intersectionRatio: 0.5, 79 | isIntersecting: true, 80 | }) 81 | ); 82 | 83 | io.leaveNode(screen.getByText('A section 1 - intersecting'), { 84 | isIntersecting: true, 85 | intersectionRatio: 0.5, 86 | }); 87 | 88 | const [entries2] = cb.mock.calls[1]; 89 | expect(entries2[0]).toEqual( 90 | expect.objectContaining({ 91 | intersectionRatio: 0.5, 92 | isIntersecting: false, 93 | }) 94 | ); 95 | }); 96 | 97 | it('should enter all nodes at once', () => { 98 | render(); 99 | 100 | io.enterAll(); 101 | 102 | expect( 103 | screen.getAllByText(/A section/).map((node) => node.textContent) 104 | ).toEqual([ 105 | 'A section 0 - intersecting', 106 | 'A section 1 - intersecting', 107 | 'A section 2 - intersecting', 108 | 'A section 3 - intersecting', 109 | 'A section 4 - intersecting', 110 | 'A section 5 - intersecting', 111 | 'A section 6 - intersecting', 112 | 'A section 7 - intersecting', 113 | 'A section 8 - intersecting', 114 | 'A section 9 - intersecting', 115 | ]); 116 | }); 117 | 118 | it('should enter and leave all nodes at once', () => { 119 | render(); 120 | 121 | io.enterAll(); 122 | 123 | expect( 124 | screen.getAllByText(/A section/).map((node) => node.textContent) 125 | ).toEqual([ 126 | 'A section 0 - intersecting', 127 | 'A section 1 - intersecting', 128 | 'A section 2 - intersecting', 129 | 'A section 3 - intersecting', 130 | 'A section 4 - intersecting', 131 | 'A section 5 - intersecting', 132 | 'A section 6 - intersecting', 133 | 'A section 7 - intersecting', 134 | 'A section 8 - intersecting', 135 | 'A section 9 - intersecting', 136 | ]); 137 | 138 | io.leaveAll(); 139 | 140 | expect( 141 | screen.getAllByText(/A section/).map((node) => node.textContent) 142 | ).toEqual([ 143 | 'A section 0 - not intersecting', 144 | 'A section 1 - not intersecting', 145 | 'A section 2 - not intersecting', 146 | 'A section 3 - not intersecting', 147 | 'A section 4 - not intersecting', 148 | 'A section 5 - not intersecting', 149 | 'A section 6 - not intersecting', 150 | 'A section 7 - not intersecting', 151 | 'A section 8 - not intersecting', 152 | 'A section 9 - not intersecting', 153 | ]); 154 | }); 155 | 156 | it('should enter one node and leave one node', () => { 157 | render(); 158 | 159 | io.enterNode(screen.getByText('A section 4 - not intersecting')); 160 | 161 | expect( 162 | screen.getAllByText(/A section/).map((node) => node.textContent) 163 | ).toEqual([ 164 | 'A section 0 - not intersecting', 165 | 'A section 1 - not intersecting', 166 | 'A section 2 - not intersecting', 167 | 'A section 3 - not intersecting', 168 | 'A section 4 - intersecting', 169 | 'A section 5 - not intersecting', 170 | 'A section 6 - not intersecting', 171 | 'A section 7 - not intersecting', 172 | 'A section 8 - not intersecting', 173 | 'A section 9 - not intersecting', 174 | ]); 175 | 176 | io.enterNode(screen.getByText('A section 7 - not intersecting')); 177 | io.enterNode(screen.getByText('A section 8 - not intersecting')); 178 | 179 | expect( 180 | screen.getAllByText(/A section/).map((node) => node.textContent) 181 | ).toEqual([ 182 | 'A section 0 - not intersecting', 183 | 'A section 1 - not intersecting', 184 | 'A section 2 - not intersecting', 185 | 'A section 3 - not intersecting', 186 | 'A section 4 - intersecting', 187 | 'A section 5 - not intersecting', 188 | 'A section 6 - not intersecting', 189 | 'A section 7 - intersecting', 190 | 'A section 8 - intersecting', 191 | 'A section 9 - not intersecting', 192 | ]); 193 | 194 | io.leaveNode(screen.getByText('A section 4 - intersecting')); 195 | 196 | expect( 197 | screen.getAllByText(/A section/).map((node) => node.textContent) 198 | ).toEqual([ 199 | 'A section 0 - not intersecting', 200 | 'A section 1 - not intersecting', 201 | 'A section 2 - not intersecting', 202 | 'A section 3 - not intersecting', 203 | 'A section 4 - not intersecting', 204 | 'A section 5 - not intersecting', 205 | 'A section 6 - not intersecting', 206 | 'A section 7 - intersecting', 207 | 'A section 8 - intersecting', 208 | 'A section 9 - not intersecting', 209 | ]); 210 | 211 | io.leaveAll(); 212 | 213 | expect( 214 | screen.getAllByText(/A section/).map((node) => node.textContent) 215 | ).toEqual([ 216 | 'A section 0 - not intersecting', 217 | 'A section 1 - not intersecting', 218 | 'A section 2 - not intersecting', 219 | 'A section 3 - not intersecting', 220 | 'A section 4 - not intersecting', 221 | 'A section 5 - not intersecting', 222 | 'A section 6 - not intersecting', 223 | 'A section 7 - not intersecting', 224 | 'A section 8 - not intersecting', 225 | 'A section 9 - not intersecting', 226 | ]); 227 | }); 228 | 229 | it('should enter, leave and trigger multiple nodes', () => { 230 | render(); 231 | 232 | io.enterNodes([ 233 | { node: screen.getByText('A section 4 - not intersecting') }, 234 | { node: screen.getByText('A section 5 - not intersecting') }, 235 | ]); 236 | 237 | expect( 238 | screen.getAllByText(/A section/).map((node) => node.textContent) 239 | ).toEqual([ 240 | 'A section 0 - not intersecting', 241 | 'A section 1 - not intersecting', 242 | 'A section 2 - not intersecting', 243 | 'A section 3 - not intersecting', 244 | 'A section 4 - intersecting', 245 | 'A section 5 - intersecting', 246 | 'A section 6 - not intersecting', 247 | 'A section 7 - not intersecting', 248 | 'A section 8 - not intersecting', 249 | 'A section 9 - not intersecting', 250 | ]); 251 | 252 | io.enterNodes([ 253 | screen.getByText('A section 7 - not intersecting'), 254 | screen.getByText('A section 8 - not intersecting'), 255 | ]); 256 | 257 | expect( 258 | screen.getAllByText(/A section/).map((node) => node.textContent) 259 | ).toEqual([ 260 | 'A section 0 - not intersecting', 261 | 'A section 1 - not intersecting', 262 | 'A section 2 - not intersecting', 263 | 'A section 3 - not intersecting', 264 | 'A section 4 - intersecting', 265 | 'A section 5 - intersecting', 266 | 'A section 6 - not intersecting', 267 | 'A section 7 - intersecting', 268 | 'A section 8 - intersecting', 269 | 'A section 9 - not intersecting', 270 | ]); 271 | 272 | io.triggerNodes([ 273 | { 274 | node: screen.getByText('A section 4 - intersecting'), 275 | desc: { isIntersecting: false }, 276 | }, 277 | { 278 | node: screen.getByText('A section 5 - intersecting'), 279 | desc: { isIntersecting: false }, 280 | }, 281 | { 282 | node: screen.getByText('A section 6 - not intersecting'), 283 | desc: { isIntersecting: true }, 284 | }, 285 | ]); 286 | 287 | expect( 288 | screen.getAllByText(/A section/).map((node) => node.textContent) 289 | ).toEqual([ 290 | 'A section 0 - not intersecting', 291 | 'A section 1 - not intersecting', 292 | 'A section 2 - not intersecting', 293 | 'A section 3 - not intersecting', 294 | 'A section 4 - not intersecting', 295 | 'A section 5 - not intersecting', 296 | 'A section 6 - intersecting', 297 | 'A section 7 - intersecting', 298 | 'A section 8 - intersecting', 299 | 'A section 9 - not intersecting', 300 | ]); 301 | 302 | io.leaveNodes([ 303 | { node: screen.getByText('A section 6 - intersecting') }, 304 | screen.getByText('A section 7 - intersecting'), 305 | { node: screen.getByText('A section 8 - intersecting') }, 306 | ]); 307 | 308 | expect( 309 | screen.getAllByText(/A section/).map((node) => node.textContent) 310 | ).toEqual([ 311 | 'A section 0 - not intersecting', 312 | 'A section 1 - not intersecting', 313 | 'A section 2 - not intersecting', 314 | 'A section 3 - not intersecting', 315 | 'A section 4 - not intersecting', 316 | 'A section 5 - not intersecting', 317 | 'A section 6 - not intersecting', 318 | 'A section 7 - not intersecting', 319 | 'A section 8 - not intersecting', 320 | 'A section 9 - not intersecting', 321 | ]); 322 | }); 323 | 324 | it('should receive intersection options to the callback', () => { 325 | const cb = runner.fn(); 326 | const options: IntersectionDescription = { 327 | intersectionRatio: 1, 328 | rootBounds: { 329 | x: 0, 330 | y: 0, 331 | height: 100, 332 | width: 100, 333 | }, 334 | }; 335 | 336 | render(
); 337 | 338 | expect(cb).not.toHaveBeenCalled(); 339 | 340 | io.enterNode(screen.getByText(/A section 1/), options); 341 | 342 | const [entries] = cb.mock.calls[0]; 343 | 344 | expect(cb).toHaveBeenCalledTimes(1); 345 | expect(entries).toHaveLength(1); // Number of entries 346 | expect(entries[0]).toEqual( 347 | expect.objectContaining({ 348 | ...options, 349 | isIntersecting: true, 350 | }) 351 | ); 352 | expect(entries[0].target).toBe(screen.getByText(/A section 1/)); 353 | }); 354 | }); 355 | -------------------------------------------------------------------------------- /examples/src/components/resize-observer/measure-parent/MeasureParent.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | 3 | import useDoIFit from './useDoIFit'; 4 | 5 | const MeasureParent = () => { 6 | const ref1 = useRef(null); 7 | const ref2 = useRef(null); 8 | const iFit1 = useDoIFit(ref1); 9 | const iFit2 = useDoIFit(ref2); 10 | 11 | return ( 12 |
13 |
14 |
22 |
31 |
32 |
{iFit1 ? 'fit' : "doesn't fit"}
33 |
34 |
35 |
44 |
53 |
54 |
{iFit2 ? 'fit' : "doesn't fit"}
55 |
56 |
57 | ); 58 | }; 59 | 60 | export default MeasureParent; 61 | -------------------------------------------------------------------------------- /examples/src/components/resize-observer/measure-parent/useDoIFit.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | const useDoIFit = (ref: React.RefObject) => { 4 | const [iFit, setIFit] = useState(false); 5 | 6 | useEffect(() => { 7 | if (!ref.current || !ref.current.parentElement) { 8 | return; 9 | } 10 | 11 | const parentElement = ref.current.parentElement; 12 | 13 | const observer = new ResizeObserver(([entry]) => { 14 | const { width, height } = entry.contentRect; 15 | const childElement = parentElement.children[0] as HTMLElement; 16 | const { width: childWidth, height: childHeight } = 17 | childElement.getBoundingClientRect(); 18 | 19 | setIFit(childWidth < width && childHeight < height); 20 | }); 21 | 22 | observer.observe(parentElement); 23 | 24 | return () => { 25 | observer.disconnect(); 26 | }; 27 | }, [ref]); 28 | 29 | return iFit; 30 | }; 31 | 32 | export default useDoIFit; 33 | -------------------------------------------------------------------------------- /examples/src/components/resize-observer/print-my-size/PrintMySize.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react'; 2 | 3 | const PrintMySize = () => { 4 | const ref1 = useRef(null); 5 | const ref2 = useRef(null); 6 | const ref3 = useRef(null); 7 | const [sizes, setSizes] = useState< 8 | Map 9 | >(new Map()); 10 | 11 | useEffect(() => { 12 | if (!ref1.current || !ref2.current || !ref3.current) { 13 | return; 14 | } 15 | 16 | const observer = new ResizeObserver((entries) => { 17 | setSizes( 18 | new Map( 19 | entries.map((entry) => [ 20 | entry.target as HTMLElement, 21 | entry.contentRect, 22 | ]) 23 | ) 24 | ); 25 | }); 26 | 27 | observer.observe(ref1.current); 28 | observer.observe(ref2.current); 29 | observer.observe(ref3.current); 30 | 31 | return () => { 32 | observer.disconnect(); 33 | }; 34 | }, []); 35 | 36 | const size1 = ref1.current && sizes.get(ref1.current); 37 | const size2 = ref2.current && sizes.get(ref2.current); 38 | const size3 = ref3.current && sizes.get(ref3.current); 39 | 40 | return ( 41 |
42 |
51 | {size1 && `${size1.width}x${size1.height}`} 52 |
53 |
63 | {size2 && `${size2.width}x${size2.height}`} 64 |
65 |
75 | {size3 && `${size3.width}x${size3.height}`} 76 |
77 |
78 | ); 79 | }; 80 | 81 | export default PrintMySize; 82 | -------------------------------------------------------------------------------- /examples/src/components/resize-observer/resize-observer.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, act, screen, renderHook } from '@testing-library/react'; 2 | import { useMemo } from 'react'; 3 | 4 | import { 5 | mockResizeObserver, 6 | mockElementBoundingClientRect, 7 | configMocks, 8 | } from '../../../../dist'; 9 | 10 | import MeasureParent from './measure-parent/MeasureParent'; 11 | import PrintMySize from './print-my-size/PrintMySize'; 12 | import useResizeObserver from './useResizeObserver'; 13 | 14 | const { resize, mockElementSize } = mockResizeObserver(); 15 | 16 | configMocks({ act }); 17 | 18 | describe('mockResizeObserver', () => { 19 | describe('MeasureParent', () => { 20 | it('should work', async () => { 21 | render(); 22 | 23 | expect( 24 | screen.getAllByTestId('result').map((node) => node.textContent) 25 | ).toEqual(["doesn't fit", "doesn't fit"]); 26 | 27 | const parent1 = screen.getByTestId('parent1'); 28 | const parent2 = screen.getByTestId('parent2'); 29 | const child1 = screen.getByTestId('child1'); 30 | const child2 = screen.getByTestId('child2'); 31 | 32 | // the child is smaller than the parent, so it should fit 33 | mockElementSize(parent1, { 34 | contentBoxSize: [{ inlineSize: 400, blockSize: 200 }], 35 | }); 36 | 37 | mockElementBoundingClientRect(child1, { width: 200, height: 100 }); 38 | 39 | // the child is larger than the parent, so it should not fit 40 | mockElementBoundingClientRect(child2, { width: 500, height: 300 }); 41 | 42 | mockElementSize(parent2, { 43 | contentBoxSize: [{ inlineSize: 400, blockSize: 200 }], 44 | }); 45 | 46 | resize(); 47 | 48 | expect( 49 | screen.getAllByTestId('result').map((node) => node.textContent) 50 | ).toEqual(['fit', "doesn't fit"]); 51 | 52 | // make the first parent smaller 53 | mockElementSize(parent1, { 54 | contentBoxSize: [{ inlineSize: 400, blockSize: 90 }], 55 | }); 56 | 57 | // make the second parent bigger 58 | mockElementSize(parent2, { 59 | contentBoxSize: [{ inlineSize: 600, blockSize: 400 }], 60 | }); 61 | 62 | resize([parent1, parent2]); 63 | 64 | expect( 65 | screen.getAllByTestId('result').map((node) => node.textContent) 66 | ).toEqual(["doesn't fit", 'fit']); 67 | }); 68 | }); 69 | 70 | describe('PrintMySize', () => { 71 | it('should work', async () => { 72 | render(); 73 | 74 | expect( 75 | screen.getAllByTestId('element').map((node) => node.textContent) 76 | ).toEqual(['', '', '']); 77 | 78 | const elements = screen.getAllByTestId('element'); 79 | 80 | mockElementBoundingClientRect(elements[0], { width: 400, height: 200 }); 81 | mockElementBoundingClientRect(elements[2], { width: 100, height: 200 }); 82 | 83 | resize(); 84 | 85 | expect( 86 | screen.getAllByTestId('element').map((node) => node.textContent) 87 | ).toEqual(['400x200', '', '100x200']); 88 | }); 89 | }); 90 | 91 | describe('useResizeObserver', () => { 92 | it("shouldn't fire the callback if size isn't mocked", async () => { 93 | const callback = runner.fn(); 94 | const { result } = renderHook(() => 95 | useResizeObserver(useMemo(() => ({ callback }), [])) 96 | ); 97 | 98 | expect(result.current).toBeInstanceOf(ResizeObserver); 99 | 100 | if (!result.current) { 101 | return; 102 | } 103 | 104 | const element = document.createElement('div'); 105 | const element2 = document.createElement('div'); 106 | 107 | result.current.observe(element); 108 | result.current.observe(element2); 109 | 110 | resize(); 111 | 112 | expect(callback).toHaveBeenCalledTimes(0); 113 | }); 114 | 115 | it("shouldn't fire the callback if size is 0", async () => { 116 | const callback = runner.fn(); 117 | const { result } = renderHook(() => 118 | useResizeObserver(useMemo(() => ({ callback }), [])) 119 | ); 120 | 121 | expect(result.current).toBeInstanceOf(ResizeObserver); 122 | 123 | if (!result.current) { 124 | return; 125 | } 126 | 127 | const element = document.createElement('div'); 128 | const element2 = document.createElement('div'); 129 | 130 | result.current.observe(element); 131 | result.current.observe(element2); 132 | 133 | mockElementSize(element, { 134 | contentBoxSize: [{ inlineSize: 0, blockSize: 0 }], 135 | }); 136 | 137 | mockElementSize(element2, { 138 | contentBoxSize: [{ inlineSize: 0, blockSize: 0 }], 139 | }); 140 | 141 | resize(); 142 | 143 | expect(callback).toHaveBeenCalledTimes(0); 144 | }); 145 | 146 | it('should fire the callback once if size is mocked', async () => { 147 | const callback = runner.fn(); 148 | const { result } = renderHook(() => 149 | useResizeObserver(useMemo(() => ({ callback }), [])) 150 | ); 151 | 152 | expect(result.current).toBeInstanceOf(ResizeObserver); 153 | 154 | if (!result.current) { 155 | return; 156 | } 157 | 158 | const element = document.createElement('div'); 159 | const element2 = document.createElement('div'); 160 | 161 | result.current.observe(element); 162 | result.current.observe(element2); 163 | 164 | mockElementSize(element, { 165 | contentBoxSize: [{ inlineSize: 400, blockSize: 200 }], 166 | }); 167 | 168 | mockElementSize(element2, { 169 | contentBoxSize: [{ inlineSize: 100, blockSize: 200 }], 170 | }); 171 | 172 | resize(); 173 | 174 | expect(callback).toHaveBeenCalledTimes(1); 175 | }); 176 | 177 | test('mockElementSize accepts arrays for borderBoxSize and contentBoxSize', () => { 178 | const callback = runner.fn(); 179 | const { result } = renderHook(() => 180 | useResizeObserver(useMemo(() => ({ callback }), [])) 181 | ); 182 | 183 | if (!result.current) { 184 | return; 185 | } 186 | 187 | const element = document.createElement('div'); 188 | 189 | result.current.observe(element); 190 | 191 | mockElementSize(element, { 192 | borderBoxSize: [{ inlineSize: 450, blockSize: 250 }], 193 | contentBoxSize: [{ inlineSize: 400, blockSize: 200 }], 194 | }); 195 | 196 | resize(); 197 | 198 | expect(callback).toHaveBeenCalledTimes(1); 199 | expect(callback).toHaveBeenCalledWith([ 200 | { 201 | target: element, 202 | borderBoxSize: [{ inlineSize: 450, blockSize: 250 }], 203 | contentBoxSize: [{ inlineSize: 400, blockSize: 200 }], 204 | contentRect: expect.objectContaining({ 205 | x: 0, 206 | y: 0, 207 | width: 400, 208 | height: 200, 209 | top: 0, 210 | right: 400, 211 | bottom: 200, 212 | left: 0, 213 | }), 214 | devicePixelContentBoxSize: [{ inlineSize: 400, blockSize: 200 }], 215 | }, 216 | ]); 217 | }); 218 | 219 | test('mockElementSize also accepts a plain object for borderBoxSize and contentBoxSize', async () => { 220 | const callback = runner.fn(); 221 | const { result } = renderHook(() => 222 | useResizeObserver(useMemo(() => ({ callback }), [])) 223 | ); 224 | 225 | if (!result.current) { 226 | return; 227 | } 228 | 229 | const element = document.createElement('div'); 230 | 231 | result.current.observe(element); 232 | 233 | mockElementSize(element, { 234 | borderBoxSize: { inlineSize: 450, blockSize: 250 }, 235 | contentBoxSize: { inlineSize: 400, blockSize: 200 }, 236 | }); 237 | 238 | resize(); 239 | 240 | expect(callback).toHaveBeenCalledTimes(1); 241 | expect(callback).toHaveBeenCalledWith([ 242 | { 243 | target: element, 244 | borderBoxSize: [{ inlineSize: 450, blockSize: 250 }], 245 | contentBoxSize: [{ inlineSize: 400, blockSize: 200 }], 246 | contentRect: expect.objectContaining({ 247 | x: 0, 248 | y: 0, 249 | width: 400, 250 | height: 200, 251 | top: 0, 252 | right: 400, 253 | bottom: 200, 254 | left: 0, 255 | }), 256 | devicePixelContentBoxSize: [{ inlineSize: 400, blockSize: 200 }], 257 | }, 258 | ]); 259 | }); 260 | 261 | it('should be possible to omit either inlineSize or blockSize', async () => { 262 | const callback = runner.fn(); 263 | const { result } = renderHook(() => 264 | useResizeObserver(useMemo(() => ({ callback }), [])) 265 | ); 266 | 267 | if (!result.current) { 268 | return; 269 | } 270 | 271 | const element = document.createElement('div'); 272 | 273 | result.current.observe(element); 274 | 275 | mockElementSize(element, { contentBoxSize: { inlineSize: 400 } }); 276 | 277 | resize(); 278 | 279 | expect(callback).toHaveBeenCalledTimes(1); 280 | expect(callback).toHaveBeenCalledWith([ 281 | { 282 | target: element, 283 | borderBoxSize: [{ inlineSize: 400, blockSize: 0 }], 284 | contentBoxSize: [{ inlineSize: 400, blockSize: 0 }], 285 | contentRect: expect.objectContaining({ 286 | x: 0, 287 | y: 0, 288 | width: 400, 289 | height: 0, 290 | top: 0, 291 | right: 400, 292 | bottom: 0, 293 | left: 0, 294 | }), 295 | devicePixelContentBoxSize: [{ inlineSize: 400, blockSize: 0 }], 296 | }, 297 | ]); 298 | 299 | mockElementSize(element, { contentBoxSize: { blockSize: 200 } }); 300 | 301 | resize(element); 302 | 303 | expect(callback).toHaveBeenCalledTimes(2); 304 | expect(callback).toHaveBeenCalledWith([ 305 | { 306 | target: element, 307 | borderBoxSize: [{ inlineSize: 0, blockSize: 200 }], 308 | contentBoxSize: [{ inlineSize: 0, blockSize: 200 }], 309 | contentRect: expect.objectContaining({ 310 | x: 0, 311 | y: 0, 312 | width: 0, 313 | height: 200, 314 | top: 0, 315 | right: 0, 316 | bottom: 200, 317 | left: 0, 318 | }), 319 | devicePixelContentBoxSize: [{ inlineSize: 0, blockSize: 200 }], 320 | }, 321 | ]); 322 | }); 323 | 324 | test('Remocking the size', () => { 325 | const callback = runner.fn(); 326 | const { result } = renderHook(() => 327 | useResizeObserver(useMemo(() => ({ callback }), [])) 328 | ); 329 | 330 | if (!result.current) { 331 | return; 332 | } 333 | 334 | const element = document.createElement('div'); 335 | 336 | result.current.observe(element); 337 | 338 | mockElementSize(element, { 339 | contentBoxSize: { inlineSize: 400, blockSize: 200 }, 340 | }); 341 | 342 | resize(); 343 | 344 | expect(callback).toHaveBeenCalledTimes(1); 345 | expect(callback).toHaveBeenCalledWith([ 346 | { 347 | target: element, 348 | borderBoxSize: [{ inlineSize: 400, blockSize: 200 }], 349 | contentBoxSize: [{ inlineSize: 400, blockSize: 200 }], 350 | contentRect: expect.objectContaining({ 351 | x: 0, 352 | y: 0, 353 | width: 400, 354 | height: 200, 355 | top: 0, 356 | right: 400, 357 | bottom: 200, 358 | left: 0, 359 | }), 360 | devicePixelContentBoxSize: [{ inlineSize: 400, blockSize: 200 }], 361 | }, 362 | ]); 363 | 364 | mockElementSize(element, { 365 | contentBoxSize: { inlineSize: 500, blockSize: 300 }, 366 | }); 367 | 368 | resize(element); 369 | 370 | expect(callback).toHaveBeenCalledTimes(2); 371 | expect(callback).toHaveBeenCalledWith([ 372 | { 373 | target: element, 374 | borderBoxSize: [{ inlineSize: 500, blockSize: 300 }], 375 | contentBoxSize: [{ inlineSize: 500, blockSize: 300 }], 376 | contentRect: expect.objectContaining({ 377 | x: 0, 378 | y: 0, 379 | width: 500, 380 | height: 300, 381 | top: 0, 382 | right: 500, 383 | bottom: 300, 384 | left: 0, 385 | }), 386 | devicePixelContentBoxSize: [{ inlineSize: 500, blockSize: 300 }], 387 | }, 388 | ]); 389 | }); 390 | }); 391 | }); 392 | -------------------------------------------------------------------------------- /examples/src/components/resize-observer/useResizeObserver.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | type Telementry = { 4 | callback: (entries: ResizeObserverEntry[]) => void; 5 | }; 6 | 7 | const useResizeObserver = (telemetry: Telementry) => { 8 | const [ro, setRo] = useState(null); 9 | 10 | useEffect(() => { 11 | const observer = new ResizeObserver((entries) => { 12 | telemetry.callback(entries); 13 | }); 14 | 15 | setRo(observer); 16 | 17 | return () => { 18 | observer.disconnect(); 19 | }; 20 | }, [telemetry]); 21 | 22 | return ro; 23 | }; 24 | 25 | export default useResizeObserver; 26 | -------------------------------------------------------------------------------- /examples/src/components/viewport/custom-use-media/CustomUseMedia.tsx: -------------------------------------------------------------------------------- 1 | import useMedia from './useMedia'; 2 | 3 | const CustomUseMedia = ({ 4 | query = '(min-width: 640px)', 5 | callback, 6 | asObject = false, 7 | messages: { ok = 'desktop', ko = 'not desktop' } = { 8 | ok: 'desktop', 9 | ko: 'not desktop', 10 | }, 11 | }: { 12 | query?: string; 13 | callback?: () => void; 14 | asObject?: boolean; 15 | messages?: { ok: string; ko: string }; 16 | }) => { 17 | const doesMatch = useMedia(query, null, { callback, asObject }); 18 | 19 | if (doesMatch === null) { 20 | return
server
; 21 | } 22 | 23 | return
{doesMatch ? ok : ko}
; 24 | }; 25 | 26 | export default CustomUseMedia; 27 | -------------------------------------------------------------------------------- /examples/src/components/viewport/custom-use-media/useMedia.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from 'react'; 2 | 3 | function useMedia( 4 | query: string, 5 | defaultValue: any | null = null, 6 | options?: 7 | | { 8 | callback?: (this: MediaQueryList, ev: MediaQueryListEvent) => any; 9 | asObject: false; 10 | } 11 | | { 12 | callback?: (ev: MediaQueryListEvent) => any; 13 | asObject: true; 14 | } 15 | ) { 16 | const isInBrowser = typeof window !== 'undefined' && window.matchMedia; 17 | 18 | const mq = isInBrowser ? window.matchMedia(query) : null; 19 | 20 | const getValue = useCallback(() => mq?.matches, [mq?.matches]); 21 | 22 | const [value, setValue] = useState(isInBrowser ? getValue : defaultValue); 23 | 24 | useEffect(() => { 25 | if (mq === null) { 26 | return; 27 | } 28 | 29 | if (options?.asObject) { 30 | const handler = { 31 | handleEvent: (ev: MediaQueryListEvent) => { 32 | setValue(getValue); 33 | 34 | options?.callback?.(ev); 35 | }, 36 | }; 37 | 38 | mq.addEventListener('change', handler); 39 | 40 | return () => mq.removeEventListener('change', handler); 41 | } 42 | 43 | function handler(this: MediaQueryList, ev: MediaQueryListEvent) { 44 | setValue(getValue); 45 | 46 | options?.callback?.call(this, ev); 47 | } 48 | 49 | mq.addEventListener('change', handler); 50 | 51 | return () => mq.removeEventListener('change', handler); 52 | }, [getValue, mq, options]); 53 | 54 | return value; 55 | } 56 | 57 | export default useMedia; 58 | -------------------------------------------------------------------------------- /examples/src/components/viewport/deprecated-use-media/DeprecatedUseMedia.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from 'react'; 2 | 3 | function useMedia( 4 | query: string, 5 | defaultValue: any | null = null, 6 | callback?: (this: MediaQueryList, ev: MediaQueryListEvent) => any 7 | ) { 8 | const isInBrowser = typeof window !== 'undefined' && window.matchMedia; 9 | 10 | const mq = isInBrowser ? window.matchMedia(query) : null; 11 | 12 | const getValue = useCallback(() => mq?.matches, [mq?.matches]); 13 | 14 | const [value, setValue] = useState(isInBrowser ? getValue : defaultValue); 15 | 16 | useEffect(() => { 17 | if (mq === null) { 18 | return; 19 | } 20 | 21 | function handler(this: MediaQueryList, ev: MediaQueryListEvent) { 22 | setValue(getValue); 23 | 24 | callback?.call(this, ev); 25 | } 26 | 27 | mq.addListener(handler); 28 | 29 | return () => mq.removeListener(handler); 30 | }, [callback, getValue, mq]); 31 | 32 | return value; 33 | } 34 | 35 | const DeprecatedCustomUseMedia = ({ 36 | query = '(min-width: 640px)', 37 | callback, 38 | messages: { ok = 'desktop', ko = 'not desktop' } = { 39 | ok: 'desktop', 40 | ko: 'not desktop', 41 | }, 42 | }: { 43 | query?: string; 44 | callback?: () => void; 45 | messages?: { ok: string; ko: string }; 46 | }) => { 47 | const doesMatch = useMedia(query, null, callback); 48 | 49 | if (doesMatch === null) { 50 | return
server
; 51 | } 52 | 53 | return
{doesMatch ? ok : ko}
; 54 | }; 55 | 56 | export default DeprecatedCustomUseMedia; 57 | -------------------------------------------------------------------------------- /examples/src/components/viewport/viewport.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, act, screen } from '@testing-library/react'; 2 | 3 | import { 4 | mockViewport, 5 | mockViewportForTestGroup, 6 | configMocks, 7 | } from '../../../../dist'; 8 | 9 | import CustomUseMedia from './custom-use-media/CustomUseMedia'; 10 | import DeprecatedUseMedia from './deprecated-use-media/DeprecatedUseMedia'; 11 | 12 | const VIEWPORT_DESKTOP = { width: '1440px', height: '900px' }; 13 | const VIEWPORT_DESKTOP_EDGE = { width: '640px', height: '400px' }; 14 | 15 | const VIEWPORT_MOBILE = { width: '320px', height: '568px' }; 16 | const VIEWPORT_MOBILE_EDGE = { width: '639px', height: '400px' }; 17 | 18 | configMocks({ act }); 19 | 20 | describe('mockViewport', () => { 21 | describe('It renders correctly on server, desktop and mobile', () => { 22 | it('works on the server', () => { 23 | render(); 24 | 25 | expect(screen.getByText('server')).toBeInTheDocument(); 26 | }); 27 | 28 | it('works on desktop', () => { 29 | const viewport = mockViewport(VIEWPORT_DESKTOP); 30 | 31 | render(); 32 | 33 | expect(screen.getByText('desktop')).toBeInTheDocument(); 34 | 35 | viewport.cleanup(); 36 | }); 37 | 38 | it('works on mobile', () => { 39 | const viewport = mockViewport(VIEWPORT_MOBILE); 40 | 41 | render(); 42 | 43 | expect(screen.getByText('not desktop')).toBeInTheDocument(); 44 | 45 | viewport.cleanup(); 46 | }); 47 | 48 | it('changing viewport description triggers callbacks', () => { 49 | const viewport = mockViewport(VIEWPORT_DESKTOP); 50 | 51 | render(); 52 | 53 | expect(screen.getByText('desktop')).toBeInTheDocument(); 54 | expect(screen.queryByText('not desktop')).not.toBeInTheDocument(); 55 | 56 | viewport.set(VIEWPORT_MOBILE); 57 | 58 | expect(screen.getByText('not desktop')).toBeInTheDocument(); 59 | expect(screen.queryByText('desktop')).not.toBeInTheDocument(); 60 | 61 | viewport.cleanup(); 62 | }); 63 | 64 | it('changing viewport description triggers deprecated callbacks', () => { 65 | const viewport = mockViewport(VIEWPORT_DESKTOP); 66 | const cb = runner.fn(); 67 | 68 | render(); 69 | 70 | expect(screen.getByText('desktop')).toBeInTheDocument(); 71 | expect(screen.queryByText('not desktop')).not.toBeInTheDocument(); 72 | expect(cb).toHaveBeenCalledTimes(0); 73 | 74 | viewport.set(VIEWPORT_MOBILE); 75 | 76 | const [event] = cb.mock.calls[0]; 77 | 78 | expect(screen.getByText('not desktop')).toBeInTheDocument(); 79 | expect(screen.queryByText('desktop')).not.toBeInTheDocument(); 80 | expect(cb).toHaveBeenCalledTimes(1); 81 | expect(event).toBeInstanceOf(MediaQueryListEvent); 82 | expect(event.media).toBe('(min-width: 640px)'); 83 | expect(event.matches).toBe(false); 84 | 85 | viewport.cleanup(); 86 | }); 87 | 88 | it('changing viewport description triggers callbacks with correct params', () => { 89 | const viewport = mockViewport(VIEWPORT_DESKTOP); 90 | const cb = runner.fn(); 91 | 92 | render(); 93 | 94 | expect(screen.getByText('desktop')).toBeInTheDocument(); 95 | expect(screen.queryByText('not desktop')).not.toBeInTheDocument(); 96 | expect(cb).toHaveBeenCalledTimes(0); 97 | 98 | viewport.set(VIEWPORT_MOBILE); 99 | 100 | const [event] = cb.mock.calls[0]; 101 | 102 | expect(cb).toHaveBeenCalledTimes(1); 103 | expect(event).toBeInstanceOf(MediaQueryListEvent); 104 | expect(event.media).toBe('(min-width: 640px)'); 105 | expect(event.matches).toBe(false); 106 | 107 | viewport.cleanup(); 108 | }); 109 | 110 | it('changing viewport description triggers callbacks (passed as object) with correct params', () => { 111 | const viewport = mockViewport(VIEWPORT_DESKTOP); 112 | const cb = runner.fn(); 113 | 114 | render(); 115 | 116 | expect(screen.getByText('desktop')).toBeInTheDocument(); 117 | expect(screen.queryByText('not desktop')).not.toBeInTheDocument(); 118 | expect(cb).toHaveBeenCalledTimes(0); 119 | 120 | viewport.set(VIEWPORT_MOBILE); 121 | 122 | const [event] = cb.mock.calls[0]; 123 | 124 | expect(cb).toHaveBeenCalledTimes(1); 125 | expect(event).toBeInstanceOf(MediaQueryListEvent); 126 | expect(event.media).toBe('(min-width: 640px)'); 127 | expect(event.matches).toBe(false); 128 | 129 | viewport.cleanup(); 130 | }); 131 | 132 | it('triggers callbacks only when state actually changes', () => { 133 | const viewport = mockViewport(VIEWPORT_DESKTOP); 134 | const cb = runner.fn(); 135 | 136 | render(); 137 | 138 | expect(screen.getByText('desktop')).toBeInTheDocument(); 139 | expect(screen.queryByText('not desktop')).not.toBeInTheDocument(); 140 | expect(cb).toHaveBeenCalledTimes(0); 141 | 142 | viewport.set(VIEWPORT_DESKTOP_EDGE); 143 | 144 | expect(screen.getByText('desktop')).toBeInTheDocument(); 145 | expect(screen.queryByText('not desktop')).not.toBeInTheDocument(); 146 | expect(cb).toHaveBeenCalledTimes(0); 147 | 148 | viewport.set(VIEWPORT_MOBILE_EDGE); 149 | 150 | expect(screen.getByText('not desktop')).toBeInTheDocument(); 151 | expect(screen.queryByText('desktop')).not.toBeInTheDocument(); 152 | expect(cb).toHaveBeenCalledTimes(1); 153 | 154 | viewport.set(VIEWPORT_MOBILE); 155 | 156 | expect(screen.getByText('not desktop')).toBeInTheDocument(); 157 | expect(screen.queryByText('desktop')).not.toBeInTheDocument(); 158 | expect(cb).toHaveBeenCalledTimes(1); 159 | 160 | viewport.cleanup(); 161 | }); 162 | 163 | it('works with multiple lists', () => { 164 | const viewport = mockViewport({ width: '600px' }); 165 | const cb1 = runner.fn(); 166 | const cb2 = runner.fn(); 167 | 168 | render( 169 | <> 170 | =300px', ko: '<300px' }} 174 | /> 175 | =500px', ko: '<500px' }} 179 | /> 180 | 181 | ); 182 | 183 | expect(screen.getByText('>=300px')).toBeInTheDocument(); 184 | expect(screen.getByText('>=500px')).toBeInTheDocument(); 185 | expect(cb1).toHaveBeenCalledTimes(0); 186 | expect(cb2).toHaveBeenCalledTimes(0); 187 | 188 | viewport.set({ width: '500px' }); 189 | 190 | expect(screen.getByText('>=300px')).toBeInTheDocument(); 191 | expect(screen.getByText('>=500px')).toBeInTheDocument(); 192 | expect(cb1).toHaveBeenCalledTimes(0); 193 | expect(cb2).toHaveBeenCalledTimes(0); 194 | 195 | viewport.set({ width: '499px' }); 196 | 197 | expect(screen.getByText('>=300px')).toBeInTheDocument(); 198 | expect(screen.getByText('<500px')).toBeInTheDocument(); 199 | expect(cb1).toHaveBeenCalledTimes(0); 200 | expect(cb2).toHaveBeenCalledTimes(1); 201 | 202 | viewport.set({ width: '300px' }); 203 | 204 | expect(screen.getByText('>=300px')).toBeInTheDocument(); 205 | expect(screen.getByText('<500px')).toBeInTheDocument(); 206 | expect(cb1).toHaveBeenCalledTimes(0); 207 | expect(cb2).toHaveBeenCalledTimes(1); 208 | 209 | viewport.set({ width: '299px' }); 210 | 211 | expect(screen.getByText('<300px')).toBeInTheDocument(); 212 | expect(screen.getByText('<500px')).toBeInTheDocument(); 213 | expect(cb1).toHaveBeenCalledTimes(1); 214 | expect(cb2).toHaveBeenCalledTimes(1); 215 | 216 | viewport.set({ width: '600px' }); 217 | 218 | expect(screen.getByText('>=300px')).toBeInTheDocument(); 219 | expect(screen.getByText('>=500px')).toBeInTheDocument(); 220 | expect(cb1).toHaveBeenCalledTimes(2); 221 | expect(cb2).toHaveBeenCalledTimes(2); 222 | 223 | viewport.cleanup(); 224 | }); 225 | }); 226 | }); 227 | 228 | describe('mockViewportForTestGroup', () => { 229 | describe('Desktop', () => { 230 | mockViewportForTestGroup(VIEWPORT_DESKTOP); 231 | 232 | it('works on desktop and mobile, even if we change the viewport description', () => { 233 | render(); 234 | 235 | expect(screen.getByText('desktop')).toBeInTheDocument(); 236 | expect(screen.queryByText('not desktop')).not.toBeInTheDocument(); 237 | }); 238 | }); 239 | 240 | describe('Mobile', () => { 241 | mockViewportForTestGroup(VIEWPORT_MOBILE); 242 | 243 | it('works on desktop and mobile, even if we change the viewport description', () => { 244 | render(); 245 | 246 | expect(screen.getByText('not desktop')).toBeInTheDocument(); 247 | expect(screen.queryByText('desktop')).not.toBeInTheDocument(); 248 | }); 249 | }); 250 | }); 251 | -------------------------------------------------------------------------------- /examples/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /examples/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | import './index.css'; 5 | import App from './App'; 6 | 7 | const root = ReactDOM.createRoot( 8 | document.getElementById('root') as HTMLElement 9 | ); 10 | 11 | root.render( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /examples/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | import failOnConsole from 'jest-fail-on-console'; 7 | 8 | failOnConsole(); 9 | 10 | // or with options: 11 | failOnConsole({ 12 | shouldFailOnWarn: false, 13 | }); 14 | 15 | function useFakeTimers() { 16 | jest.useFakeTimers(); 17 | } 18 | 19 | function useRealTimers() { 20 | jest.useRealTimers(); 21 | } 22 | 23 | async function advanceTimersByTime(time: number) { 24 | jest.advanceTimersByTime(time); 25 | } 26 | 27 | function fn() { 28 | return jest.fn(); 29 | } 30 | 31 | globalThis.runner = { 32 | name: 'jest', 33 | useFakeTimers, 34 | useRealTimers, 35 | advanceTimersByTime, 36 | fn, 37 | }; 38 | -------------------------------------------------------------------------------- /examples/src/setupVitests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | import failOnConsole from 'jest-fail-on-console'; 7 | import { vi } from 'vitest'; 8 | 9 | failOnConsole(); 10 | 11 | // or with options: 12 | failOnConsole({ 13 | shouldFailOnWarn: false, 14 | }); 15 | 16 | function useFakeTimers() { 17 | // vitest doesn't enable performance by default 18 | vi.useFakeTimers({ 19 | shouldClearNativeTimers: true, 20 | toFake: [ 21 | 'setTimeout', 22 | 'clearTimeout', 23 | 'setInterval', 24 | 'clearInterval', 25 | 'performance', 26 | 'requestAnimationFrame', 27 | 'cancelAnimationFrame', 28 | ], 29 | }); 30 | } 31 | 32 | function useRealTimers() { 33 | vi.useRealTimers(); 34 | } 35 | 36 | async function advanceTimersByTime(time: number) { 37 | await vi.advanceTimersByTimeAsync(time); 38 | } 39 | 40 | function fn() { 41 | return vi.fn(); 42 | } 43 | 44 | globalThis.runner = { 45 | name: 'vi', 46 | useFakeTimers, 47 | useRealTimers, 48 | advanceTimersByTime, 49 | fn, 50 | }; 51 | -------------------------------------------------------------------------------- /examples/swcjest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jsdom', 3 | transform: { 4 | '^.+\\.(t|j)sx?$': [ 5 | '@swc/jest', 6 | // This is needed to make swc/jest work with React 18 in github actions 7 | { 8 | jsc: { 9 | transform: { 10 | react: { 11 | runtime: 'automatic', 12 | }, 13 | }, 14 | }, 15 | }, 16 | ], 17 | }, 18 | setupFilesAfterEnv: ['/src/setupTests.ts'], 19 | moduleNameMapper: { 20 | '\\.(css|less)$': 'identity-obj-proxy', 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src", "global.d.ts"] 20 | } 21 | -------------------------------------------------------------------------------- /examples/vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | import react from '@vitejs/plugin-react'; 5 | import { defineConfig } from 'vite'; 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | plugins: [react()], 10 | test: { 11 | globals: true, 12 | environment: 'jsdom', 13 | setupFiles: './src/setupVitests.ts', 14 | include: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | declare global { 4 | // eslint-disable-next-line no-var 5 | var runner: { 6 | name: 'vi' | 'jest'; 7 | useFakeTimers: () => void; 8 | useRealTimers: () => void; 9 | advanceTimersByTime: (time: number) => Promise; 10 | fn: () => jest.Mock; 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /jest-setup.ts: -------------------------------------------------------------------------------- 1 | function useFakeTimers() { 2 | jest.useFakeTimers(); 3 | } 4 | 5 | function useRealTimers() { 6 | jest.useRealTimers(); 7 | } 8 | 9 | async function advanceTimersByTime(time: number) { 10 | jest.advanceTimersByTime(time); 11 | } 12 | 13 | function fn() { 14 | return jest.fn(); 15 | } 16 | 17 | globalThis.runner = { 18 | name: 'jest', 19 | useFakeTimers, 20 | useRealTimers, 21 | advanceTimersByTime, 22 | fn, 23 | }; 24 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'jsdom', 5 | setupFilesAfterEnv: ['/jest-setup.ts'], 6 | testPathIgnorePatterns: ['/node_modules/', '/examples/'], 7 | globals: { 8 | 'ts-jest': { 9 | tsconfig: { 10 | jsx: 'react-jsx', 11 | }, 12 | diagnostics: { 13 | warnOnly: true, 14 | }, 15 | }, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jsdom-testing-mocks", 3 | "version": "1.13.1", 4 | "author": "Ivan Galiatin", 5 | "license": "MIT", 6 | "description": "A set of tools for emulating browser behavior in jsdom environment", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/trurl-master/jsdom-testing-mocks.git" 10 | }, 11 | "keywords": [ 12 | "testing", 13 | "jsdom", 14 | "jest", 15 | "vitest", 16 | "mock", 17 | "Resize Observer API", 18 | "Intersection Observer API", 19 | "Web Animations API", 20 | "WAAPI", 21 | "matchMedia", 22 | "viewport", 23 | "react" 24 | ], 25 | "main": "dist/index.js", 26 | "typings": "dist/index.d.ts", 27 | "files": [ 28 | "/dist", 29 | "/src", 30 | "!/src/**/__tests__/*", 31 | "!/src/**/*.test.tsx", 32 | "!/src/mocks/web-animations-api/testTools.ts" 33 | ], 34 | "engines": { 35 | "node": ">=14" 36 | }, 37 | "scripts": { 38 | "watch": "tsup --watch", 39 | "build": "tsup", 40 | "test:jest": "jest", 41 | "test:swc": "jest --config ./swcjest.config.ts", 42 | "test:vi": "vitest --config ./vitest.config.ts run", 43 | "test:examples": "npm --prefix ./examples run test:all", 44 | "test:all": "npm run test:jest && npm run test:vi && npm run test:swc && npm run test:examples", 45 | "lint": "eslint src/ --ext .ts,.tsx", 46 | "prepare": "tsup" 47 | }, 48 | "husky": { 49 | "hooks": { 50 | "pre-commit": "npm run lint" 51 | } 52 | }, 53 | "prettier": { 54 | "printWidth": 80, 55 | "semi": true, 56 | "singleQuote": true, 57 | "trailingComma": "es5" 58 | }, 59 | "module": "dist/esm/index.js", 60 | "tsup": { 61 | "entry": [ 62 | "src/index.ts" 63 | ], 64 | "splitting": false, 65 | "sourcemap": true, 66 | "dts": true, 67 | "format": [ 68 | "cjs", 69 | "esm" 70 | ], 71 | "clean": true, 72 | "legacyOutput": true 73 | }, 74 | "dependencies": { 75 | "bezier-easing": "^2.1.0", 76 | "css-mediaquery": "^0.1.2" 77 | }, 78 | "devDependencies": { 79 | "@swc/core": "^1.3.82", 80 | "@swc/jest": "^0.2.29", 81 | "@types/css-mediaquery": "^0.1.1", 82 | "@types/jest": "^28.1.8", 83 | "@types/testing-library__jest-dom": "^5.14.5", 84 | "@typescript-eslint/eslint-plugin": "^5.30.3", 85 | "@typescript-eslint/parser": "^5.30.3", 86 | "eslint": "^8.19.0", 87 | "eslint-config-prettier": "^8.5.0", 88 | "eslint-plugin-jsx-a11y": "^6.6.0", 89 | "eslint-plugin-react": "^7.30.1", 90 | "husky": "4.2.3", 91 | "jest": "^28.1.3", 92 | "jest-environment-jsdom": "^28.1.3", 93 | "jest-fail-on-console": "^3.1.1", 94 | "prettier": "^2.7.1", 95 | "ts-jest": "^28.0.8", 96 | "ts-node": "^10.8.2", 97 | "tslib": "^2.4.0", 98 | "tsup": "^6.1.3", 99 | "type-fest": "^2.15.1", 100 | "typescript": "^4.7.4", 101 | "vitest": "^0.30.1" 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/helper.ts: -------------------------------------------------------------------------------- 1 | export class UndefinedHookError extends Error { 2 | constructor(hook: string) { 3 | let message = ''; 4 | 5 | // @ts-ignore 6 | if (typeof global?.['vi'] !== 'undefined') { 7 | message = `jsdom-testing-mocks: ${hook} is not defined. You can enable globals in your config or pass the hook manually to the configMocks function`; 8 | } else { 9 | message = `jsdom-testing-mocks: ${hook} is not defined. If you need to pass it manually, please use the configMocks function`; 10 | } 11 | 12 | super(message); 13 | } 14 | } 15 | 16 | export class WrongEnvironmentError extends Error { 17 | constructor() { 18 | super( 19 | 'jsdom-testing-mocks: window is not defined. Please use this library in a browser environment' 20 | ); 21 | } 22 | } 23 | 24 | export const isJsdomEnv = () => { 25 | return typeof window !== 'undefined'; 26 | }; 27 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mocks/intersection-observer'; 2 | export * from './mocks/resize-observer'; 3 | export * from './mocks/size'; 4 | export * from './mocks/viewport'; 5 | export * from './mocks/web-animations-api'; 6 | export * from './tools'; 7 | -------------------------------------------------------------------------------- /src/mocks/MediaQueryListEvent.ts: -------------------------------------------------------------------------------- 1 | import { isJsdomEnv, WrongEnvironmentError } from '../helper'; 2 | 3 | class MockedMediaQueryListEvent extends Event implements MediaQueryListEvent { 4 | readonly matches: boolean; 5 | readonly media: string; 6 | 7 | constructor(type: 'change', eventInitDict: MediaQueryListEventInit = {}) { 8 | super(type); 9 | 10 | this.media = eventInitDict.media ?? ''; 11 | this.matches = eventInitDict.matches ?? false; 12 | } 13 | } 14 | 15 | function mockMediaQueryListEvent() { 16 | if (!isJsdomEnv()) { 17 | throw new WrongEnvironmentError(); 18 | } 19 | 20 | if (typeof MediaQueryListEvent === 'undefined') { 21 | Object.defineProperty(window, 'MediaQueryListEvent', { 22 | writable: true, 23 | configurable: true, 24 | value: MockedMediaQueryListEvent, 25 | }); 26 | } 27 | } 28 | 29 | export { MockedMediaQueryListEvent, mockMediaQueryListEvent }; 30 | -------------------------------------------------------------------------------- /src/mocks/intersection-observer.env.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import { WrongEnvironmentError } from '../helper'; 6 | import { mockIntersectionObserver } from './intersection-observer'; 7 | 8 | describe('mockIntersectionObserver', () => { 9 | it('throws an error when used in a non jsdom environment', () => { 10 | expect(() => { 11 | mockIntersectionObserver(); 12 | }).toThrowError(WrongEnvironmentError); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/mocks/intersection-observer.test.ts: -------------------------------------------------------------------------------- 1 | import { mockIntersectionObserver } from './intersection-observer'; 2 | 3 | const io = mockIntersectionObserver(); 4 | 5 | describe('mockIntersectionObserver', () => { 6 | it("don't call unobserved nodes, enterNode", () => { 7 | const node = document.createElement('div'); 8 | const callback = runner.fn(); 9 | 10 | const observer = new IntersectionObserver(callback); 11 | 12 | observer.observe(node); 13 | 14 | io.enterNode(node); 15 | 16 | expect(callback).toHaveBeenCalledTimes(1); 17 | 18 | observer.unobserve(node); 19 | 20 | io.enterNode(node); 21 | 22 | expect(callback).toHaveBeenCalledTimes(1); 23 | }); 24 | 25 | it('handles multiple nodes correctly, enterNodes', () => { 26 | const node1 = document.createElement('div'); 27 | const node2 = document.createElement('div'); 28 | const callback = runner.fn(); 29 | 30 | const observer = new IntersectionObserver(callback); 31 | 32 | observer.observe(node1); 33 | observer.observe(node2); 34 | 35 | io.enterNodes([node1, node2]); 36 | 37 | expect(callback).toHaveBeenCalledTimes(1); 38 | 39 | observer.unobserve(node1); 40 | observer.unobserve(node2); 41 | 42 | io.enterNodes([node1, node2]); 43 | 44 | expect(callback).toHaveBeenCalledTimes(1); 45 | }); 46 | 47 | it('handles multiple nodes correctly, enterAll', () => { 48 | const node1 = document.createElement('div'); 49 | const node2 = document.createElement('div'); 50 | const callback = runner.fn(); 51 | 52 | const observer = new IntersectionObserver(callback); 53 | 54 | observer.observe(node1); 55 | observer.observe(node2); 56 | 57 | io.enterAll(); 58 | 59 | expect(callback).toHaveBeenCalledTimes(1); 60 | 61 | observer.unobserve(node1); 62 | observer.unobserve(node2); 63 | 64 | io.enterAll(); 65 | 66 | expect(callback).toHaveBeenCalledTimes(1); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/mocks/intersection-observer.ts: -------------------------------------------------------------------------------- 1 | import { Writable, PartialDeep } from 'type-fest'; 2 | import { mockDOMRect } from './size/DOMRect'; 3 | import { getConfig } from '../tools'; 4 | import { isJsdomEnv, WrongEnvironmentError } from '../helper'; 5 | 6 | const config = getConfig(); 7 | 8 | export type IntersectionDescription = Omit< 9 | PartialDeep>, 10 | 'target' 11 | > & { 12 | target?: Element; 13 | }; 14 | 15 | export type NodeIntersectionDescription = { 16 | node: HTMLElement; 17 | desc?: IntersectionDescription; 18 | }; 19 | 20 | type State = { 21 | observers: MockedIntersectionObserver[]; 22 | }; 23 | 24 | const defaultState: State = { 25 | observers: [], 26 | }; 27 | 28 | const state = { ...defaultState }; 29 | 30 | function isElement(tested: unknown): tested is HTMLElement { 31 | return typeof HTMLElement === 'object' 32 | ? tested instanceof HTMLElement // DOM2 33 | : Boolean(tested) && 34 | typeof tested === 'object' && 35 | tested !== null && 36 | (tested as HTMLElement).nodeType === 1 && 37 | typeof (tested as HTMLElement).nodeName === 'string'; 38 | } 39 | 40 | function getObserversByNode(node: HTMLElement) { 41 | return state.observers.filter((observer) => observer.nodes.includes(node)); 42 | } 43 | 44 | function normalizeNodeDescriptions( 45 | nodeDescriptions: (NodeIntersectionDescription | HTMLElement)[] 46 | ): NodeIntersectionDescription[] { 47 | return nodeDescriptions.map((nodeDesc) => { 48 | if (isElement(nodeDesc)) { 49 | return { node: nodeDesc }; 50 | } 51 | 52 | return nodeDesc; 53 | }); 54 | } 55 | 56 | function getNodeDescriptionsByObserver( 57 | nodeDescriptions: NodeIntersectionDescription[] 58 | ) { 59 | const observerNodes: { 60 | observer: MockedIntersectionObserver; 61 | nodeDescriptions: NodeIntersectionDescription[]; 62 | }[] = []; 63 | 64 | nodeDescriptions.forEach(({ node, desc }) => { 65 | const observers = getObserversByNode(node); 66 | 67 | observers.forEach((observer) => { 68 | const observerNode = observerNodes.find( 69 | ({ observer: obs }) => obs === observer 70 | ); 71 | 72 | if (observerNode) { 73 | observerNode.nodeDescriptions.push({ node, desc }); 74 | } else { 75 | observerNodes.push({ 76 | observer, 77 | nodeDescriptions: [{ node, desc }], 78 | }); 79 | } 80 | }); 81 | }); 82 | 83 | return observerNodes; 84 | } 85 | 86 | function findNodeIndex(nodes: HTMLElement[], node: HTMLElement) { 87 | const index = nodes.findIndex((nodeInArray) => node.isSameNode(nodeInArray)); 88 | 89 | if (index === -1) { 90 | throw new Error('IntersectionObserver mock: node not found'); 91 | } 92 | 93 | return index; 94 | } 95 | 96 | export class MockedIntersectionObserver implements IntersectionObserver { 97 | nodes: HTMLElement[] = []; 98 | nodeStates: IntersectionObserverEntry[] = []; 99 | callback: IntersectionObserverCallback; 100 | readonly root: Element | Document | null = null; 101 | readonly rootMargin: string = '0px 0px 0px 0px'; 102 | readonly thresholds: ReadonlyArray = [0]; 103 | timeOrigin = 0; 104 | 105 | constructor( 106 | callback: IntersectionObserverCallback, 107 | options?: IntersectionObserverInit | undefined 108 | ) { 109 | this.callback = callback; 110 | 111 | if (options) { 112 | if (typeof options.root !== 'undefined') { 113 | this.root = options.root; 114 | } 115 | 116 | if (typeof options.rootMargin !== 'undefined') { 117 | this.rootMargin = options.rootMargin; 118 | } 119 | 120 | if (typeof options.threshold !== 'undefined') { 121 | this.thresholds = Array.isArray(options.threshold) 122 | ? options.threshold 123 | : [options.threshold]; 124 | } 125 | } 126 | 127 | this.timeOrigin = performance.now(); 128 | 129 | state.observers.push(this); 130 | } 131 | 132 | observe(node: HTMLElement) { 133 | this.nodes.push(node); 134 | this.nodeStates.push({ 135 | isIntersecting: false, 136 | target: node, 137 | time: performance.now() - this.timeOrigin, 138 | rootBounds: new DOMRectReadOnly(), 139 | intersectionRect: new DOMRectReadOnly(), 140 | intersectionRatio: 0, 141 | boundingClientRect: new DOMRectReadOnly(), 142 | }); 143 | } 144 | 145 | unobserve(node: HTMLElement) { 146 | const index = this.nodes.findIndex((value) => value.isSameNode(node)); 147 | 148 | this.nodes.splice(index, 1); 149 | this.nodeStates.splice(index, 1); 150 | } 151 | 152 | disconnect() { 153 | this.nodes = []; 154 | this.nodeStates = []; 155 | } 156 | 157 | triggerNode(node: HTMLElement, desc: IntersectionDescription) { 158 | const index = findNodeIndex(this.nodes, node); 159 | const nodeState = this.nodeStates[index]; 160 | 161 | this.nodeStates[index] = { 162 | ...nodeState, 163 | time: performance.now() - this.timeOrigin, 164 | ...desc, 165 | } as IntersectionObserverEntry; 166 | 167 | this.callback([this.nodeStates[index]], this); 168 | } 169 | 170 | triggerNodes(nodeDescriptions: NodeIntersectionDescription[]) { 171 | if (nodeDescriptions.length === 0) return; 172 | 173 | const nodeIndexes = nodeDescriptions.map(({ node }) => 174 | findNodeIndex(this.nodes, node) 175 | ); 176 | 177 | const nodeStates = nodeDescriptions.map(({ desc }, index) => { 178 | const newState = { 179 | ...this.nodeStates[nodeIndexes[index]], 180 | time: performance.now() - this.timeOrigin, 181 | ...desc, 182 | } as IntersectionObserverEntry; 183 | 184 | this.nodeStates[nodeIndexes[index]] = newState; 185 | 186 | return newState; 187 | }); 188 | 189 | this.callback(nodeStates, this); 190 | } 191 | 192 | takeRecords(): IntersectionObserverEntry[] { 193 | return []; 194 | } 195 | } 196 | 197 | function mockIntersectionObserver() { 198 | if (!isJsdomEnv()) { 199 | throw new WrongEnvironmentError(); 200 | } 201 | 202 | mockDOMRect(); 203 | 204 | const savedImplementation = window.IntersectionObserver; 205 | 206 | Object.defineProperty(window, 'IntersectionObserver', { 207 | writable: true, 208 | configurable: true, 209 | value: MockedIntersectionObserver, 210 | }); 211 | 212 | config.afterAll(() => { 213 | window.IntersectionObserver = savedImplementation; 214 | }); 215 | 216 | return { 217 | enterAll: (desc?: IntersectionDescription) => { 218 | config.act(() => { 219 | state.observers.forEach((observer) => { 220 | const nodeDescriptions = observer.nodes.map((node) => ({ 221 | node, 222 | desc: { 223 | intersectionRatio: 1, 224 | ...desc, 225 | isIntersecting: true, 226 | }, 227 | })); 228 | 229 | observer.triggerNodes(nodeDescriptions); 230 | }); 231 | }); 232 | }, 233 | enterNode: (node: HTMLElement, desc?: IntersectionDescription) => { 234 | const observers = getObserversByNode(node); 235 | 236 | config.act(() => { 237 | observers.forEach((observer) => { 238 | observer.triggerNode(node, { 239 | intersectionRatio: 1, 240 | ...desc, 241 | isIntersecting: true, 242 | }); 243 | }); 244 | }); 245 | }, 246 | enterNodes: ( 247 | nodeDescriptions: (NodeIntersectionDescription | HTMLElement)[] 248 | ) => { 249 | const normalizedNodeDescriptions = 250 | normalizeNodeDescriptions(nodeDescriptions); 251 | const observerNodes = getNodeDescriptionsByObserver( 252 | normalizedNodeDescriptions 253 | ); 254 | 255 | config.act(() => { 256 | observerNodes.forEach(({ observer, nodeDescriptions }) => { 257 | observer.triggerNodes( 258 | nodeDescriptions.map(({ node, desc }) => ({ 259 | node, 260 | desc: { intersectionRatio: 1, ...desc, isIntersecting: true }, 261 | })) 262 | ); 263 | }); 264 | }); 265 | }, 266 | leaveAll: (desc?: IntersectionDescription) => { 267 | config.act(() => { 268 | state.observers.forEach((observer) => { 269 | const nodeDescriptions = observer.nodes.map((node) => ({ 270 | node, 271 | desc: { 272 | intersectionRatio: 0, 273 | ...desc, 274 | isIntersecting: false, 275 | }, 276 | })); 277 | 278 | observer.triggerNodes(nodeDescriptions); 279 | }); 280 | }); 281 | }, 282 | leaveNode: (node: HTMLElement, desc?: IntersectionDescription) => { 283 | const observers = getObserversByNode(node); 284 | 285 | config.act(() => { 286 | observers.forEach((observer) => { 287 | observer.triggerNode(node, { 288 | intersectionRatio: 0, 289 | ...desc, 290 | isIntersecting: false, 291 | }); 292 | }); 293 | }); 294 | }, 295 | triggerNodes: ( 296 | nodeDescriptions: (NodeIntersectionDescription | HTMLElement)[] 297 | ) => { 298 | const normalizedNodeDescriptions = 299 | normalizeNodeDescriptions(nodeDescriptions); 300 | 301 | const observerNodes = getNodeDescriptionsByObserver( 302 | normalizedNodeDescriptions 303 | ); 304 | 305 | config.act(() => { 306 | observerNodes.forEach(({ observer, nodeDescriptions }) => { 307 | observer.triggerNodes(nodeDescriptions); 308 | }); 309 | }); 310 | }, 311 | leaveNodes: ( 312 | nodeDescriptions: (NodeIntersectionDescription | HTMLElement)[] 313 | ) => { 314 | const normalizedNodeDescriptions = 315 | normalizeNodeDescriptions(nodeDescriptions); 316 | 317 | const observerNodes = getNodeDescriptionsByObserver( 318 | normalizedNodeDescriptions 319 | ); 320 | 321 | config.act(() => { 322 | observerNodes.forEach(({ observer, nodeDescriptions }) => { 323 | observer.triggerNodes( 324 | nodeDescriptions.map(({ node, desc }) => ({ 325 | node, 326 | desc: { intersectionRatio: 0, ...desc, isIntersecting: false }, 327 | })) 328 | ); 329 | }); 330 | }); 331 | }, 332 | cleanup: () => { 333 | window.IntersectionObserver = savedImplementation; 334 | 335 | state.observers = []; 336 | }, 337 | }; 338 | } 339 | 340 | export { mockIntersectionObserver }; 341 | -------------------------------------------------------------------------------- /src/mocks/resize-observer.env.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import { WrongEnvironmentError } from '../helper'; 6 | import { mockResizeObserver } from './resize-observer'; 7 | 8 | describe('mockResizeObserver', () => { 9 | it('throws an error when used in a non jsdom environment', () => { 10 | expect(() => { 11 | mockResizeObserver(); 12 | }).toThrowError(WrongEnvironmentError); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/mocks/resize-observer.ts: -------------------------------------------------------------------------------- 1 | import { RequireAtLeastOne } from 'type-fest'; 2 | import { mockDOMRect } from './size/DOMRect'; 3 | import { isJsdomEnv, WrongEnvironmentError } from '../helper'; 4 | import { getConfig } from '../tools'; 5 | 6 | const config = getConfig(); 7 | 8 | type Sizes = { 9 | borderBoxSize: ResizeObserverSize[]; 10 | contentBoxSize: ResizeObserverSize[]; 11 | contentRect: DOMRectReadOnly; 12 | }; 13 | 14 | type ResizeObserverSizeInput = RequireAtLeastOne; 15 | type SizeInput = { 16 | borderBoxSize: ResizeObserverSizeInput[] | ResizeObserverSizeInput; 17 | contentBoxSize: ResizeObserverSizeInput[] | ResizeObserverSizeInput; 18 | }; 19 | 20 | type Size = RequireAtLeastOne; 21 | 22 | type State = { 23 | observers: MockedResizeObserver[]; 24 | targetObservers: Map; 25 | elementSizes: Map; 26 | }; 27 | 28 | const state: State = { 29 | observers: [], 30 | targetObservers: new Map(), 31 | elementSizes: new Map(), 32 | }; 33 | 34 | function resetState() { 35 | state.observers = []; 36 | state.targetObservers = new Map(); 37 | state.elementSizes = new Map(); 38 | } 39 | 40 | function defineResizeObserverSize( 41 | input: ResizeObserverSizeInput 42 | ): ResizeObserverSize { 43 | return { 44 | blockSize: input.blockSize ?? 0, 45 | inlineSize: input.inlineSize ?? 0, 46 | }; 47 | } 48 | 49 | class MockedResizeObserver implements ResizeObserver { 50 | callback: ResizeObserverCallback; 51 | observationTargets = new Set(); 52 | activeTargets = new Set(); 53 | 54 | constructor(callback: ResizeObserverCallback) { 55 | this.callback = callback; 56 | state.observers.push(this); 57 | } 58 | 59 | observe = (node: HTMLElement) => { 60 | this.observationTargets.add(node); 61 | this.activeTargets.add(node); 62 | 63 | if (state.targetObservers.has(node)) { 64 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 65 | state.targetObservers.get(node)!.push(this); 66 | } else { 67 | state.targetObservers.set(node, [this]); 68 | } 69 | }; 70 | 71 | unobserve = (node: HTMLElement) => { 72 | this.observationTargets.delete(node); 73 | 74 | const targetObservers = state.targetObservers.get(node); 75 | 76 | if (targetObservers) { 77 | const index = targetObservers.findIndex((mro) => mro === this); 78 | 79 | targetObservers.splice(index, 1); 80 | 81 | if (targetObservers.length === 0) { 82 | state.targetObservers.delete(node); 83 | } 84 | } 85 | }; 86 | 87 | disconnect = () => { 88 | for (const node of this.observationTargets) { 89 | const targetObservers = state.targetObservers.get(node); 90 | 91 | if (targetObservers) { 92 | const index = targetObservers.findIndex((mro) => mro === this); 93 | 94 | targetObservers.splice(index, 1); 95 | 96 | if (targetObservers.length === 0) { 97 | state.targetObservers.delete(node); 98 | } 99 | } 100 | } 101 | 102 | this.observationTargets.clear(); 103 | }; 104 | } 105 | 106 | function elementToEntry(element: HTMLElement): ResizeObserverEntry | null { 107 | const boundingClientRect = element.getBoundingClientRect(); 108 | let sizes = state.elementSizes.get(element); 109 | 110 | if (!sizes) { 111 | sizes = { 112 | borderBoxSize: [ 113 | { 114 | blockSize: boundingClientRect.width, 115 | inlineSize: boundingClientRect.height, 116 | }, 117 | ], 118 | contentBoxSize: [ 119 | { 120 | blockSize: boundingClientRect.width, 121 | inlineSize: boundingClientRect.height, 122 | }, 123 | ], 124 | contentRect: boundingClientRect, 125 | }; 126 | } 127 | 128 | if (sizes.contentRect.width === 0 && sizes.contentRect.height === 0) { 129 | return null; 130 | } 131 | 132 | return { 133 | borderBoxSize: Object.freeze(sizes.borderBoxSize), 134 | contentBoxSize: Object.freeze(sizes.contentBoxSize), 135 | contentRect: sizes.contentRect, 136 | devicePixelContentBoxSize: Object.freeze( 137 | sizes.contentBoxSize.map((size) => ({ 138 | blockSize: size.blockSize * window.devicePixelRatio, 139 | inlineSize: size.inlineSize * window.devicePixelRatio, 140 | })) 141 | ), 142 | target: element, 143 | }; 144 | } 145 | 146 | function mockResizeObserver() { 147 | if (!isJsdomEnv()) { 148 | throw new WrongEnvironmentError(); 149 | } 150 | 151 | mockDOMRect(); 152 | 153 | const savedImplementation = window.ResizeObserver; 154 | 155 | Object.defineProperty(window, 'ResizeObserver', { 156 | writable: true, 157 | configurable: true, 158 | value: MockedResizeObserver, 159 | }); 160 | 161 | config.afterEach(() => { 162 | resetState(); 163 | }); 164 | 165 | config.afterAll(() => { 166 | window.ResizeObserver = savedImplementation; 167 | }); 168 | 169 | return { 170 | getObservers: (element?: HTMLElement) => { 171 | if (element) { 172 | return [...(state.targetObservers.get(element) ?? [])]; 173 | } 174 | 175 | return [...state.observers]; 176 | }, 177 | getObservedElements: (observer?: ResizeObserver) => { 178 | if (observer) { 179 | return [...(observer as MockedResizeObserver).observationTargets]; 180 | } 181 | 182 | return [...state.targetObservers.keys()]; 183 | }, 184 | mockElementSize: (element: HTMLElement, size: Size) => { 185 | let contentBoxSize: ResizeObserverSize[]; 186 | let borderBoxSize: ResizeObserverSize[]; 187 | 188 | if (!size.borderBoxSize && size.contentBoxSize) { 189 | if (!Array.isArray(size.contentBoxSize)) { 190 | size.contentBoxSize = [size.contentBoxSize]; 191 | } 192 | 193 | contentBoxSize = size.contentBoxSize.map(defineResizeObserverSize); 194 | borderBoxSize = contentBoxSize; 195 | } else if (size.borderBoxSize && !size.contentBoxSize) { 196 | if (!Array.isArray(size.borderBoxSize)) { 197 | size.borderBoxSize = [size.borderBoxSize]; 198 | } 199 | 200 | contentBoxSize = size.borderBoxSize.map(defineResizeObserverSize); 201 | borderBoxSize = contentBoxSize; 202 | } else if (size.borderBoxSize && size.contentBoxSize) { 203 | if (!Array.isArray(size.borderBoxSize)) { 204 | size.borderBoxSize = [size.borderBoxSize]; 205 | } 206 | 207 | if (!Array.isArray(size.contentBoxSize)) { 208 | size.contentBoxSize = [size.contentBoxSize]; 209 | } 210 | 211 | contentBoxSize = size.contentBoxSize.map(defineResizeObserverSize); 212 | borderBoxSize = size.borderBoxSize.map(defineResizeObserverSize); 213 | 214 | if (borderBoxSize.length !== contentBoxSize.length) { 215 | throw new Error( 216 | 'Both borderBoxSize and contentBoxSize must have the same amount of elements.' 217 | ); 218 | } 219 | } else { 220 | throw new Error( 221 | 'Neither borderBoxSize nor contentBoxSize was provided.' 222 | ); 223 | } 224 | 225 | // verify contentBoxSize and borderBoxSize are not negative 226 | contentBoxSize.forEach((size, index) => { 227 | if (size.blockSize < 0) { 228 | throw new Error( 229 | `contentBoxSize[${index}].blockSize must not be negative.` 230 | ); 231 | } 232 | 233 | if (size.inlineSize < 0) { 234 | throw new Error( 235 | `contentBoxSize[${index}].inlineSize must not be negative.` 236 | ); 237 | } 238 | }); 239 | 240 | borderBoxSize.forEach((size, index) => { 241 | if (size.blockSize < 0) { 242 | throw new Error( 243 | `borderBoxSize[${index}].blockSize must not be negative.` 244 | ); 245 | } 246 | 247 | if (size.inlineSize < 0) { 248 | throw new Error( 249 | `borderBoxSize[${index}].inlineSize must not be negative.` 250 | ); 251 | } 252 | }); 253 | 254 | const contentRect = new DOMRect( 255 | 0, 256 | 0, 257 | contentBoxSize.reduce((acc, size) => acc + size.inlineSize, 0), 258 | contentBoxSize.reduce((acc, size) => acc + size.blockSize, 0) 259 | ); 260 | 261 | state.elementSizes.set(element, { 262 | contentBoxSize, 263 | borderBoxSize, 264 | contentRect, 265 | }); 266 | }, 267 | resize: ( 268 | elements: HTMLElement | HTMLElement[] = [], 269 | { ignoreImplicit = false } = {} 270 | ) => { 271 | config.act(() => { 272 | if (!Array.isArray(elements)) { 273 | elements = [elements]; 274 | } 275 | 276 | for (const observer of state.observers) { 277 | const observedSubset = elements.filter((element) => 278 | observer.observationTargets.has(element) 279 | ); 280 | 281 | const observedSubsetAndActive = new Set([ 282 | ...observedSubset, 283 | ...(ignoreImplicit ? [] : observer.activeTargets), 284 | ]); 285 | 286 | observer.activeTargets.clear(); 287 | 288 | const entries = Array.from(observedSubsetAndActive) 289 | .map(elementToEntry) 290 | .filter(Boolean) as ResizeObserverEntry[]; 291 | 292 | if (entries.length > 0) { 293 | observer.callback(entries, observer); 294 | } 295 | } 296 | }); 297 | }, 298 | }; 299 | } 300 | 301 | export { mockResizeObserver }; 302 | -------------------------------------------------------------------------------- /src/mocks/size/DOMRect.env.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import { WrongEnvironmentError } from '../../helper'; 6 | import { mockDOMRect } from './DOMRect'; 7 | 8 | describe('mockDOMRect', () => { 9 | it('throws an error when used in a non jsdom environment', () => { 10 | expect(() => { 11 | mockDOMRect(); 12 | }).toThrowError(WrongEnvironmentError); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/mocks/size/DOMRect.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { mockDOMRect } from './DOMRect'; 3 | 4 | mockDOMRect(); 5 | 6 | describe('DOMRectReadOnly', () => { 7 | test('constructor and props', () => { 8 | const domRect = new DOMRectReadOnly(100, 100, 200, 200); 9 | 10 | expect(domRect.x).toBe(100); 11 | expect(domRect.y).toBe(100); 12 | expect(domRect.width).toBe(200); 13 | expect(domRect.height).toBe(200); 14 | expect(domRect.top).toBe(100); 15 | expect(domRect.right).toBe(300); 16 | expect(domRect.bottom).toBe(300); 17 | expect(domRect.left).toBe(100); 18 | }); 19 | 20 | test('there should be no enumerable props', () => { 21 | const domRect = new DOMRectReadOnly(100, 100, 200, 200); 22 | 23 | expect(Object.keys(domRect)).toEqual([]); 24 | }); 25 | 26 | it('should be impossible to set x, y, width, height', () => { 27 | const domRect = new DOMRectReadOnly(100, 100, 200, 200); 28 | 29 | // @ts-ignore 30 | domRect.x = 150; 31 | // @ts-ignore 32 | domRect.y = 250; 33 | // @ts-ignore 34 | domRect.width = 350; 35 | // @ts-ignore 36 | domRect.height = 450; 37 | 38 | expect(domRect.x).toBe(100); 39 | expect(domRect.y).toBe(100); 40 | expect(domRect.width).toBe(200); 41 | expect(domRect.height).toBe(200); 42 | }); 43 | 44 | it('should be impossible to set top, right, bottom, left', () => { 45 | const domRect = new DOMRectReadOnly(100, 100, 200, 200); 46 | 47 | // @ts-ignore 48 | domRect.top = 150; 49 | // @ts-ignore 50 | domRect.right = 250; 51 | // @ts-ignore 52 | domRect.bottom = 350; 53 | // @ts-ignore 54 | domRect.left = 450; 55 | 56 | expect(domRect.top).toBe(100); 57 | expect(domRect.right).toBe(300); 58 | expect(domRect.bottom).toBe(300); 59 | expect(domRect.left).toBe(100); 60 | }); 61 | 62 | test('toJSON', () => { 63 | const domRect = new DOMRectReadOnly(100, 100, 200, 200); 64 | 65 | expect(domRect.toJSON()).toEqual({ 66 | x: 100, 67 | y: 100, 68 | width: 200, 69 | height: 200, 70 | top: 100, 71 | right: 300, 72 | bottom: 300, 73 | left: 100, 74 | }); 75 | }); 76 | 77 | test('toString', () => { 78 | const domRect = new DOMRectReadOnly(100, 100, 200, 200); 79 | 80 | expect(domRect.toString()).toBe('[object DOMRectReadOnly]'); 81 | }); 82 | }); 83 | 84 | describe('DOMRect', () => { 85 | test('constructor and props', () => { 86 | const domRect = new DOMRect(100, 100, 200, 200); 87 | 88 | expect(domRect.x).toBe(100); 89 | expect(domRect.y).toBe(100); 90 | expect(domRect.width).toBe(200); 91 | expect(domRect.height).toBe(200); 92 | expect(domRect.top).toBe(100); 93 | expect(domRect.right).toBe(300); 94 | expect(domRect.bottom).toBe(300); 95 | expect(domRect.left).toBe(100); 96 | }); 97 | 98 | test('there should be no enumerable props', () => { 99 | const domRect = new DOMRect(100, 100, 200, 200); 100 | 101 | expect(Object.keys(domRect)).toEqual([]); 102 | }); 103 | 104 | it('should be possible to set x, y, width, height', () => { 105 | const domRect = new DOMRect(100, 100, 200, 200); 106 | 107 | domRect.x = 200; 108 | domRect.y = 200; 109 | domRect.width = 300; 110 | domRect.height = 300; 111 | 112 | expect(domRect.x).toBe(200); 113 | expect(domRect.y).toBe(200); 114 | expect(domRect.width).toBe(300); 115 | expect(domRect.height).toBe(300); 116 | expect(domRect.top).toBe(200); 117 | expect(domRect.right).toBe(500); 118 | expect(domRect.bottom).toBe(500); 119 | expect(domRect.left).toBe(200); 120 | }); 121 | 122 | it('should be impossible to set the top, right, bottom, left props', () => { 123 | const domRect = new DOMRect(100, 100, 200, 200); 124 | 125 | // @ts-ignore 126 | domRect.top = 150; 127 | // @ts-ignore 128 | domRect.right = 250; 129 | // @ts-ignore 130 | domRect.bottom = 350; 131 | // @ts-ignore 132 | domRect.left = 450; 133 | 134 | expect(domRect.top).toBe(100); 135 | expect(domRect.right).toBe(300); 136 | expect(domRect.bottom).toBe(300); 137 | expect(domRect.left).toBe(100); 138 | }); 139 | 140 | test('toString', () => { 141 | const domRect = new DOMRect(100, 100, 200, 200); 142 | 143 | expect(domRect.toString()).toBe('[object DOMRect]'); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /src/mocks/size/DOMRect.ts: -------------------------------------------------------------------------------- 1 | import { isJsdomEnv, WrongEnvironmentError } from '../../helper'; 2 | 3 | const protectedProps = ['_x', '_y', '_width', '_height']; 4 | 5 | class MockedDOMRectReadOnly implements DOMRectReadOnly { 6 | _x: number; 7 | _y: number; 8 | _width: number; 9 | _height: number; 10 | 11 | constructor(x = 0, y = 0, width = 0, height = 0) { 12 | this._x = x; 13 | this._y = y; 14 | this._width = width; 15 | this._height = height; 16 | 17 | // make protected props non-enumerable 18 | protectedProps.forEach((prop) => { 19 | const descriptor = Object.getOwnPropertyDescriptor(this, prop); 20 | 21 | if (descriptor) { 22 | Object.defineProperty(this, prop, { 23 | ...descriptor, 24 | enumerable: false, 25 | }); 26 | } 27 | }); 28 | } 29 | 30 | get x() { 31 | return this._x; 32 | } 33 | 34 | set x(_value) { 35 | // repeat native behavior 36 | } 37 | 38 | get y() { 39 | return this._y; 40 | } 41 | 42 | set y(_value) { 43 | // repeat native behavior 44 | } 45 | 46 | get width() { 47 | return this._width; 48 | } 49 | 50 | set width(_value) { 51 | // repeat native behavior 52 | } 53 | 54 | get height() { 55 | return this._height; 56 | } 57 | 58 | set height(_value) { 59 | // repeat native behavior 60 | } 61 | 62 | get left() { 63 | return this._x; 64 | } 65 | 66 | set left(_value) { 67 | // repeat native behavior 68 | } 69 | 70 | get right() { 71 | return this._x + Math.max(0, this._width); 72 | } 73 | 74 | set right(_value) { 75 | // repeat native behavior 76 | } 77 | 78 | get top() { 79 | return this._y; 80 | } 81 | 82 | set top(_value) { 83 | // repeat native behavior 84 | } 85 | 86 | get bottom() { 87 | return this._y + Math.max(0, this._height); 88 | } 89 | 90 | set bottom(_value) { 91 | // repeat native behavior 92 | } 93 | 94 | toJSON() { 95 | return { 96 | bottom: this.bottom, 97 | height: this.height, 98 | left: this.left, 99 | right: this.right, 100 | top: this.top, 101 | width: this.width, 102 | x: this.x, 103 | y: this.y, 104 | }; 105 | } 106 | 107 | toString() { 108 | return '[object DOMRectReadOnly]'; 109 | } 110 | } 111 | 112 | class MockedDOMRect extends MockedDOMRectReadOnly implements DOMRect { 113 | constructor(x = 0, y = 0, width = 0, height = 0) { 114 | super(x, y, width, height); 115 | } 116 | 117 | get x() { 118 | return super.x; 119 | } 120 | 121 | set x(_value: number) { 122 | this._x = _value; 123 | } 124 | 125 | get y() { 126 | return super.y; 127 | } 128 | 129 | set y(_value: number) { 130 | this._y = _value; 131 | } 132 | 133 | get width() { 134 | return super.width; 135 | } 136 | 137 | set width(_value: number) { 138 | this._width = _value; 139 | } 140 | 141 | get height() { 142 | return super.height; 143 | } 144 | 145 | set height(_value: number) { 146 | this._height = _value; 147 | } 148 | 149 | toString() { 150 | return '[object DOMRect]'; 151 | } 152 | } 153 | 154 | function mockDOMRect() { 155 | if (!isJsdomEnv()) { 156 | throw new WrongEnvironmentError(); 157 | } 158 | 159 | if (typeof DOMRectReadOnly === 'undefined') { 160 | Object.defineProperty(window, 'DOMRectReadOnly', { 161 | writable: true, 162 | configurable: true, 163 | value: MockedDOMRectReadOnly, 164 | }); 165 | } 166 | 167 | if (typeof DOMRect === 'undefined') { 168 | Object.defineProperty(window, 'DOMRect', { 169 | writable: true, 170 | configurable: true, 171 | value: MockedDOMRect, 172 | }); 173 | } 174 | } 175 | 176 | export { MockedDOMRectReadOnly, MockedDOMRect, mockDOMRect }; 177 | -------------------------------------------------------------------------------- /src/mocks/size/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DOMRect'; 2 | export * from './size'; 3 | -------------------------------------------------------------------------------- /src/mocks/size/size.test.ts: -------------------------------------------------------------------------------- 1 | import { mockElementBoundingClientRect } from './size'; 2 | 3 | test('mockElementBoundingClientRect', () => { 4 | const element = document.createElement('div'); 5 | 6 | mockElementBoundingClientRect(element, { 7 | x: 100, 8 | y: 100, 9 | width: 200, 10 | height: 200, 11 | }); 12 | 13 | const rect = element.getBoundingClientRect(); 14 | 15 | expect(rect.x).toBe(100); 16 | expect(rect.y).toBe(100); 17 | expect(rect.width).toBe(200); 18 | expect(rect.height).toBe(200); 19 | expect(rect.top).toBe(100); 20 | expect(rect.right).toBe(300); 21 | expect(rect.bottom).toBe(300); 22 | expect(rect.left).toBe(100); 23 | }); 24 | -------------------------------------------------------------------------------- /src/mocks/size/size.ts: -------------------------------------------------------------------------------- 1 | import { mockDOMRect } from './DOMRect'; 2 | 3 | export const mockElementBoundingClientRect = ( 4 | element: HTMLElement, 5 | { 6 | x = 0, 7 | y = 0, 8 | width = 0, 9 | height = 0, 10 | }: Partial> 11 | ) => { 12 | mockDOMRect(); 13 | 14 | const savedImplementation = element.getBoundingClientRect; 15 | 16 | element.getBoundingClientRect = () => 17 | new DOMRectReadOnly(x, y, width, height); 18 | 19 | return savedImplementation; 20 | }; 21 | -------------------------------------------------------------------------------- /src/mocks/testTools.ts: -------------------------------------------------------------------------------- 1 | async function playAnimation(animation: Animation) { 2 | animation.play(); 3 | runner.advanceTimersByTime(0); 4 | await expect(animation.ready); 5 | } 6 | 7 | async function playAnimationInReverse(animation: Animation) { 8 | animation.reverse(); 9 | runner.advanceTimersByTime(0); 10 | await expect(animation.ready); 11 | } 12 | 13 | async function updateAnimationPlaybackRate(animation: Animation, rate: number) { 14 | animation.updatePlaybackRate(rate); 15 | runner.advanceTimersByTime(0); 16 | await expect(animation.ready); 17 | } 18 | 19 | // https://github.com/sinonjs/fake-timers/blob/3a77a0978eaccd73ccc87dd42204b54e2bac0f6f/src/fake-timers-src.js#L1066 20 | const FRAME_DURATION = 16; 21 | 22 | function framesToTime(frames: number) { 23 | return frames * FRAME_DURATION; 24 | } 25 | 26 | function wait(time: number) { 27 | return new Promise((resolve) => { 28 | setTimeout(resolve, time); 29 | }); 30 | } 31 | 32 | export { 33 | wait, 34 | playAnimation, 35 | playAnimationInReverse, 36 | updateAnimationPlaybackRate, 37 | FRAME_DURATION, 38 | framesToTime, 39 | }; 40 | -------------------------------------------------------------------------------- /src/mocks/viewport.env.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import { WrongEnvironmentError } from '../helper'; 6 | import { mockViewport } from './viewport'; 7 | 8 | describe('mockViewport', () => { 9 | it('throws an error when used in a non jsdom environment', () => { 10 | expect(() => { 11 | mockViewport({ width: 0, height: 0 }); 12 | }).toThrowError(WrongEnvironmentError); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/mocks/viewport.ts: -------------------------------------------------------------------------------- 1 | import mediaQuery, { MediaValues } from 'css-mediaquery'; 2 | import { mockMediaQueryListEvent } from './MediaQueryListEvent'; 3 | import { getConfig } from '../tools'; 4 | import { isJsdomEnv, WrongEnvironmentError } from '../helper'; 5 | 6 | const config = getConfig(); 7 | 8 | /** 9 | * A tool that allows testing components that use js media queries (matchMedia) 10 | * `mockViewport` must be called before rendering the component 11 | * @example using react testing library 12 | * 13 | * const viewport = mockViewport({ width: '320px', height: '568px' }) 14 | * 15 | * const { getByText, queryByText } = render() 16 | * 17 | * expect(getByText('Content visible only in the phone')).toBeInTheDocument() 18 | * expect(queryByText('Content visible only on desktop')).not.toBeInTheDocument() 19 | * 20 | * act(() => { 21 | * viewport.set({ width: '1440px', height: '900px' }) 22 | * }) 23 | * 24 | * expect(queryByText('Content visible only on the phone')).not.toBeInTheDocument() 25 | * expect(getByText('Content visible only on desktop')).toBeInTheDocument() 26 | * 27 | * viewport.cleanup() 28 | * 29 | */ 30 | 31 | export type ViewportDescription = Partial; 32 | export type MockViewport = { 33 | cleanup: () => void; 34 | set: (newDesc: ViewportDescription) => void; 35 | }; 36 | 37 | type Listener = (this: MediaQueryList, ev: MediaQueryListEvent) => void; 38 | type ListenerObject = { 39 | handleEvent: (ev: MediaQueryListEvent) => void; 40 | }; 41 | type ListenerOrListenerObject = Listener | ListenerObject; 42 | 43 | function isEventListenerObject( 44 | obj: ListenerOrListenerObject 45 | ): obj is ListenerObject { 46 | return (obj as ListenerObject).handleEvent !== undefined; 47 | } 48 | 49 | function mockViewport(desc: ViewportDescription): MockViewport { 50 | if (!isJsdomEnv()) { 51 | throw new WrongEnvironmentError(); 52 | } 53 | 54 | mockMediaQueryListEvent(); 55 | 56 | const state: { 57 | currentDesc: ViewportDescription; 58 | oldListeners: { 59 | listener: Listener; 60 | list: MediaQueryList; 61 | matches: boolean; 62 | }[]; 63 | listeners: { 64 | listener: ListenerOrListenerObject; 65 | list: MediaQueryList; 66 | matches: boolean; 67 | }[]; 68 | } = { 69 | currentDesc: desc, 70 | oldListeners: [], 71 | listeners: [], 72 | }; 73 | 74 | const savedImplementation = window.matchMedia; 75 | 76 | const addOldListener = ( 77 | list: MediaQueryList, 78 | matches: boolean, 79 | listener: Listener 80 | ) => { 81 | state.oldListeners.push({ listener, matches, list }); 82 | }; 83 | 84 | const removeOldListener = (listenerToRemove: Listener) => { 85 | const index = state.oldListeners.findIndex( 86 | ({ listener }) => listener === listenerToRemove 87 | ); 88 | 89 | state.oldListeners.splice(index, 1); 90 | }; 91 | 92 | const addListener = ( 93 | list: MediaQueryList, 94 | matches: boolean, 95 | listener: ListenerOrListenerObject 96 | ) => { 97 | state.listeners.push({ listener, matches, list }); 98 | }; 99 | 100 | const removeListener = (listenerToRemove: ListenerOrListenerObject) => { 101 | const index = state.listeners.findIndex( 102 | ({ listener }) => listener === listenerToRemove 103 | ); 104 | 105 | state.listeners.splice(index, 1); 106 | }; 107 | 108 | Object.defineProperty(window, 'matchMedia', { 109 | writable: true, 110 | value: (query: string): MediaQueryList => ({ 111 | get matches() { 112 | return mediaQuery.match(query, state.currentDesc); 113 | }, 114 | media: query, 115 | onchange: null, 116 | addListener: function (listener) { 117 | if (listener) { 118 | addOldListener(this, this.matches, listener); 119 | } 120 | }, // deprecated 121 | removeListener: (listener) => { 122 | if (listener) { 123 | removeOldListener(listener); 124 | } 125 | }, // deprecated 126 | addEventListener: function ( 127 | eventType: Parameters[0], 128 | listener: Parameters[1] 129 | ) { 130 | if (eventType === 'change') { 131 | addListener(this, this.matches, listener); 132 | } 133 | }, 134 | removeEventListener: ( 135 | eventType: Parameters[0], 136 | listener: Parameters[1] 137 | ) => { 138 | if (eventType === 'change') { 139 | if (isEventListenerObject(listener)) { 140 | removeListener(listener.handleEvent); 141 | } else { 142 | removeListener(listener); 143 | } 144 | } 145 | }, 146 | dispatchEvent: (event: Event) => { 147 | if (event.type === 'change') { 148 | state.listeners.forEach(({ listener, list }) => { 149 | if (isEventListenerObject(listener)) { 150 | listener.handleEvent(event as MediaQueryListEvent); 151 | } else { 152 | listener.call(list, event as MediaQueryListEvent); 153 | } 154 | }); 155 | 156 | state.oldListeners.forEach(({ listener, list }) => { 157 | listener.call(list, event as MediaQueryListEvent); 158 | }); 159 | } 160 | 161 | return true; 162 | }, 163 | }), 164 | }); 165 | 166 | return { 167 | cleanup: () => { 168 | window.matchMedia = savedImplementation; 169 | }, 170 | set: (newDesc: ViewportDescription) => { 171 | config.act(() => { 172 | state.currentDesc = newDesc; 173 | state.listeners.forEach( 174 | ({ listener, matches, list }, listenerIndex) => { 175 | const newMatches = list.matches; 176 | 177 | if (newMatches !== matches) { 178 | const changeEvent = new MediaQueryListEvent('change', { 179 | matches: newMatches, 180 | media: list.media, 181 | }); 182 | 183 | if (isEventListenerObject(listener)) { 184 | listener.handleEvent(changeEvent); 185 | } else { 186 | listener.call(list, changeEvent); 187 | } 188 | 189 | state.listeners[listenerIndex].matches = newMatches; 190 | } 191 | } 192 | ); 193 | 194 | state.oldListeners.forEach( 195 | ({ listener, matches, list }, listenerIndex) => { 196 | const newMatches = list.matches; 197 | 198 | if (newMatches !== matches) { 199 | const changeEvent = new MediaQueryListEvent('change', { 200 | matches: newMatches, 201 | media: list.media, 202 | }); 203 | 204 | listener.call(list, changeEvent); 205 | 206 | state.oldListeners[listenerIndex].matches = newMatches; 207 | } 208 | } 209 | ); 210 | }); 211 | }, 212 | }; 213 | } 214 | 215 | function mockViewportForTestGroup(desc: ViewportDescription) { 216 | let viewport: MockViewport; 217 | 218 | config.beforeAll(() => { 219 | viewport = mockViewport(desc); 220 | }); 221 | 222 | config.afterAll(() => { 223 | viewport.cleanup(); 224 | }); 225 | } 226 | 227 | export { mockViewport, mockViewportForTestGroup }; 228 | -------------------------------------------------------------------------------- /src/mocks/web-animations-api/AnimationEffect.ts: -------------------------------------------------------------------------------- 1 | class MockedAnimationEffect implements AnimationEffect { 2 | #timing: EffectTiming = { 3 | delay: 0, 4 | direction: 'normal', 5 | duration: 'auto', 6 | easing: 'linear', 7 | endDelay: 0, 8 | fill: 'auto', 9 | iterationStart: 0, 10 | iterations: 1, 11 | }; 12 | 13 | constructor() { 14 | if (this.constructor === MockedAnimationEffect) { 15 | throw new TypeError('Illegal constructor'); 16 | } 17 | } 18 | 19 | #getNormalizedDuration(): number { 20 | // the only possible value is "auto" 21 | if (typeof this.#timing.duration === 'string') { 22 | return 0; 23 | } 24 | 25 | return this.#timing.duration ?? 0; 26 | } 27 | 28 | getTiming() { 29 | return this.#timing; 30 | } 31 | 32 | getComputedTiming(): ComputedEffectTiming { 33 | // duration of the animation 34 | const duration = this.#getNormalizedDuration(); 35 | 36 | // Calculated as (iteration_duration * iteration_count) 37 | const activeDuration = 38 | this.#timing.iterations === Infinity 39 | ? Infinity 40 | : duration * (this.#timing.iterations ?? 1); 41 | 42 | // The end time of an animation effect is the result of evaluating max(start delay + active duration + end delay, 0). 43 | const endTime = 44 | this.#timing.iterations === Infinity 45 | ? Infinity 46 | : Math.max( 47 | (this.#timing.delay ?? 0) + 48 | activeDuration + 49 | (this.#timing.endDelay ?? 0), 50 | 0 51 | ); 52 | 53 | // must be linked to the animation 54 | const currentIteration = null; 55 | 56 | return { 57 | ...this.#timing, 58 | duration, 59 | fill: this.#timing.fill === 'auto' ? 'none' : this.#timing.fill, 60 | activeDuration, 61 | currentIteration: 62 | this.#timing.iterations === Infinity ? null : currentIteration, 63 | endTime, 64 | localTime: null, 65 | progress: null, 66 | }; 67 | } 68 | 69 | updateTiming(timing?: OptionalEffectTiming | undefined): void { 70 | Object.assign(this.#timing, timing); 71 | } 72 | } 73 | 74 | function mockAnimationEffect() { 75 | if (typeof AnimationEffect === 'undefined') { 76 | Object.defineProperty(window, 'AnimationEffect', { 77 | writable: true, 78 | configurable: true, 79 | value: MockedAnimationEffect, 80 | }); 81 | } 82 | } 83 | 84 | export { MockedAnimationEffect, mockAnimationEffect }; 85 | -------------------------------------------------------------------------------- /src/mocks/web-animations-api/AnimationPlaybackEvent.ts: -------------------------------------------------------------------------------- 1 | class MockedAnimationPlaybackEvent 2 | extends Event 3 | implements AnimationPlaybackEvent 4 | { 5 | readonly currentTime = null; 6 | readonly timelineTime = null; 7 | } 8 | 9 | function mockAnimationPlaybackEvent() { 10 | if (typeof AnimationPlaybackEvent === 'undefined') { 11 | Object.defineProperty(window, 'AnimationPlaybackEvent', { 12 | writable: true, 13 | configurable: true, 14 | value: MockedAnimationPlaybackEvent, 15 | }); 16 | } 17 | } 18 | 19 | export { mockAnimationPlaybackEvent }; 20 | -------------------------------------------------------------------------------- /src/mocks/web-animations-api/AnimationTimeline.ts: -------------------------------------------------------------------------------- 1 | class MockedAnimationTimeline implements AnimationTimeline { 2 | constructor() { 3 | if (this.constructor === MockedAnimationTimeline) { 4 | throw new TypeError('Illegal constructor'); 5 | } 6 | } 7 | 8 | get currentTime() { 9 | return performance.now(); 10 | } 11 | } 12 | 13 | function mockAnimationTimeline() { 14 | if (typeof AnimationTimeline === 'undefined') { 15 | Object.defineProperty(window, 'AnimationTimeline', { 16 | writable: true, 17 | configurable: true, 18 | value: MockedAnimationTimeline, 19 | }); 20 | } 21 | } 22 | 23 | export { MockedAnimationTimeline, mockAnimationTimeline }; 24 | -------------------------------------------------------------------------------- /src/mocks/web-animations-api/DocumentTimeline.ts: -------------------------------------------------------------------------------- 1 | import { 2 | mockAnimationTimeline, 3 | MockedAnimationTimeline, 4 | } from './AnimationTimeline'; 5 | 6 | class MockedDocumentTimeline 7 | extends MockedAnimationTimeline 8 | implements DocumentTimeline 9 | { 10 | #originTime = 0; 11 | 12 | constructor(options?: DocumentTimelineOptions) { 13 | super(); 14 | 15 | this.#originTime = options?.originTime ?? 0; 16 | } 17 | 18 | get currentTime() { 19 | return performance.now() - this.#originTime; 20 | } 21 | } 22 | 23 | function mockDocumentTimeline() { 24 | mockAnimationTimeline(); 25 | 26 | if (typeof DocumentTimeline === 'undefined') { 27 | Object.defineProperty(window, 'DocumentTimeline', { 28 | writable: true, 29 | configurable: true, 30 | value: MockedDocumentTimeline, 31 | }); 32 | 33 | Object.defineProperty(Document.prototype, 'timeline', { 34 | writable: true, 35 | configurable: true, 36 | value: new MockedDocumentTimeline(), 37 | }); 38 | } 39 | } 40 | 41 | export { mockDocumentTimeline }; 42 | -------------------------------------------------------------------------------- /src/mocks/web-animations-api/KeyframeEffect.ts: -------------------------------------------------------------------------------- 1 | import { mockAnimationEffect, MockedAnimationEffect } from './AnimationEffect'; 2 | 3 | /** 4 | Given the structure of PropertyIndexedKeyframes as such: 5 | { 6 | opacity: [ 0, 0.9, 1 ], 7 | transform: [ "translateX(0)", "translateX(50px)", "translateX(100px)" ], 8 | offset: [ 0, 0.8 ], 9 | easing: [ 'ease-in', 'ease-out' ], 10 | } 11 | convert it to the structure of Keyframe[] as such: 12 | [ 13 | { opacity: 0, transform: "translateX(0)", offset: 0, easing: 'ease-in' }, 14 | { opacity: 0.9, transform: "translateX(50px)", offset: 0.8, easing: 'ease-out' }, 15 | { opacity: 1, transform: "translateX(100px)" }, 16 | ] 17 | */ 18 | export function convertPropertyIndexedKeyframes( 19 | piKeyframes: PropertyIndexedKeyframes 20 | ): Keyframe[] { 21 | const keyframes: Keyframe[] = []; 22 | let done = false; 23 | let keyframeIndex = 0; 24 | 25 | while (!done) { 26 | let keyframe: Keyframe | undefined; 27 | 28 | for (const property in piKeyframes) { 29 | const values = piKeyframes[property]; 30 | const propertyArray = Array.isArray(values) ? values : [values]; 31 | 32 | if (!propertyArray) { 33 | continue; 34 | } 35 | 36 | const piKeyframe = propertyArray[keyframeIndex]; 37 | 38 | if (typeof piKeyframe === 'undefined' || piKeyframe === null) { 39 | continue; 40 | } 41 | 42 | if (!keyframe) { 43 | keyframe = {}; 44 | } 45 | 46 | keyframe[property] = piKeyframe; 47 | } 48 | 49 | if (keyframe) { 50 | keyframeIndex++; 51 | keyframes.push(keyframe); 52 | continue; 53 | } 54 | 55 | done = true; 56 | } 57 | 58 | return keyframes; 59 | } 60 | 61 | class MockedKeyframeEffect 62 | extends MockedAnimationEffect 63 | implements KeyframeEffect 64 | { 65 | composite: CompositeOperation = 'replace'; 66 | iterationComposite: IterationCompositeOperation; 67 | pseudoElement: string | null = null; 68 | target: Element | null; 69 | #keyframes: Keyframe[] = []; 70 | 71 | constructor( 72 | target: Element, 73 | keyframes: Keyframe[] | PropertyIndexedKeyframes | null, 74 | options: number | KeyframeEffectOptions = {} 75 | ) { 76 | super(); 77 | 78 | if (typeof options === 'number') { 79 | options = { duration: options }; 80 | } 81 | 82 | const { composite, iterationComposite, pseudoElement, ...timing } = options; 83 | 84 | this.setKeyframes(keyframes); 85 | this.target = target; 86 | this.composite = composite || 'replace'; 87 | // not actually implemented, just to make ts happy 88 | this.iterationComposite = iterationComposite || 'replace'; 89 | this.pseudoElement = pseudoElement || null; 90 | this.updateTiming(timing); 91 | } 92 | 93 | #validateKeyframes(keyframes: Keyframe[]) { 94 | let lastExplicitOffset: number | undefined; 95 | 96 | keyframes.forEach((keyframe) => { 97 | const offset = keyframe.offset; 98 | 99 | if (typeof offset === 'number') { 100 | if (offset < 0 || offset > 1) { 101 | throw new TypeError( 102 | "Failed to construct 'KeyframeEffect': Offsets must be null or in the range [0,1]." 103 | ); 104 | } 105 | 106 | if (typeof lastExplicitOffset === 'number') { 107 | if (offset < lastExplicitOffset) { 108 | throw new TypeError( 109 | "Failed to construct 'KeyframeEffect': Offsets must be monotonically non-decreasing." 110 | ); 111 | } 112 | } 113 | 114 | lastExplicitOffset = offset; 115 | } 116 | }); 117 | } 118 | 119 | getKeyframes(): ComputedKeyframe[] { 120 | const totalKeyframes = this.#keyframes.length; 121 | 122 | if (totalKeyframes === 0) { 123 | return []; 124 | } 125 | 126 | let currentOffset = 127 | this.#keyframes[0]?.offset ?? (totalKeyframes === 1 ? 1 : 0); 128 | 129 | return this.#keyframes.map( 130 | ({ composite, offset, easing, ...keyframe }, index) => { 131 | const computedKeyframe = { 132 | offset: offset ?? null, 133 | composite: composite ?? this.composite, 134 | easing: easing ?? 'linear', 135 | computedOffset: currentOffset, 136 | ...keyframe, 137 | }; 138 | 139 | // calculate the next offset 140 | // (implements KeyframeEffect.spacing) 141 | let nextOffset: number | undefined; 142 | let keyframesUntilNextOffset: number | undefined; 143 | 144 | for (let i = index + 1; i < totalKeyframes; i++) { 145 | const offset = this.#keyframes[i].offset; 146 | 147 | if (typeof offset === 'number') { 148 | nextOffset = offset; 149 | keyframesUntilNextOffset = i - index; 150 | break; 151 | } 152 | } 153 | 154 | if (nextOffset === undefined) { 155 | nextOffset = 1; 156 | keyframesUntilNextOffset = this.#keyframes.length - index - 1; 157 | } 158 | 159 | const offsetDiff = 160 | typeof keyframesUntilNextOffset === 'number' && 161 | keyframesUntilNextOffset > 0 162 | ? (nextOffset - currentOffset) / keyframesUntilNextOffset 163 | : 0; 164 | 165 | currentOffset = currentOffset + offsetDiff; 166 | 167 | return computedKeyframe; 168 | } 169 | ); 170 | } 171 | 172 | setKeyframes(keyframes: Keyframe[] | PropertyIndexedKeyframes | null) { 173 | let kf: Keyframe[]; 174 | 175 | if (keyframes === null) { 176 | kf = []; 177 | } else if (Array.isArray(keyframes)) { 178 | kf = keyframes; 179 | } else { 180 | kf = convertPropertyIndexedKeyframes(keyframes); 181 | } 182 | 183 | this.#validateKeyframes(kf); 184 | 185 | this.#keyframes = kf; 186 | } 187 | } 188 | 189 | function mockKeyframeEffect() { 190 | mockAnimationEffect(); 191 | 192 | if (typeof KeyframeEffect === 'undefined') { 193 | Object.defineProperty(window, 'KeyframeEffect', { 194 | writable: true, 195 | configurable: true, 196 | value: MockedKeyframeEffect, 197 | }); 198 | } 199 | } 200 | 201 | export { MockedKeyframeEffect, mockKeyframeEffect }; 202 | -------------------------------------------------------------------------------- /src/mocks/web-animations-api/__tests__/AnimationEffect.test.ts: -------------------------------------------------------------------------------- 1 | import { mockAnimationEffect } from '../AnimationEffect'; 2 | 3 | mockAnimationEffect(); 4 | 5 | describe('AnimationEffect', () => { 6 | it('should be defined', () => { 7 | expect(AnimationEffect).toBeDefined(); 8 | }); 9 | 10 | it('should throw "TypeError: Illegal constructor" if instantiated directly', () => { 11 | expect(() => { 12 | new AnimationEffect(); 13 | }).toThrow(TypeError); 14 | expect(() => { 15 | new AnimationEffect(); 16 | }).toThrow('Illegal constructor'); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/mocks/web-animations-api/__tests__/AnimationTimeline.test.ts: -------------------------------------------------------------------------------- 1 | import { mockAnimationTimeline } from '../AnimationTimeline'; 2 | 3 | mockAnimationTimeline(); 4 | 5 | describe('AnimationTimeline', () => { 6 | it('should be defined', () => { 7 | expect(AnimationTimeline).toBeDefined(); 8 | }); 9 | 10 | it('should throw "TypeError: Illegal constructor" if instantiated directly', () => { 11 | expect(() => { 12 | new AnimationTimeline(); 13 | }).toThrow(TypeError); 14 | expect(() => { 15 | new AnimationTimeline(); 16 | }).toThrow('Illegal constructor'); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/mocks/web-animations-api/__tests__/DocumentTimeline.test.ts: -------------------------------------------------------------------------------- 1 | import { mockDocumentTimeline } from '../DocumentTimeline'; 2 | 3 | mockDocumentTimeline(); 4 | runner.useFakeTimers(); 5 | 6 | describe('DocumentTimeline', () => { 7 | it('should be defined', () => { 8 | expect(DocumentTimeline).toBeDefined(); 9 | }); 10 | 11 | it('should add a default timeline to the document', () => { 12 | expect(document.timeline).toBeInstanceOf(DocumentTimeline); 13 | 14 | // check that currentTime is non negative number 15 | expect(document.timeline.currentTime).toBeGreaterThanOrEqual(0); 16 | }); 17 | 18 | it('should set default origin time to 0', () => { 19 | const timeline = new DocumentTimeline(); 20 | 21 | expect((timeline.currentTime ?? 0) / 10).toBeCloseTo( 22 | (document.timeline.currentTime ?? 0) / 10, 23 | 1 24 | ); 25 | }); 26 | 27 | it('should set origin time to the given value', () => { 28 | const timeline = new DocumentTimeline({ originTime: 100 }); 29 | 30 | expect(timeline.currentTime ?? 0).toBeCloseTo( 31 | (document.timeline.currentTime ?? 0) - 100, 32 | 0 33 | ); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/mocks/web-animations-api/__tests__/KeyframeEffect.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MockedKeyframeEffect, 3 | convertPropertyIndexedKeyframes, 4 | mockKeyframeEffect, 5 | } from '../KeyframeEffect'; 6 | 7 | mockKeyframeEffect(); 8 | 9 | describe('KeyframeEffect', () => { 10 | it('should have correct properties by default', () => { 11 | const element = document.createElement('div'); 12 | const ke = new KeyframeEffect(element, []); 13 | 14 | expect(ke).toBeInstanceOf(MockedKeyframeEffect); 15 | expect(ke.composite).toBe('replace'); 16 | expect(ke.pseudoElement).toBeNull(); 17 | expect(ke.target).toBe(element); 18 | expect(ke.getKeyframes()).toEqual([]); 19 | expect(ke.getTiming()).toEqual({ 20 | delay: 0, 21 | direction: 'normal', 22 | duration: 'auto', 23 | easing: 'linear', 24 | endDelay: 0, 25 | fill: 'auto', 26 | iterationStart: 0, 27 | iterations: 1, 28 | }); 29 | expect(ke.getComputedTiming()).toEqual({ 30 | delay: 0, 31 | direction: 'normal', 32 | duration: 0, 33 | easing: 'linear', 34 | endDelay: 0, 35 | fill: 'none', 36 | iterationStart: 0, 37 | iterations: 1, 38 | activeDuration: 0, 39 | currentIteration: null, 40 | endTime: 0, 41 | localTime: null, 42 | progress: null, 43 | }); 44 | }); 45 | 46 | it('should calculate timing correctly', () => { 47 | const element = document.createElement('div'); 48 | const ke = new KeyframeEffect(element, [], { 49 | delay: 100, 50 | duration: 3000, 51 | endDelay: 300, 52 | fill: 'forwards', 53 | iterations: 2, 54 | }); 55 | 56 | expect(ke.getComputedTiming()).toEqual({ 57 | activeDuration: 6000, 58 | currentIteration: null, 59 | delay: 100, 60 | direction: 'normal', 61 | duration: 3000, 62 | easing: 'linear', 63 | endDelay: 300, 64 | endTime: 6400, 65 | fill: 'forwards', 66 | iterationStart: 0, 67 | iterations: 2, 68 | localTime: null, 69 | progress: null, 70 | }); 71 | }); 72 | 73 | describe('converting keyframes from object to array', () => { 74 | it('should convert keyframes from object to array', () => { 75 | const testObjectKeyframes = { 76 | opacity: [0, 0.9, 1], 77 | transform: ['translateX(0)', 'translateX(50px)', 'translateX(100px)'], 78 | offset: [0, 0.8], 79 | easing: ['ease-in', 'ease-out'], 80 | }; 81 | 82 | expect(convertPropertyIndexedKeyframes(testObjectKeyframes)).toEqual([ 83 | { 84 | opacity: 0, 85 | transform: 'translateX(0)', 86 | offset: 0, 87 | easing: 'ease-in', 88 | }, 89 | { 90 | opacity: 0.9, 91 | transform: 'translateX(50px)', 92 | offset: 0.8, 93 | easing: 'ease-out', 94 | }, 95 | { opacity: 1, transform: 'translateX(100px)' }, 96 | ]); 97 | }); 98 | }); 99 | 100 | it('throws an error if keyframe offset is not in the range [0,1]', () => { 101 | const element = document.createElement('div'); 102 | const keyframes = [{ offset: -0.1, transform: 'translateX(0)' }]; 103 | 104 | expect(() => { 105 | new KeyframeEffect(element, keyframes); 106 | }).toThrowError( 107 | "Failed to construct 'KeyframeEffect': Offsets must be null or in the range [0,1]." 108 | ); 109 | }); 110 | 111 | it('throws an error if offset are not monotonically non-decreasing', () => { 112 | const element = document.createElement('div'); 113 | const keyframes = [ 114 | { transform: 'translateX(0)', offset: 0.5 }, 115 | { transform: 'translateX(50px)', offset: 0.4 }, 116 | ]; 117 | 118 | expect(() => { 119 | new KeyframeEffect(element, keyframes); 120 | }).toThrowError( 121 | "Failed to construct 'KeyframeEffect': Offsets must be monotonically non-decreasing." 122 | ); 123 | }); 124 | 125 | it('computes keyframes correctly, with default offset spacing', () => { 126 | const element = document.createElement('div'); 127 | const ke = new KeyframeEffect(element, [ 128 | { transform: 'translateX(0)', opacity: 0 }, 129 | { transform: 'translateX(50px)', opacity: 1 }, 130 | { transform: 'translateX(100px)', opacity: 0 }, 131 | { transform: 'translateX(150px)', opacity: 1 }, 132 | { transform: 'translateX(200px)', opacity: 0 }, 133 | ]); 134 | 135 | expect(ke.getKeyframes()).toEqual([ 136 | { 137 | offset: null, 138 | transform: 'translateX(0)', 139 | opacity: 0, 140 | easing: 'linear', 141 | composite: 'replace', 142 | computedOffset: 0, 143 | }, 144 | { 145 | offset: null, 146 | transform: 'translateX(50px)', 147 | opacity: 1, 148 | easing: 'linear', 149 | composite: 'replace', 150 | computedOffset: 0.25, 151 | }, 152 | { 153 | offset: null, 154 | transform: 'translateX(100px)', 155 | opacity: 0, 156 | easing: 'linear', 157 | composite: 'replace', 158 | computedOffset: 0.5, 159 | }, 160 | { 161 | offset: null, 162 | transform: 'translateX(150px)', 163 | opacity: 1, 164 | easing: 'linear', 165 | composite: 'replace', 166 | computedOffset: 0.75, 167 | }, 168 | { 169 | offset: null, 170 | transform: 'translateX(200px)', 171 | opacity: 0, 172 | easing: 'linear', 173 | composite: 'replace', 174 | computedOffset: 1, 175 | }, 176 | ]); 177 | }); 178 | 179 | describe('auto spacing', () => { 180 | it('auto spaces keyframes, when some offset is set, case 1', () => { 181 | const element = document.createElement('div'); 182 | const ke = new KeyframeEffect(element, [{ transform: 'translateX(0)' }]); 183 | 184 | expect(ke.getKeyframes()).toEqual([ 185 | expect.objectContaining({ computedOffset: 1 }), 186 | ]); 187 | }); 188 | 189 | it('auto spaces keyframes, when some offset is set, case 2', () => { 190 | const element = document.createElement('div'); 191 | const ke = new KeyframeEffect(element, [ 192 | { transform: 'translateX(0)', offset: 0.4 }, 193 | { transform: 'translateX(50px)' }, 194 | ]); 195 | 196 | expect(ke.getKeyframes()).toEqual([ 197 | expect.objectContaining({ computedOffset: 0.4 }), 198 | expect.objectContaining({ computedOffset: 1 }), 199 | ]); 200 | }); 201 | 202 | it('auto spaces keyframes, when some offset is set, case 3', () => { 203 | const element = document.createElement('div'); 204 | const ke = new KeyframeEffect(element, [ 205 | { transform: 'translateX(0)' }, 206 | { transform: 'translateX(50px)', offset: 0 }, 207 | { transform: 'translateX(100px)' }, 208 | { transform: 'translateX(150px)' }, 209 | { transform: 'translateX(200px)' }, 210 | ]); 211 | 212 | expect(ke.getKeyframes()).toEqual([ 213 | expect.objectContaining({ computedOffset: 0 }), 214 | expect.objectContaining({ computedOffset: 0 }), 215 | expect.objectContaining({ computedOffset: 0.3333333333333333 }), 216 | expect.objectContaining({ computedOffset: 0.6666666666666667 }), 217 | expect.objectContaining({ computedOffset: 1 }), 218 | ]); 219 | }); 220 | 221 | it('auto spaces keyframes, when some offset is set, case 4', () => { 222 | const element = document.createElement('div'); 223 | const ke = new KeyframeEffect(element, [ 224 | { transform: 'translateX(0)' }, 225 | { transform: 'translateX(50px)' }, 226 | { transform: 'translateX(100px)' }, 227 | { transform: 'translateX(150px)', offset: 1 }, 228 | { transform: 'translateX(200px)' }, 229 | ]); 230 | 231 | expect(ke.getKeyframes()).toEqual([ 232 | expect.objectContaining({ computedOffset: 0 }), 233 | expect.objectContaining({ computedOffset: 0.3333333333333333 }), 234 | expect.objectContaining({ computedOffset: 0.6666666666666667 }), 235 | expect.objectContaining({ computedOffset: 1 }), 236 | expect.objectContaining({ computedOffset: 1 }), 237 | ]); 238 | }); 239 | 240 | it('auto spaces keyframes, when some offset is set, case 5', () => { 241 | const element = document.createElement('div'); 242 | const ke = new KeyframeEffect(element, [ 243 | { transform: 'translateX(0)', offset: 0.5 }, 244 | { transform: 'translateX(50px)' }, 245 | { transform: 'translateX(100px)' }, 246 | { transform: 'translateX(150px)' }, 247 | { transform: 'translateX(200px)' }, 248 | ]); 249 | 250 | expect(ke.getKeyframes()).toEqual([ 251 | expect.objectContaining({ computedOffset: 0.5 }), 252 | expect.objectContaining({ computedOffset: 0.625 }), 253 | expect.objectContaining({ computedOffset: 0.75 }), 254 | expect.objectContaining({ computedOffset: 0.875 }), 255 | expect.objectContaining({ computedOffset: 1 }), 256 | ]); 257 | }); 258 | 259 | it('auto spaces keyframes, when some offset is set, case 6', () => { 260 | const element = document.createElement('div'); 261 | const ke = new KeyframeEffect(element, [ 262 | { transform: 'translateX(0)' }, 263 | { transform: 'translateX(50px)', offset: 0.5 }, 264 | { transform: 'translateX(100px)' }, 265 | { transform: 'translateX(150px)', offset: 0.5 }, 266 | { transform: 'translateX(200px)' }, 267 | ]); 268 | 269 | expect(ke.getKeyframes()).toEqual([ 270 | expect.objectContaining({ computedOffset: 0 }), 271 | expect.objectContaining({ computedOffset: 0.5 }), 272 | expect.objectContaining({ computedOffset: 0.5 }), 273 | expect.objectContaining({ computedOffset: 0.5 }), 274 | expect.objectContaining({ computedOffset: 1 }), 275 | ]); 276 | }); 277 | 278 | it('auto spaces keyframes, when some offset is set, case 7', () => { 279 | const element = document.createElement('div'); 280 | const ke = new KeyframeEffect(element, [ 281 | { transform: 'translateX(0)' }, 282 | { transform: 'translateX(50px)', offset: 0.2 }, 283 | { transform: 'translateX(100px)' }, 284 | { transform: 'translateX(150px)', offset: 0.9 }, 285 | { transform: 'translateX(150px)' }, 286 | { transform: 'translateX(200px)' }, 287 | ]); 288 | 289 | expect(ke.getKeyframes()).toEqual([ 290 | expect.objectContaining({ computedOffset: 0 }), 291 | expect.objectContaining({ computedOffset: 0.2 }), 292 | expect.objectContaining({ computedOffset: 0.55 }), 293 | expect.objectContaining({ computedOffset: 0.9 }), 294 | expect.objectContaining({ computedOffset: 0.95 }), 295 | expect.objectContaining({ computedOffset: 1 }), 296 | ]); 297 | }); 298 | }); 299 | }); 300 | -------------------------------------------------------------------------------- /src/mocks/web-animations-api/__tests__/cancel.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | playAnimation, 3 | FRAME_DURATION, 4 | framesToTime, 5 | wait, 6 | } from '../../testTools'; 7 | import { mockAnimationsApi } from '../index'; 8 | 9 | mockAnimationsApi(); 10 | 11 | describe('Animation', () => { 12 | describe('real timers', () => { 13 | describe('cancel', () => { 14 | it('it doesn\'t cancel if state is "idle"', () => { 15 | runner.useRealTimers(); 16 | 17 | const element = document.createElement('div'); 18 | 19 | const effect = new KeyframeEffect( 20 | element, 21 | { transform: 'translateX(100px)' }, 22 | 200 23 | ); 24 | 25 | const animation = new Animation(effect); 26 | 27 | const finishedPromise = animation.finished; 28 | 29 | animation.cancel(); 30 | 31 | expect(finishedPromise === animation.finished).toBe(true); 32 | }); 33 | 34 | it("it cancels a running animation, but doesn't throw", async () => { 35 | runner.useRealTimers(); 36 | 37 | const element = document.createElement('div'); 38 | 39 | const effect = new KeyframeEffect( 40 | element, 41 | { transform: 'translateX(100px)' }, 42 | framesToTime(10) 43 | ); 44 | 45 | const animation = new Animation(effect); 46 | 47 | animation.play(); 48 | 49 | const finishedPromise = animation.finished; 50 | 51 | await wait(framesToTime(3)); 52 | 53 | animation.cancel(); 54 | 55 | await wait(framesToTime(5)); 56 | 57 | expect(finishedPromise).not.toBe(animation.finished); 58 | }); 59 | 60 | it('rejects the finished promise with an error, if state is "running"', async () => { 61 | runner.useRealTimers(); 62 | 63 | const element = document.createElement('div'); 64 | 65 | const effect = new KeyframeEffect( 66 | element, 67 | { transform: 'translateX(100px)' }, 68 | 200 69 | ); 70 | 71 | const animation = new Animation(effect); 72 | 73 | animation.play(); 74 | 75 | await animation.ready; 76 | 77 | const initialFinishedPromise = animation.finished; 78 | 79 | const result = animation.finished.catch((error: unknown) => { 80 | expect(error).toBeInstanceOf(DOMException); 81 | 82 | if (error instanceof DOMException) { 83 | expect(error.name).toBe('AbortError'); 84 | expect(error.message).toEqual('The user aborted a request.'); 85 | } 86 | 87 | expect(animation.playState).toBe('idle'); 88 | expect(animation.currentTime).toBeNull(); 89 | expect(animation.finished !== initialFinishedPromise).toBe(true); 90 | }); 91 | 92 | animation.cancel(); 93 | 94 | return result; 95 | }); 96 | }); 97 | }); 98 | 99 | describe('fake timers', () => { 100 | beforeEach(async () => { 101 | runner.useFakeTimers(); 102 | 103 | const syncShift = FRAME_DURATION - (performance.now() % FRAME_DURATION); 104 | 105 | await runner.advanceTimersByTime(syncShift); 106 | }); 107 | 108 | describe('cancel', () => { 109 | it('it doesn\'t cancel if state is "idle"', () => { 110 | runner.useFakeTimers(); 111 | 112 | const element = document.createElement('div'); 113 | 114 | const effect = new KeyframeEffect( 115 | element, 116 | { transform: 'translateX(100px)' }, 117 | 200 118 | ); 119 | 120 | const animation = new Animation(effect); 121 | 122 | const finishedPromise = animation.finished; 123 | 124 | animation.cancel(); 125 | 126 | expect(finishedPromise === animation.finished).toBe(true); 127 | }); 128 | 129 | it("it cancels a running animation, but doesn't throw", async () => { 130 | runner.useFakeTimers(); 131 | 132 | const element = document.createElement('div'); 133 | 134 | const effect = new KeyframeEffect( 135 | element, 136 | { transform: 'translateX(100px)' }, 137 | framesToTime(10) 138 | ); 139 | 140 | const animation = new Animation(effect); 141 | 142 | await playAnimation(animation); 143 | 144 | await runner.advanceTimersByTime(framesToTime(3)); 145 | 146 | const finishedPromise = animation.finished; 147 | animation.cancel(); 148 | await runner.advanceTimersByTime(framesToTime(3)); 149 | 150 | expect(finishedPromise).not.toBe(animation.finished); 151 | }); 152 | 153 | it('rejects the finished promise with an error, if state is "running"', async () => { 154 | runner.useFakeTimers(); 155 | 156 | const element = document.createElement('div'); 157 | 158 | const effect = new KeyframeEffect( 159 | element, 160 | { transform: 'translateX(100px)' }, 161 | 200 162 | ); 163 | 164 | const animation = new Animation(effect); 165 | 166 | await playAnimation(animation); 167 | 168 | await animation.ready; 169 | 170 | const initialFinishedPromise = animation.finished; 171 | 172 | const result = animation.finished.catch((error: unknown) => { 173 | expect(error).toBeInstanceOf(DOMException); 174 | 175 | if (error instanceof DOMException) { 176 | expect(error.name).toBe('AbortError'); 177 | expect(error.message).toEqual('The user aborted a request.'); 178 | } 179 | 180 | expect(animation.playState).toBe('idle'); 181 | expect(animation.currentTime).toBeNull(); 182 | expect(animation.finished !== initialFinishedPromise).toBe(true); 183 | }); 184 | 185 | animation.cancel(); 186 | 187 | return result; 188 | }); 189 | }); 190 | }); 191 | }); 192 | -------------------------------------------------------------------------------- /src/mocks/web-animations-api/__tests__/easingFunctions.test.ts: -------------------------------------------------------------------------------- 1 | import { StepsEasing } from '../easingFunctions'; 2 | 3 | // Test values from: 4 | // https://codesandbox.io/s/staging-frog-hbtyhn?file=/index.html 5 | 6 | describe('StepsEasing', () => { 7 | it('should throw error when invalid steps() function is provided', () => { 8 | expect(() => StepsEasing('steps(0.5, jump-end)')).toThrowError( 9 | 'Invalid easing function: steps(0.5, jump-end)' 10 | ); 11 | }); 12 | 13 | describe('jumpterms', () => { 14 | it('should return correct values for jump-start', () => { 15 | const easing1 = StepsEasing('steps(5, jump-start)'); 16 | const easing2 = StepsEasing('steps(5, start)'); 17 | 18 | function tests(easing: (value: number) => number) { 19 | expect(easing(0)).toBe(0); 20 | expect(easing(0.09158299999999997)).toBe(0.2); 21 | expect(easing(0.19157899999999994)).toBe(0.2); 22 | expect(easing(0.29990799999999984)).toBe(0.4); 23 | expect(easing(0.3999039999999998)).toBe(0.4); 24 | expect(easing(0.4998999999999998)).toBe(0.6); 25 | expect(easing(0.5998959999999998)).toBe(0.6); 26 | expect(easing(0.6998919999999997)).toBe(0.8); 27 | expect(easing(0.7998879999999997)).toBe(0.8); 28 | expect(easing(0.8998839999999997)).toBe(1); 29 | expect(easing(0.9915469999999997)).toBe(1); 30 | expect(easing(1)).toBe(1); 31 | } 32 | 33 | tests(easing1); 34 | tests(easing2); 35 | }); 36 | 37 | it('should return correct values for jump-end', () => { 38 | const easing1 = StepsEasing('steps(5, jump-end)'); 39 | const easing2 = StepsEasing('steps(5, end)'); 40 | 41 | function tests(easing: (value: number) => number) { 42 | expect(easing(0)).toBe(0); 43 | expect(easing(0.00022300000000008424)).toBe(0); 44 | expect(easing(0.10021900000000006)).toBe(0); 45 | expect(easing(0.20021500000000003)).toBe(0.2); 46 | expect(easing(0.29187799999999964)).toBe(0.2); 47 | expect(easing(0.400207)).toBe(0.4); 48 | expect(easing(0.500203)).toBe(0.4); 49 | expect(easing(0.5918659999999996)).toBe(0.4); 50 | expect(easing(0.7001949999999999)).toBe(0.6); 51 | expect(easing(0.8001909999999999)).toBe(0.8); 52 | expect(easing(0.9001869999999998)).toBe(0.8); 53 | expect(easing(1)).toBe(1); 54 | } 55 | 56 | tests(easing1); 57 | tests(easing2); 58 | }); 59 | 60 | it('should return correct values for jump-none', () => { 61 | const easing = StepsEasing('steps(5, jump-none)'); 62 | 63 | expect(easing(0)).toBe(0); 64 | expect(easing(0.09995599999999993)).toBe(0); 65 | expect(easing(0.19161899999999998)).toBe(0); 66 | expect(easing(0.29161499999999996)).toBe(0.25); 67 | expect(easing(0.3999440000000001)).toBe(0.25); 68 | expect(easing(0.49994000000000005)).toBe(0.5); 69 | expect(easing(0.5916030000000001)).toBe(0.5); 70 | expect(easing(0.699932)).toBe(0.75); 71 | expect(easing(0.7999280000000002)).toBe(0.75); 72 | expect(easing(0.8999240000000002)).toBe(1); 73 | expect(easing(0.9999200000000001)).toBe(1); 74 | expect(easing(1)).toBe(1); 75 | }); 76 | 77 | it('should return correct values for jump-both', () => { 78 | const easing = StepsEasing('steps(4, jump-both)'); 79 | 80 | expect(easing(0)).toBe(0); 81 | expect(easing(0.09979599999999955)).toBe(0.2); 82 | expect(easing(0.19979199999999953)).toBe(0.2); 83 | expect(easing(0.2997879999999995)).toBe(0.4); 84 | expect(easing(0.3997839999999995)).toBe(0.4); 85 | expect(easing(0.49977999999999945)).toBe(0.4); 86 | expect(easing(0.5997759999999994)).toBe(0.6); 87 | expect(easing(0.6997719999999994)).toBe(0.6); 88 | expect(easing(0.7997679999999994)).toBe(0.8); 89 | expect(easing(0.8997639999999993)).toBe(0.8); 90 | expect(easing(0.9997600000000002)).toBe(0.8); 91 | expect(easing(1)).toBe(1); 92 | }); 93 | 94 | it('should return correct values for step-start', () => { 95 | const easing = StepsEasing('step-start'); 96 | 97 | expect(easing(0)).toBe(0); 98 | expect(easing(0.09979599999999955)).toBe(1); 99 | expect(easing(0.19979199999999953)).toBe(1); 100 | expect(easing(0.2997879999999995)).toBe(1); 101 | expect(easing(0.3997839999999995)).toBe(1); 102 | expect(easing(0.49977999999999945)).toBe(1); 103 | expect(easing(0.5997759999999994)).toBe(1); 104 | expect(easing(0.6997719999999994)).toBe(1); 105 | expect(easing(0.7997679999999994)).toBe(1); 106 | expect(easing(0.8997639999999993)).toBe(1); 107 | expect(easing(0.9997600000000002)).toBe(1); 108 | expect(easing(1)).toBe(1); 109 | }); 110 | 111 | it('should return correct values for step-end', () => { 112 | const easing = StepsEasing('step-end'); 113 | 114 | expect(easing(0)).toBe(0); 115 | expect(easing(0.09979599999999955)).toBe(0); 116 | expect(easing(0.19979199999999953)).toBe(0); 117 | expect(easing(0.2997879999999995)).toBe(0); 118 | expect(easing(0.3997839999999995)).toBe(0); 119 | expect(easing(0.49977999999999945)).toBe(0); 120 | expect(easing(0.5997759999999994)).toBe(0); 121 | expect(easing(0.6997719999999994)).toBe(0); 122 | expect(easing(0.7997679999999994)).toBe(0); 123 | expect(easing(0.8997639999999993)).toBe(0); 124 | expect(easing(0.9997600000000002)).toBe(0); 125 | expect(easing(1)).toBe(1); 126 | }); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /src/mocks/web-animations-api/__tests__/effect.test.ts: -------------------------------------------------------------------------------- 1 | import { playAnimation, FRAME_DURATION } from '../../testTools'; 2 | import { mockAnimationsApi } from '../index'; 3 | 4 | mockAnimationsApi(); 5 | 6 | runner.useFakeTimers(); 7 | 8 | describe('Animation', () => { 9 | beforeEach(async () => { 10 | const syncShift = FRAME_DURATION - (performance.now() % FRAME_DURATION); 11 | 12 | await runner.advanceTimersByTime(syncShift); 13 | }); 14 | 15 | describe('effect', () => { 16 | it('should calculate computed timing', () => { 17 | const element = document.createElement('div'); 18 | 19 | const effect = new KeyframeEffect( 20 | element, 21 | [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], 22 | { 23 | duration: 3000, 24 | fill: 'forwards', 25 | iterations: 2, 26 | delay: 100, 27 | endDelay: 300, 28 | } 29 | ); 30 | const animation = new Animation(effect); 31 | 32 | expect(animation.effect?.getComputedTiming()).toEqual({ 33 | activeDuration: 6000, 34 | currentIteration: null, 35 | delay: 100, 36 | direction: 'normal', 37 | duration: 3000, 38 | easing: 'linear', 39 | endDelay: 300, 40 | endTime: 6400, 41 | fill: 'forwards', 42 | iterationStart: 0, 43 | iterations: 2, 44 | localTime: null, 45 | progress: null, 46 | }); 47 | }); 48 | 49 | describe('should calculate localTime and progress correctly', () => { 50 | it('when just created', () => { 51 | const element = document.createElement('div'); 52 | 53 | const effect = new KeyframeEffect( 54 | element, 55 | [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], 56 | { 57 | duration: 3000, 58 | fill: 'forwards', 59 | iterations: 2, 60 | delay: 100, 61 | endDelay: 300, 62 | } 63 | ); 64 | const animation = new Animation(effect); 65 | 66 | expect(animation.effect?.getComputedTiming()).toEqual( 67 | expect.objectContaining({ 68 | localTime: null, 69 | progress: null, 70 | }) 71 | ); 72 | }); 73 | 74 | it('when running', async () => { 75 | const element = document.createElement('div'); 76 | 77 | const effect = new KeyframeEffect( 78 | element, 79 | [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], 80 | { 81 | duration: 200, 82 | fill: 'forwards', 83 | iterations: 2, 84 | delay: 100, 85 | } 86 | ); 87 | 88 | const animation = new Animation(effect); 89 | 90 | await playAnimation(animation); 91 | 92 | await runner.advanceTimersByTime(50); 93 | 94 | expect(animation.currentTime).toBe(50); 95 | expect(animation.effect?.getComputedTiming().localTime).toBe(50); 96 | 97 | await runner.advanceTimersByTime(50); 98 | 99 | expect(animation.currentTime).toBe(100); 100 | 101 | // first iteration starts, progress should be 0, localTime should be equal to "delay" 102 | expect(animation.effect?.getComputedTiming().progress).toBeCloseTo( 103 | 0, 104 | 1 105 | ); 106 | expect(animation.effect?.getComputedTiming().localTime).toBe(100); 107 | 108 | // 100ms after that we're still in the first iteration 109 | // progress should be 0.5, localTime should be equal to "delay" + "duration" / 2 110 | await runner.advanceTimersByTime(100); 111 | 112 | expect(animation.effect?.getComputedTiming().progress).toBeCloseTo( 113 | 0.5, 114 | 1 115 | ); 116 | expect(animation.effect?.getComputedTiming().localTime).toBe(200); 117 | 118 | // 200ms after that we're in the middle of the second iteration 119 | // progress should be 0.5, localTime should be "delay" + "duration" + "duration" / 2 120 | await runner.advanceTimersByTime(200); 121 | 122 | expect(animation.effect?.getComputedTiming().progress).toBeCloseTo( 123 | 0.5, 124 | 1 125 | ); 126 | expect(animation.effect?.getComputedTiming().localTime).toBe(400); 127 | 128 | await runner.advanceTimersByTime(200); 129 | 130 | await animation.finished; 131 | 132 | expect(animation.currentTime).toBe(500); 133 | }); 134 | }); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /src/mocks/web-animations-api/__tests__/events.test.ts: -------------------------------------------------------------------------------- 1 | import { playAnimation, FRAME_DURATION } from '../../testTools'; 2 | import { mockAnimationsApi } from '../index'; 3 | 4 | mockAnimationsApi(); 5 | 6 | runner.useFakeTimers(); 7 | 8 | describe('Animation', () => { 9 | beforeEach(async () => { 10 | const syncShift = FRAME_DURATION - (performance.now() % FRAME_DURATION); 11 | 12 | await runner.advanceTimersByTime(syncShift); 13 | }); 14 | 15 | describe('events', () => { 16 | it('should fire finish events', async () => { 17 | const element = document.createElement('div'); 18 | 19 | const effect = new KeyframeEffect( 20 | element, 21 | { transform: 'translateX(0)' }, 22 | 100 23 | ); 24 | 25 | const animation = new Animation(effect); 26 | 27 | const onfinish = runner.fn(); 28 | 29 | animation.onfinish = onfinish; 30 | animation.addEventListener('finish', onfinish); 31 | 32 | await playAnimation(animation); 33 | 34 | await runner.advanceTimersByTime(150); 35 | 36 | await expect(animation.finished).resolves.toBeInstanceOf(Animation); 37 | 38 | expect(onfinish).toHaveBeenCalledTimes(2); 39 | }); 40 | 41 | it('should fire cancel events', async () => { 42 | const element = document.createElement('div'); 43 | 44 | const effect = new KeyframeEffect( 45 | element, 46 | { transform: 'translateX(0)' }, 47 | 100 48 | ); 49 | 50 | const animation = new Animation(effect); 51 | 52 | const oncancel = runner.fn(); 53 | 54 | animation.oncancel = oncancel; 55 | animation.addEventListener('cancel', oncancel); 56 | 57 | animation.play(); 58 | 59 | await runner.advanceTimersByTime(50); 60 | 61 | animation.cancel(); 62 | 63 | expect(oncancel).toHaveBeenCalledTimes(2); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/mocks/web-animations-api/__tests__/index.env.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import { WrongEnvironmentError } from '../../../helper'; 6 | import { mockAnimationsApi } from '../'; 7 | 8 | describe('mockAnimationsApi', () => { 9 | it('throws an error when used in a non jsdom environment', () => { 10 | expect(() => { 11 | mockAnimationsApi(); 12 | }).toThrowError(WrongEnvironmentError); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/mocks/web-animations-api/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { mockAnimationsApi } from '../index'; 2 | 3 | mockAnimationsApi(); 4 | 5 | describe('Animations API', () => { 6 | it('should be defined', () => { 7 | const element = document.createElement('div'); 8 | 9 | expect(element.animate).toBeDefined(); 10 | expect(element.getAnimations).toBeDefined(); 11 | expect(document.getAnimations).toBeDefined(); 12 | }); 13 | 14 | test('.animate should create an animation and play it', () => { 15 | const element = document.createElement('div'); 16 | 17 | const animation = element.animate({ opacity: 0 }, 1000); 18 | 19 | expect(animation).toBeInstanceOf(Animation); 20 | expect(animation.playState).toBe('running'); 21 | }); 22 | 23 | it('should add/delete an animation to/from element and document lists', async () => { 24 | const element = document.createElement('div'); 25 | 26 | const animation = element.animate({ opacity: 0 }, 100); 27 | 28 | expect(element.getAnimations().length).toBe(1); 29 | expect(element.getAnimations()).toContain(animation); 30 | expect(document.getAnimations().length).toBe(1); 31 | expect(document.getAnimations()).toContain(animation); 32 | 33 | await animation.finished; 34 | 35 | expect(element.getAnimations().length).toBe(0); 36 | expect(document.getAnimations().length).toBe(0); 37 | }); 38 | 39 | it('should add/delete an animation to/from element and document lists if created manually', async () => { 40 | const element = document.createElement('div'); 41 | 42 | const keyframeEffect = new KeyframeEffect(element, { opacity: 0 }, 100); 43 | const animation = new Animation(keyframeEffect); 44 | animation.play(); 45 | 46 | expect(element.getAnimations().length).toBe(1); 47 | expect(element.getAnimations()).toContain(animation); 48 | expect(document.getAnimations().length).toBe(1); 49 | expect(document.getAnimations()).toContain(animation); 50 | 51 | await animation.finished; 52 | 53 | expect(element.getAnimations().length).toBe(0); 54 | expect(document.getAnimations().length).toBe(0); 55 | }); 56 | 57 | it('should read id from options and attach it to the returned animation', async () => { 58 | const element = document.createElement('div'); 59 | const testId = 'testId'; 60 | 61 | const animation = element.animate({ opacity: 0 }, { id: testId }); 62 | expect(animation.id).toBe(testId); 63 | }); 64 | 65 | it('should remove an animation from element if the animation was cancelled', () => { 66 | const element = document.createElement('div'); 67 | 68 | const animation = element.animate({ opacity: 0 }, 1000); 69 | expect(element.getAnimations().length).toBe(1); 70 | 71 | animation.cancel(); 72 | expect(element.getAnimations().length).toBe(0); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/mocks/web-animations-api/__tests__/main.test.ts: -------------------------------------------------------------------------------- 1 | import { MockedAnimation } from '../Animation'; 2 | import { mockAnimationsApi } from '../index'; 3 | 4 | mockAnimationsApi(); 5 | 6 | runner.useFakeTimers(); 7 | 8 | describe('Animation', () => { 9 | describe('constructor', () => { 10 | it('should be defined', () => { 11 | const animation = new Animation(); 12 | 13 | expect(animation).toBeInstanceOf(MockedAnimation); 14 | }); 15 | 16 | it('should have correct properties if no keyframe effect is provided', async () => { 17 | const animation = new Animation(); 18 | 19 | expect(animation.currentTime).toBeNull(); 20 | expect(animation.effect).toBeNull(); 21 | expect(animation.finished).toBeInstanceOf(Promise); 22 | expect(animation.id).toBe(''); 23 | expect(animation.oncancel).toBeNull(); 24 | expect(animation.onfinish).toBeNull(); 25 | expect(animation.onremove).toBeNull(); 26 | expect(animation.pending).toBe(false); 27 | expect(animation.playState).toBe('idle'); 28 | expect(animation.playbackRate).toBe(1); 29 | expect(await animation.ready).toBe(animation); 30 | expect(animation.replaceState).toBe('active'); 31 | expect(animation.startTime).toBeNull(); 32 | expect(animation.timeline).toBeInstanceOf(AnimationTimeline); 33 | }); 34 | 35 | it('should have correct properties if finite keyframe effect is provided', async () => { 36 | const element = document.createElement('div'); 37 | 38 | const effect = new KeyframeEffect( 39 | element, 40 | [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], 41 | { 42 | duration: 3000, 43 | fill: 'forwards', 44 | iterations: 2, 45 | delay: 100, 46 | endDelay: 300, 47 | } 48 | ); 49 | const animation = new Animation(effect); 50 | 51 | expect(animation.currentTime).toBeNull(); 52 | expect(animation.effect).toBe(effect); 53 | expect(animation.finished).toBeInstanceOf(Promise); 54 | expect(animation.id).toBe(''); 55 | expect(animation.oncancel).toBeNull(); 56 | expect(animation.onfinish).toBeNull(); 57 | expect(animation.onremove).toBeNull(); 58 | expect(animation.pending).toBe(false); 59 | expect(animation.playState).toBe('idle'); 60 | expect(animation.playbackRate).toBe(1); 61 | expect(await animation.ready).toBe(animation); 62 | expect(animation.replaceState).toBe('active'); 63 | expect(animation.startTime).toBeNull(); 64 | expect(animation.timeline).toBeInstanceOf(AnimationTimeline); 65 | }); 66 | }); 67 | 68 | describe('currentTime', () => { 69 | it('should be null if no keyframe effect is provided', () => { 70 | const animation = new Animation(); 71 | 72 | expect(animation.currentTime).toBeNull(); 73 | }); 74 | 75 | it('should be null if infinite keyframe effect is provided', async () => { 76 | const element = document.createElement('div'); 77 | 78 | const effect = new KeyframeEffect( 79 | element, 80 | [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], 81 | { duration: 100, iterations: Infinity } 82 | ); 83 | 84 | const animation = new Animation(effect); 85 | 86 | expect(animation.currentTime).toBeNull(); 87 | }); 88 | }); 89 | 90 | describe('finish', () => { 91 | it('throws an InvalidStateError when finishing an infinite animation', () => { 92 | const element = document.createElement('div'); 93 | 94 | const effect = new KeyframeEffect( 95 | element, 96 | [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], 97 | { duration: 100, iterations: Infinity } 98 | ); 99 | 100 | const animation = new Animation(effect); 101 | 102 | expect(() => animation.finish()).toThrowError( 103 | "Failed to execute 'finish' on 'Animation': Cannot finish Animation with an infinite target effect end." 104 | ); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /src/mocks/web-animations-api/__tests__/pause.test.ts: -------------------------------------------------------------------------------- 1 | import { framesToTime, playAnimation, FRAME_DURATION } from '../../testTools'; 2 | import { mockAnimationsApi } from '../index'; 3 | 4 | mockAnimationsApi(); 5 | 6 | runner.useFakeTimers(); 7 | 8 | describe('Animation', () => { 9 | beforeEach(async () => { 10 | const syncShift = FRAME_DURATION - (performance.now() % FRAME_DURATION); 11 | 12 | await runner.advanceTimersByTime(syncShift); 13 | }); 14 | 15 | describe('pause', () => { 16 | test('during before', async () => { 17 | const element = document.createElement('div'); 18 | 19 | const effect = new KeyframeEffect( 20 | element, 21 | [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], 22 | { 23 | duration: 200, 24 | fill: 'forwards', 25 | delay: 100, 26 | } 27 | ); 28 | 29 | const animation = new Animation(effect); 30 | 31 | await playAnimation(animation); 32 | 33 | await runner.advanceTimersByTime(50); 34 | 35 | animation.pause(); 36 | expect(animation.playState).toBe('paused'); 37 | expect(animation.currentTime).toBeLessThan(300); 38 | 39 | await runner.advanceTimersByTime(50); 40 | 41 | expect(animation.playState).toBe('paused'); 42 | expect(animation.currentTime).toBeLessThan(300); 43 | 44 | await playAnimation(animation); 45 | 46 | expect(animation.playState).toBe('running'); 47 | 48 | await runner.advanceTimersByTime(50); 49 | 50 | expect(animation.currentTime).toBeLessThan(300); 51 | 52 | await runner.advanceTimersByTime(200 + framesToTime(1)); 53 | 54 | await animation.finished; 55 | expect(animation.playState).toBe('finished'); 56 | expect(animation.currentTime).toEqual(300); 57 | }); 58 | 59 | test('during active', async () => { 60 | const element = document.createElement('div'); 61 | 62 | const effect = new KeyframeEffect( 63 | element, 64 | [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], 65 | { 66 | duration: 200, 67 | fill: 'forwards', 68 | delay: 100, 69 | } 70 | ); 71 | 72 | const animation = new Animation(effect); 73 | 74 | await playAnimation(animation); 75 | 76 | await runner.advanceTimersByTime(150); 77 | 78 | animation.pause(); 79 | expect(animation.playState).toBe('paused'); 80 | expect(animation.currentTime).toBeLessThan(300); 81 | 82 | await runner.advanceTimersByTime(50); 83 | 84 | expect(animation.playState).toBe('paused'); 85 | expect(animation.currentTime).toBeLessThan(300); 86 | 87 | await playAnimation(animation); 88 | 89 | expect(animation.playState).toBe('running'); 90 | 91 | await runner.advanceTimersByTime(200); 92 | 93 | await animation.finished; 94 | 95 | expect(animation.currentTime).toBe(300); 96 | }); 97 | 98 | test('during after', async () => { 99 | const element = document.createElement('div'); 100 | 101 | const effect = new KeyframeEffect( 102 | element, 103 | [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], 104 | { 105 | duration: 200, 106 | fill: 'forwards', 107 | delay: 100, 108 | endDelay: 100, 109 | } 110 | ); 111 | 112 | const animation = new Animation(effect); 113 | 114 | await playAnimation(animation); 115 | 116 | await runner.advanceTimersByTime(350); 117 | 118 | animation.pause(); 119 | expect(animation.playState).toBe('paused'); 120 | expect(animation.currentTime).toBeLessThan(400); 121 | 122 | await runner.advanceTimersByTime(50); 123 | 124 | expect(animation.playState).toBe('paused'); 125 | expect(animation.currentTime).toBeLessThan(400); 126 | 127 | await playAnimation(animation); 128 | 129 | expect(animation.playState).toBe('running'); 130 | 131 | await runner.advanceTimersByTime(200); 132 | 133 | await animation.finished; 134 | expect(animation.playState).toBe('finished'); 135 | expect(animation.currentTime).toEqual(400); 136 | }); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /src/mocks/web-animations-api/__tests__/reverse.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | framesToTime, 3 | playAnimation, 4 | playAnimationInReverse, 5 | updateAnimationPlaybackRate, 6 | FRAME_DURATION, 7 | } from '../../testTools'; 8 | import { mockAnimationsApi } from '../index'; 9 | 10 | mockAnimationsApi(); 11 | 12 | runner.useFakeTimers(); 13 | 14 | describe('Animation', () => { 15 | beforeEach(async () => { 16 | const syncShift = FRAME_DURATION - (performance.now() % FRAME_DURATION); 17 | 18 | await runner.advanceTimersByTime(syncShift); 19 | }); 20 | 21 | describe('reverse', () => { 22 | describe('normal', () => { 23 | test('during active', async () => { 24 | const element = document.createElement('div'); 25 | 26 | const effect = new KeyframeEffect( 27 | element, 28 | [ 29 | { transform: 'translateX(50px)' }, 30 | { transform: 'translateX(75px)' }, 31 | { transform: 'translateX(100px)' }, 32 | ], 33 | { duration: framesToTime(6) } 34 | ); 35 | 36 | const animation = new Animation(effect); 37 | 38 | expect(element.style.transform).toBe(''); 39 | 40 | // active -> 41 | await playAnimation(animation); 42 | 43 | expect(element.style.transform).toBe('translateX(50px)'); 44 | 45 | // -> half way | 46 | await runner.advanceTimersByTime(framesToTime(3)); 47 | 48 | expect(element.style.transform).toBe('translateX(75px)'); 49 | 50 | // half way -> back to start | 51 | await playAnimationInReverse(animation); 52 | 53 | await animation.ready; 54 | 55 | await runner.advanceTimersByTime(framesToTime(3) - 1); 56 | 57 | expect(element.style.transform).toBe('translateX(50px)'); 58 | 59 | await runner.advanceTimersByTime(1); 60 | 61 | await animation.finished; 62 | 63 | expect(element.style.transform).toBe(''); 64 | }); 65 | 66 | test('by setting playbackRatio', async () => { 67 | const element = document.createElement('div'); 68 | 69 | const effect = new KeyframeEffect( 70 | element, 71 | [ 72 | { transform: 'translateX(50px)' }, 73 | { transform: 'translateX(75px)' }, 74 | { transform: 'translateX(100px)' }, 75 | ], 76 | { duration: framesToTime(6) } 77 | ); 78 | 79 | const animation = new Animation(effect); 80 | 81 | expect(element.style.transform).toBe(''); 82 | 83 | // active -> 84 | await playAnimation(animation); 85 | 86 | expect(element.style.transform).toBe('translateX(50px)'); 87 | 88 | // -> half way | 89 | await runner.advanceTimersByTime(framesToTime(3)); 90 | 91 | expect(element.style.transform).toBe('translateX(75px)'); 92 | 93 | // half way -> back to start | 94 | animation.playbackRate = -animation.playbackRate; 95 | 96 | await runner.advanceTimersByTime(framesToTime(3) - 1); 97 | 98 | expect(element.style.transform).toBe('translateX(50px)'); 99 | 100 | await runner.advanceTimersByTime(1); 101 | 102 | await animation.finished; 103 | 104 | expect(element.style.transform).toBe(''); 105 | }); 106 | 107 | test('by calling updatePlaybackRate', async () => { 108 | const element = document.createElement('div'); 109 | 110 | const effect = new KeyframeEffect( 111 | element, 112 | [ 113 | { transform: 'translateX(50px)' }, 114 | { transform: 'translateX(75px)' }, 115 | { transform: 'translateX(100px)' }, 116 | ], 117 | { duration: framesToTime(6) } 118 | ); 119 | 120 | const animation = new Animation(effect); 121 | 122 | expect(element.style.transform).toBe(''); 123 | 124 | // active -> 125 | await playAnimation(animation); 126 | 127 | expect(element.style.transform).toBe('translateX(50px)'); 128 | 129 | // -> half way | 130 | await runner.advanceTimersByTime(framesToTime(3)); 131 | 132 | expect(element.style.transform).toBe('translateX(75px)'); 133 | 134 | // half way -> back to start | 135 | await updateAnimationPlaybackRate(animation, -1); 136 | 137 | await runner.advanceTimersByTime(framesToTime(3) - 1); 138 | 139 | expect(element.style.transform).toBe('translateX(50px)'); 140 | 141 | await runner.advanceTimersByTime(1); 142 | 143 | await animation.finished; 144 | 145 | expect(element.style.transform).toBe(''); 146 | }); 147 | }); 148 | 149 | describe('reversed', () => { 150 | test('during active', async () => { 151 | const element = document.createElement('div'); 152 | 153 | const effect = new KeyframeEffect( 154 | element, 155 | [ 156 | { transform: 'translateX(50px)' }, 157 | { transform: 'translateX(75px)' }, 158 | { transform: 'translateX(100px)' }, 159 | ], 160 | { duration: framesToTime(6) } 161 | ); 162 | 163 | const animation = new Animation(effect); 164 | 165 | expect(element.style.transform).toBe(''); 166 | 167 | // active -> 168 | await playAnimationInReverse(animation); 169 | 170 | expect(element.style.transform).toBe('translateX(100px)'); 171 | 172 | // -> half way | 173 | await runner.advanceTimersByTime(framesToTime(3)); 174 | 175 | expect(element.style.transform).toBe('translateX(75px)'); 176 | 177 | // half way -> back to start | 178 | await playAnimationInReverse(animation); 179 | 180 | await runner.advanceTimersByTime(framesToTime(3) - 1); 181 | 182 | expect(element.style.transform).toBe('translateX(100px)'); 183 | 184 | await runner.advanceTimersByTime(1); 185 | 186 | await animation.finished; 187 | 188 | expect(element.style.transform).toBe(''); 189 | }); 190 | 191 | test('by setting playbackRatio', async () => { 192 | const element = document.createElement('div'); 193 | 194 | const effect = new KeyframeEffect( 195 | element, 196 | [ 197 | { transform: 'translateX(50px)' }, 198 | { transform: 'translateX(75px)' }, 199 | { transform: 'translateX(100px)' }, 200 | ], 201 | { duration: framesToTime(6) } 202 | ); 203 | 204 | const animation = new Animation(effect); 205 | 206 | expect(element.style.transform).toBe(''); 207 | 208 | // active -> 209 | await playAnimationInReverse(animation); 210 | 211 | expect(element.style.transform).toBe('translateX(100px)'); 212 | 213 | // -> half way | 214 | await runner.advanceTimersByTime(framesToTime(3)); 215 | 216 | expect(element.style.transform).toBe('translateX(75px)'); 217 | 218 | // half way -> back to start | 219 | animation.playbackRate = -animation.playbackRate; 220 | 221 | await runner.advanceTimersByTime(framesToTime(3) - 1); 222 | 223 | expect(element.style.transform).toBe('translateX(100px)'); 224 | 225 | await runner.advanceTimersByTime(1); 226 | 227 | await animation.finished; 228 | 229 | expect(element.style.transform).toBe(''); 230 | }); 231 | 232 | test('by calling updatePlaybackRate', async () => { 233 | const element = document.createElement('div'); 234 | 235 | const effect = new KeyframeEffect( 236 | element, 237 | [ 238 | { transform: 'translateX(50px)' }, 239 | { transform: 'translateX(75px)' }, 240 | { transform: 'translateX(100px)' }, 241 | ], 242 | { duration: framesToTime(6) } 243 | ); 244 | 245 | const animation = new Animation(effect); 246 | 247 | expect(element.style.transform).toBe(''); 248 | 249 | // active -> 250 | await playAnimationInReverse(animation); 251 | 252 | expect(element.style.transform).toBe('translateX(100px)'); 253 | 254 | // -> half way | 255 | await runner.advanceTimersByTime(framesToTime(3)); 256 | 257 | expect(element.style.transform).toBe('translateX(75px)'); 258 | 259 | // half way -> back to start | 260 | await updateAnimationPlaybackRate(animation, 1); 261 | 262 | await runner.advanceTimersByTime(framesToTime(3) - 1); 263 | 264 | expect(element.style.transform).toBe('translateX(100px)'); 265 | 266 | await runner.advanceTimersByTime(1); 267 | 268 | await animation.finished; 269 | 270 | expect(element.style.transform).toBe(''); 271 | }); 272 | }); 273 | }); 274 | }); 275 | -------------------------------------------------------------------------------- /src/mocks/web-animations-api/easingFunctions.ts: -------------------------------------------------------------------------------- 1 | import BezierEasing from 'bezier-easing'; 2 | 3 | const ease = BezierEasing(0.25, 0.1, 0.25, 1.0); 4 | const easeIn = BezierEasing(0.42, 0.0, 1.0, 1.0); 5 | const easeOut = BezierEasing(0.0, 0.0, 0.58, 1.0); 6 | const easeInOut = BezierEasing(0.42, 0.0, 0.58, 1.0); 7 | 8 | const VALID_JUMPTERMS = [ 9 | 'jump-start', 10 | 'jump-end', 11 | 'jump-none', 12 | 'jump-both', 13 | 'start', 14 | 'end', 15 | ]; 16 | 17 | const clamp = (value: number) => Math.min(Math.max(value, 0), 1); 18 | 19 | /** 20 | * @param easing - string like "steps(4, end)" 21 | * @returns easing function 22 | */ 23 | export const StepsEasing = (easing: string) => { 24 | switch (easing) { 25 | case 'step-start': 26 | easing = 'steps(1, jump-start)'; 27 | break; 28 | case 'step-end': 29 | easing = 'steps(1, jump-end)'; 30 | break; 31 | } 32 | 33 | const easingString = easing.replace('steps(', '').replace(')', ''); 34 | const easingArray = easingString.split(',').map((step) => step.trim()); 35 | const [nString, jumpterm = 'jump-end'] = easingArray; 36 | 37 | const n = Number(nString); 38 | 39 | if ( 40 | isNaN(n) || 41 | !Number.isInteger(n) || 42 | n <= 0 || 43 | !VALID_JUMPTERMS.includes(jumpterm) 44 | ) { 45 | throw new Error(`Invalid easing function: ${easing}`); 46 | } 47 | 48 | switch (jumpterm) { 49 | case 'start': 50 | case 'jump-start': 51 | return (value: number) => { 52 | const step = Math.ceil(value * n); 53 | return clamp(step / n); 54 | }; 55 | 56 | case 'end': 57 | case 'jump-end': 58 | return (value: number) => { 59 | const step = Math.floor(value * n); 60 | return clamp(step / n); 61 | }; 62 | 63 | case 'jump-none': 64 | return (value: number) => { 65 | const step = Math.floor(value * n); 66 | return clamp(step / (n - 1)); 67 | }; 68 | 69 | case 'jump-both': 70 | return (value: number) => { 71 | if (value === 1) { 72 | return 1; 73 | } 74 | 75 | const step = Math.ceil(value * n); 76 | return clamp(step / (n + 1)); 77 | }; 78 | } 79 | 80 | throw new Error(`Invalid easing function: ${easing}`); 81 | }; 82 | 83 | // easing functions 84 | const easingFunctions: { 85 | [key: string]: (value: number, before: boolean) => number; 86 | } = { 87 | linear: (value) => value, 88 | ease, 89 | 'ease-in': easeIn, 90 | 'ease-out': easeOut, 91 | 'ease-in-out': easeInOut, 92 | }; 93 | 94 | function getEasingFunctionFromString(easing: string) { 95 | if (easingFunctions[easing]) { 96 | return easingFunctions[easing]; 97 | } 98 | 99 | // convert "cubic-bezier(x1, y1, x2, y2)" string to bezier easing function 100 | if (easing.indexOf('cubic-bezier(') === 0) { 101 | const bezierString = easing.replace('cubic-bezier(', '').replace(')', ''); 102 | const bezierArray = bezierString.split(',').map(Number); 103 | easingFunctions[easing] = BezierEasing( 104 | bezierArray[0], 105 | bezierArray[1], 106 | bezierArray[2], 107 | bezierArray[3] 108 | ); 109 | 110 | return easingFunctions[easing]; 111 | } 112 | 113 | // convert "steps(x)" string 114 | if (easing.indexOf('steps(') === 0) { 115 | easingFunctions[easing] = StepsEasing(easing); 116 | 117 | return easingFunctions[easing]; 118 | } 119 | 120 | throw new Error(`Unknown easing function "${easing}"`); 121 | } 122 | 123 | export { getEasingFunctionFromString, easingFunctions }; 124 | -------------------------------------------------------------------------------- /src/mocks/web-animations-api/elementAnimations.ts: -------------------------------------------------------------------------------- 1 | const elementAnimations = new Map(); 2 | 3 | export function removeAnimation(element: Element, animation: Animation) { 4 | const animations = elementAnimations.get(element); 5 | 6 | if (animations) { 7 | const index = animations.indexOf(animation); 8 | 9 | if (index !== -1) { 10 | animations.splice(index, 1); 11 | } 12 | } 13 | } 14 | 15 | export function addAnimation(element: Element, animation: Animation) { 16 | const animations = elementAnimations.get(element) ?? []; 17 | animations.push(animation); 18 | 19 | elementAnimations.set(element, animations); 20 | } 21 | 22 | export function getAnimations(this: Element) { 23 | return elementAnimations.get(this) ?? []; 24 | } 25 | 26 | export function getAllAnimations() { 27 | return Array.from(elementAnimations.values()).flat(); 28 | } 29 | 30 | export function clearAnimations() { 31 | elementAnimations.clear(); 32 | } 33 | -------------------------------------------------------------------------------- /src/mocks/web-animations-api/index.ts: -------------------------------------------------------------------------------- 1 | import { mockAnimation } from './Animation'; 2 | import { 3 | getAnimations, 4 | getAllAnimations, 5 | clearAnimations, 6 | } from './elementAnimations'; 7 | import { getConfig } from '../../tools'; 8 | import { isJsdomEnv, WrongEnvironmentError } from '../../helper'; 9 | 10 | const config = getConfig(); 11 | 12 | function animate( 13 | this: Element, 14 | keyframes: Keyframe[], 15 | options?: number | KeyframeAnimationOptions 16 | ) { 17 | const keyframeEffect = new KeyframeEffect(this, keyframes, options); 18 | 19 | const animation = new Animation(keyframeEffect); 20 | if (typeof options == 'object' && options.id) { 21 | animation.id = options.id; 22 | } 23 | 24 | animation.play(); 25 | 26 | return animation; 27 | } 28 | 29 | function mockAnimationsApi() { 30 | if (!isJsdomEnv()) { 31 | throw new WrongEnvironmentError(); 32 | } 33 | 34 | const savedAnimate = Element.prototype.animate; 35 | const savedGetAnimations = Element.prototype.getAnimations; 36 | const savedGetAllAnimations = Document.prototype.getAnimations; 37 | 38 | mockAnimation(); 39 | 40 | Object.defineProperties(Element.prototype, { 41 | animate: { 42 | writable: true, 43 | configurable: true, 44 | value: animate, 45 | }, 46 | getAnimations: { 47 | writable: true, 48 | configurable: true, 49 | value: getAnimations, 50 | }, 51 | }); 52 | 53 | Object.defineProperty(Document.prototype, 'getAnimations', { 54 | writable: true, 55 | configurable: true, 56 | value: getAllAnimations, 57 | }); 58 | 59 | config.afterEach(() => { 60 | clearAnimations(); 61 | }); 62 | 63 | config.afterAll(() => { 64 | Element.prototype.animate = savedAnimate; 65 | Element.prototype.getAnimations = savedGetAnimations; 66 | Document.prototype.getAnimations = savedGetAllAnimations; 67 | }); 68 | } 69 | 70 | export { mockAnimationsApi }; 71 | -------------------------------------------------------------------------------- /src/tools.ts: -------------------------------------------------------------------------------- 1 | import { UndefinedHookError } from './helper'; 2 | 3 | type JTMConfig = { 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | beforeAll: (callback: () => any) => void; 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | afterAll: (callback: () => any) => void; 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | beforeEach: (callback: () => any) => void; 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | afterEach: (callback: () => any) => void; 12 | act: (trigger: () => void) => void; 13 | }; 14 | 15 | const getThrowHookError = (hookName: string) => () => { 16 | throw new UndefinedHookError(hookName); 17 | }; 18 | 19 | const config: JTMConfig = { 20 | beforeAll: 21 | typeof beforeAll !== 'undefined' 22 | ? beforeAll 23 | : getThrowHookError('beforeAll'), 24 | afterAll: 25 | typeof afterAll !== 'undefined' ? afterAll : getThrowHookError('afterAll'), 26 | beforeEach: 27 | typeof beforeEach !== 'undefined' 28 | ? beforeEach 29 | : getThrowHookError('beforeEach'), 30 | afterEach: 31 | typeof afterEach !== 'undefined' 32 | ? afterEach 33 | : getThrowHookError('afterEach'), 34 | act: (trigger) => trigger(), 35 | }; 36 | 37 | export const getConfig = () => config; 38 | export const configMocks = ({ 39 | beforeAll, 40 | afterAll, 41 | beforeEach, 42 | afterEach, 43 | act, 44 | }: Partial) => { 45 | if (beforeAll) config.beforeAll = beforeAll; 46 | if (afterAll) config.afterAll = afterAll; 47 | if (beforeEach) config.beforeEach = beforeEach; 48 | if (afterEach) config.afterEach = afterEach; 49 | if (act) config.act = act; 50 | }; 51 | -------------------------------------------------------------------------------- /swcjest.config.ts: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jsdom', 3 | transform: { 4 | '^.+\\.(t|j)sx?$': '@swc/jest', 5 | }, 6 | setupFilesAfterEnv: ['/jest-setup.ts'], 7 | testPathIgnorePatterns: ['/node_modules/', '/examples/'], 8 | }; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "global.d.ts"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "target": "ES2015", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "strict": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "moduleResolution": "node", 16 | "jsx": "react-jsx", 17 | "esModuleInterop": true, 18 | "skipLibCheck": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "noEmit": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /vitest-setup.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | function useFakeTimers() { 4 | // vitest doesn't enable performance by default 5 | vi.useFakeTimers({ 6 | shouldClearNativeTimers: true, 7 | toFake: [ 8 | 'setTimeout', 9 | 'clearTimeout', 10 | 'setInterval', 11 | 'clearInterval', 12 | 'performance', 13 | 'requestAnimationFrame', 14 | 'cancelAnimationFrame', 15 | ], 16 | }); 17 | } 18 | 19 | function useRealTimers() { 20 | vi.useRealTimers(); 21 | } 22 | 23 | async function advanceTimersByTime(time: number) { 24 | await vi.advanceTimersByTimeAsync(time); 25 | } 26 | 27 | function fn() { 28 | return vi.fn(); 29 | } 30 | 31 | globalThis.runner = { 32 | name: 'vi', 33 | useFakeTimers, 34 | useRealTimers, 35 | advanceTimersByTime, 36 | fn, 37 | }; 38 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | import { defineConfig } from 'vite'; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | test: { 9 | globals: true, 10 | environment: 'jsdom', 11 | setupFiles: './vitest-setup.ts', 12 | include: ['src/**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 13 | }, 14 | }); 15 | --------------------------------------------------------------------------------