diff --git a/examples/package-lock.json b/examples/package-lock.json index a0b0aed..849c54c 100644 --- a/examples/package-lock.json +++ b/examples/package-lock.json @@ -15,12 +15,16 @@ "@types/node": "^16.11.43", "@types/react": "^18.0.14", "@types/react-dom": "^18.0.5", + "motion": "^10.13.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.3.0", "react-scripts": "5.0.1", "typescript": "^4.7.4", "web-vitals": "^2.1.4" + }, + "devDependencies": { + "identity-obj-proxy": "^3.0.0" } }, "node_modules/@ampproject/remapping": { @@ -2910,6 +2914,82 @@ "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" }, + "node_modules/@motionone/animation": { + "version": "10.13.1", + "resolved": "https://registry.npmjs.org/@motionone/animation/-/animation-10.13.1.tgz", + "integrity": "sha512-dxQ+1wWxL6iFHDy1uv6hhcPjIdOg36eDT56jN4LI7Z5HZRyLpq8x1t7JFQclo/IEIb+6Bk4atmyinGFdXVECuA==", + "dependencies": { + "@motionone/easing": "^10.13.1", + "@motionone/types": "^10.13.0", + "@motionone/utils": "^10.13.1", + "tslib": "^2.3.1" + } + }, + "node_modules/@motionone/dom": { + "version": "10.13.1", + "resolved": "https://registry.npmjs.org/@motionone/dom/-/dom-10.13.1.tgz", + "integrity": "sha512-zjfX+AGMIt/fIqd/SL1Lj93S6AiJsEA3oc5M9VkUr+Gz+juRmYN1vfvZd6MvEkSqEjwPQgcjN7rGZHrDB9APfQ==", + "dependencies": { + "@motionone/animation": "^10.13.1", + "@motionone/generators": "^10.13.1", + "@motionone/types": "^10.13.0", + "@motionone/utils": "^10.13.1", + "hey-listen": "^1.0.8", + "tslib": "^2.3.1" + } + }, + "node_modules/@motionone/easing": { + "version": "10.13.1", + "resolved": "https://registry.npmjs.org/@motionone/easing/-/easing-10.13.1.tgz", + "integrity": "sha512-INEsInHHDHVgx0dp5qlXi1lMXBqYicgLMMSn3zfGzaIvcaEbI1Uz8BoyNV4BiclTupG7RYIh+T6BU83ZcEe74g==", + "dependencies": { + "@motionone/utils": "^10.13.1", + "tslib": "^2.3.1" + } + }, + "node_modules/@motionone/generators": { + "version": "10.13.1", + "resolved": "https://registry.npmjs.org/@motionone/generators/-/generators-10.13.1.tgz", + "integrity": "sha512-+HK5u2YcNJCckTTqfOLgSVcrWv2z1dVwrSZEMVJuAh0EnWEWGDJRvMBoPc0cFf/osbkA2Rq9bH2+vP0Ex/D8uw==", + "dependencies": { + "@motionone/types": "^10.13.0", + "@motionone/utils": "^10.13.1", + "tslib": "^2.3.1" + } + }, + "node_modules/@motionone/svelte": { + "version": "10.13.1", + "resolved": "https://registry.npmjs.org/@motionone/svelte/-/svelte-10.13.1.tgz", + "integrity": "sha512-9d73nH4Uow0zC68ubSRYbmN/e2V7t16dSoDrBU1TfY2fue4ol+NJo8cd8ptT+5RRKmslmB3fg4swmDcGkOslJA==", + "dependencies": { + "@motionone/dom": "^10.13.1", + "tslib": "^2.3.1" + } + }, + "node_modules/@motionone/types": { + "version": "10.13.0", + "resolved": "https://registry.npmjs.org/@motionone/types/-/types-10.13.0.tgz", + "integrity": "sha512-qegk4qg8U1N9ZwAJ187BG3TkZz1k9LP/pvNtCSlqdq/PMUDKlCFG4ZnjJ481P0IOH/vIw1OzIbKIuyg0A3rk9g==" + }, + "node_modules/@motionone/utils": { + "version": "10.13.1", + "resolved": "https://registry.npmjs.org/@motionone/utils/-/utils-10.13.1.tgz", + "integrity": "sha512-TjDPTIppaf3ofBXQv4ZzAketJgN0sclALXfZ6mfrkjJkOy83mLls9744F+6S+VKCpBmvbZcBY4PQfrfhAfeMtA==", + "dependencies": { + "@motionone/types": "^10.13.0", + "hey-listen": "^1.0.8", + "tslib": "^2.3.1" + } + }, + "node_modules/@motionone/vue": { + "version": "10.13.1", + "resolved": "https://registry.npmjs.org/@motionone/vue/-/vue-10.13.1.tgz", + "integrity": "sha512-Zg0FnLWVLFhpAd3fh3avkTgbYITE75ldgwh2VMz346uwOpKz2gPHyYq5CmoW/wzUMvfCMfjy1dqmzZThii71ew==", + "dependencies": { + "@motionone/dom": "^10.13.1", + "tslib": "^2.3.1" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -8272,6 +8352,11 @@ "he": "bin/he" } }, + "node_modules/hey-listen": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", + "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==" + }, "node_modules/history": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", @@ -11562,6 +11647,19 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/motion": { + "version": "10.13.1", + "resolved": "https://registry.npmjs.org/motion/-/motion-10.13.1.tgz", + "integrity": "sha512-MPTlDUiIhB5q/KGt3LPykAelJMZ6EM5xNr/zL21PlD68TLsNY0ox8rPiXfTUzjCh5BV04+8RLkjhnj/UNInH6w==", + "dependencies": { + "@motionone/animation": "^10.13.1", + "@motionone/dom": "^10.13.1", + "@motionone/svelte": "^10.13.1", + "@motionone/types": "^10.13.0", + "@motionone/utils": "^10.13.1", + "@motionone/vue": "^10.13.1" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -18534,6 +18632,82 @@ "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" }, + "@motionone/animation": { + "version": "10.13.1", + "resolved": "https://registry.npmjs.org/@motionone/animation/-/animation-10.13.1.tgz", + "integrity": "sha512-dxQ+1wWxL6iFHDy1uv6hhcPjIdOg36eDT56jN4LI7Z5HZRyLpq8x1t7JFQclo/IEIb+6Bk4atmyinGFdXVECuA==", + "requires": { + "@motionone/easing": "^10.13.1", + "@motionone/types": "^10.13.0", + "@motionone/utils": "^10.13.1", + "tslib": "^2.3.1" + } + }, + "@motionone/dom": { + "version": "10.13.1", + "resolved": "https://registry.npmjs.org/@motionone/dom/-/dom-10.13.1.tgz", + "integrity": "sha512-zjfX+AGMIt/fIqd/SL1Lj93S6AiJsEA3oc5M9VkUr+Gz+juRmYN1vfvZd6MvEkSqEjwPQgcjN7rGZHrDB9APfQ==", + "requires": { + "@motionone/animation": "^10.13.1", + "@motionone/generators": "^10.13.1", + "@motionone/types": "^10.13.0", + "@motionone/utils": "^10.13.1", + "hey-listen": "^1.0.8", + "tslib": "^2.3.1" + } + }, + "@motionone/easing": { + "version": "10.13.1", + "resolved": "https://registry.npmjs.org/@motionone/easing/-/easing-10.13.1.tgz", + "integrity": "sha512-INEsInHHDHVgx0dp5qlXi1lMXBqYicgLMMSn3zfGzaIvcaEbI1Uz8BoyNV4BiclTupG7RYIh+T6BU83ZcEe74g==", + "requires": { + "@motionone/utils": "^10.13.1", + "tslib": "^2.3.1" + } + }, + "@motionone/generators": { + "version": "10.13.1", + "resolved": "https://registry.npmjs.org/@motionone/generators/-/generators-10.13.1.tgz", + "integrity": "sha512-+HK5u2YcNJCckTTqfOLgSVcrWv2z1dVwrSZEMVJuAh0EnWEWGDJRvMBoPc0cFf/osbkA2Rq9bH2+vP0Ex/D8uw==", + "requires": { + "@motionone/types": "^10.13.0", + "@motionone/utils": "^10.13.1", + "tslib": "^2.3.1" + } + }, + "@motionone/svelte": { + "version": "10.13.1", + "resolved": "https://registry.npmjs.org/@motionone/svelte/-/svelte-10.13.1.tgz", + "integrity": "sha512-9d73nH4Uow0zC68ubSRYbmN/e2V7t16dSoDrBU1TfY2fue4ol+NJo8cd8ptT+5RRKmslmB3fg4swmDcGkOslJA==", + "requires": { + "@motionone/dom": "^10.13.1", + "tslib": "^2.3.1" + } + }, + "@motionone/types": { + "version": "10.13.0", + "resolved": "https://registry.npmjs.org/@motionone/types/-/types-10.13.0.tgz", + "integrity": "sha512-qegk4qg8U1N9ZwAJ187BG3TkZz1k9LP/pvNtCSlqdq/PMUDKlCFG4ZnjJ481P0IOH/vIw1OzIbKIuyg0A3rk9g==" + }, + "@motionone/utils": { + "version": "10.13.1", + "resolved": "https://registry.npmjs.org/@motionone/utils/-/utils-10.13.1.tgz", + "integrity": "sha512-TjDPTIppaf3ofBXQv4ZzAketJgN0sclALXfZ6mfrkjJkOy83mLls9744F+6S+VKCpBmvbZcBY4PQfrfhAfeMtA==", + "requires": { + "@motionone/types": "^10.13.0", + "hey-listen": "^1.0.8", + "tslib": "^2.3.1" + } + }, + "@motionone/vue": { + "version": "10.13.1", + "resolved": "https://registry.npmjs.org/@motionone/vue/-/vue-10.13.1.tgz", + "integrity": "sha512-Zg0FnLWVLFhpAd3fh3avkTgbYITE75ldgwh2VMz346uwOpKz2gPHyYq5CmoW/wzUMvfCMfjy1dqmzZThii71ew==", + "requires": { + "@motionone/dom": "^10.13.1", + "tslib": "^2.3.1" + } + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -22436,6 +22610,11 @@ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==" }, + "hey-listen": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", + "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==" + }, "history": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", @@ -24823,6 +25002,19 @@ "minimist": "^1.2.6" } }, + "motion": { + "version": "10.13.1", + "resolved": "https://registry.npmjs.org/motion/-/motion-10.13.1.tgz", + "integrity": "sha512-MPTlDUiIhB5q/KGt3LPykAelJMZ6EM5xNr/zL21PlD68TLsNY0ox8rPiXfTUzjCh5BV04+8RLkjhnj/UNInH6w==", + "requires": { + "@motionone/animation": "^10.13.1", + "@motionone/dom": "^10.13.1", + "@motionone/svelte": "^10.13.1", + "@motionone/types": "^10.13.0", + "@motionone/utils": "^10.13.1", + "@motionone/vue": "^10.13.1" + } + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", diff --git a/examples/package.json b/examples/package.json index 030beb2..4705257 100644 --- a/examples/package.json +++ b/examples/package.json @@ -10,6 +10,7 @@ "@types/node": "^16.11.43", "@types/react": "^18.0.14", "@types/react-dom": "^18.0.5", + "motion": "^10.13.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.3.0", @@ -40,5 +41,13 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "identity-obj-proxy": "^3.0.0" + }, + "jest": { + "moduleNameMapper": { + "\\.(css|less)$": "identity-obj-proxy" + } } } diff --git a/examples/public/favicon.ico b/examples/public/favicon.ico deleted file mode 100644 index a11777c..0000000 Binary files a/examples/public/favicon.ico and /dev/null differ diff --git a/examples/public/index.html b/examples/public/index.html index aa069f2..eeb8439 100644 --- a/examples/public/index.html +++ b/examples/public/index.html @@ -2,42 +2,11 @@ - - - - - - - - React App + jsdom testing mocks
- diff --git a/examples/public/logo192.png b/examples/public/logo192.png deleted file mode 100644 index fc44b0a..0000000 Binary files a/examples/public/logo192.png and /dev/null differ diff --git a/examples/public/logo512.png b/examples/public/logo512.png deleted file mode 100644 index a4e47a6..0000000 Binary files a/examples/public/logo512.png and /dev/null differ diff --git a/examples/public/manifest.json b/examples/public/manifest.json deleted file mode 100644 index 080d6c7..0000000 --- a/examples/public/manifest.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "short_name": "React App", - "name": "Create React App Sample", - "icons": [ - { - "src": "favicon.ico", - "sizes": "64x64 32x32 24x24 16x16", - "type": "image/x-icon" - }, - { - "src": "logo192.png", - "type": "image/png", - "sizes": "192x192" - }, - { - "src": "logo512.png", - "type": "image/png", - "sizes": "512x512" - } - ], - "start_url": ".", - "display": "standalone", - "theme_color": "#000000", - "background_color": "#ffffff" -} diff --git a/examples/public/robots.txt b/examples/public/robots.txt deleted file mode 100644 index e9e57dc..0000000 --- a/examples/public/robots.txt +++ /dev/null @@ -1,3 +0,0 @@ -# https://www.robotstxt.org/robotstxt.html -User-agent: * -Disallow: diff --git a/examples/src/App.tsx b/examples/src/App.tsx index c0806d3..240aeed 100644 --- a/examples/src/App.tsx +++ b/examples/src/App.tsx @@ -7,6 +7,9 @@ import MeasureParent from './components/resize-observer/measure-parent/MeasurePa import PrintMySize from './components/resize-observer/print-my-size/PrintMySize'; import CustomUseMedia from './components/viewport/custom-use-media/CustomUseMedia'; import DeprecatedCustomUseMedia from './components/viewport/deprecated-use-media/DeprecatedUseMedia'; +import { Layout } from './components/animations/Layout'; +import AnimationsInView from './components/animations/examples/InView'; +import AnimationsIndex from './components/animations'; function Index() { return <>; @@ -28,6 +31,10 @@ function App() { path="/resize-observer/print-my-size" element={} /> + }> + } /> + } /> + } /> diff --git a/examples/src/components/Nav.tsx b/examples/src/components/Nav.tsx index e8531c6..8c9d20c 100644 --- a/examples/src/components/Nav.tsx +++ b/examples/src/components/Nav.tsx @@ -8,25 +8,29 @@ const Nav = (): React.ReactElement => ( listStyleType: 'none', display: 'flex', justifyContent: 'center', + gap: '1rem', }} > -
  • +
  • Intersection Observer
  • -
  • +
  • Resize Observer: do I fit?
  • -
  • +
  • Resize Observer: print my size
  • -
  • +
  • Viewport
  • Viewport (old)
  • +
  • + Animations +
  • ); diff --git a/examples/src/components/animations/Layout.module.css b/examples/src/components/animations/Layout.module.css new file mode 100644 index 0000000..c9c2225 --- /dev/null +++ b/examples/src/components/animations/Layout.module.css @@ -0,0 +1,4 @@ +.container { + display: grid; + grid-template-columns: 200px 1fr; +} diff --git a/examples/src/components/animations/Layout.tsx b/examples/src/components/animations/Layout.tsx new file mode 100644 index 0000000..40dd810 --- /dev/null +++ b/examples/src/components/animations/Layout.tsx @@ -0,0 +1,17 @@ +import { Outlet } from 'react-router-dom'; +import Nav from './Nav'; + +import styles from './Layout.module.css'; + +export const Layout = () => { + return ( +
    + +
    + +
    +
    + ); +}; diff --git a/examples/src/components/animations/Nav.module.css b/examples/src/components/animations/Nav.module.css new file mode 100644 index 0000000..c053c10 --- /dev/null +++ b/examples/src/components/animations/Nav.module.css @@ -0,0 +1,8 @@ +.nav { + position: fixed; +} + +.list { + list-style-type: none; + padding: 0 0 0 1rem; +} diff --git a/examples/src/components/animations/Nav.tsx b/examples/src/components/animations/Nav.tsx new file mode 100644 index 0000000..4b46585 --- /dev/null +++ b/examples/src/components/animations/Nav.tsx @@ -0,0 +1,15 @@ +import styles from './Nav.module.css'; + +const Nav = () => { + return ( + + ); +}; + +export default Nav; diff --git a/examples/src/components/animations/examples/InView.tsx b/examples/src/components/animations/examples/InView.tsx new file mode 100644 index 0000000..e8f1480 --- /dev/null +++ b/examples/src/components/animations/examples/InView.tsx @@ -0,0 +1,54 @@ +import styles from './inview.module.css'; +import { inView, animate } from 'motion'; +import { useEffect, useRef } from 'react'; + +const AnimationsInView = () => { + const ref = useRef(null); + + useEffect(() => { + if (!ref.current) { + return; + } + + const stop = inView('.inview-section', ({ target }) => { + const span = target.querySelector('span'); + + if (span) { + animate( + span, + { opacity: 1, transform: 'none' }, + { delay: 0.2, duration: 0.9, easing: [0.17, 0.55, 0.55, 1] } + ); + } + }); + + return () => { + stop(); + }; + }, []); + + return ( +
    +
    + + Scroll + +
    +
    + to +
    +
    + + trigger + +
    +
    + + animations! + +
    +
    + ); +}; + +export default AnimationsInView; diff --git a/examples/src/components/animations/examples/inView.test.tsx b/examples/src/components/animations/examples/inView.test.tsx new file mode 100644 index 0000000..6cc7937 --- /dev/null +++ b/examples/src/components/animations/examples/inView.test.tsx @@ -0,0 +1,120 @@ +import { render, act, screen, waitFor } from '@testing-library/react'; + +import { + mockIntersectionObserver, + mockAnimationsApi, +} from '../../../../../dist'; + +import InView from './InView'; + +const io = mockIntersectionObserver(); +mockAnimationsApi(); + +describe('Animations/InView', () => { + it('works with real timers', async () => { + render(); + + // first section + + expect(screen.getByText('Scroll')).not.toBeVisible(); + + act(() => { + io.enterNode(screen.getByTestId('section1')); + }); + + await waitFor(() => { + expect(screen.getByText('Scroll')).toBeVisible(); + }); + + // second section + expect(screen.getByText('to')).not.toBeVisible(); + + act(() => { + io.enterNode(screen.getByTestId('section2')); + }); + + await waitFor(() => { + expect(screen.getByText('to')).toBeVisible(); + }); + + // third section + expect(screen.getByText('trigger')).not.toBeVisible(); + + act(() => { + io.enterNode(screen.getByTestId('section3')); + }); + + await waitFor(() => { + expect(screen.getByText('trigger')).toBeVisible(); + }); + + // fourth section + expect(screen.getByText('animations!')).not.toBeVisible(); + + act(() => { + io.enterNode(screen.getByTestId('section4')); + }); + + await waitFor(() => { + expect(screen.getByText('animations!')).toBeVisible(); + }); + }); + + it('works with fake timers', async () => { + jest.useFakeTimers(); + + render(); + + // first section + expect(screen.getByText('Scroll')).not.toBeVisible(); + + act(() => { + io.enterNode(screen.getByTestId('section1')); + }); + + jest.advanceTimersByTime(1000); + + await waitFor(() => { + expect(screen.getByText('Scroll')).toBeVisible(); + }); + + // second section + expect(screen.getByText('to')).not.toBeVisible(); + + act(() => { + io.enterNode(screen.getByTestId('section2')); + }); + + jest.advanceTimersByTime(1000); + + await waitFor(() => { + expect(screen.getByText('to')).toBeVisible(); + }); + + // third section + expect(screen.getByText('trigger')).not.toBeVisible(); + + act(() => { + io.enterNode(screen.getByTestId('section3')); + }); + + jest.advanceTimersByTime(1000); + + await waitFor(() => { + expect(screen.getByText('trigger')).not.toBeVisible(); + }); + + // fourth section + expect(screen.getByText('animations!')).not.toBeVisible(); + + act(() => { + io.enterNode(screen.getByTestId('section4')); + }); + + jest.advanceTimersByTime(1000); + + await waitFor(() => { + expect(screen.getByText('animations!')).toBeVisible(); + }); + }); +}); diff --git a/examples/src/components/animations/examples/inview.module.css b/examples/src/components/animations/examples/inview.module.css new file mode 100644 index 0000000..de00f59 --- /dev/null +++ b/examples/src/components/animations/examples/inview.module.css @@ -0,0 +1,62 @@ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(https://fonts.gstatic.com/s/inter/v3/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa25L7W0Q5n-wU.woff2) + format('woff2'); + unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, + U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +.container { + --white: #f5f5f5; + --black: #0f1115; + --yellow: #ffeb0e; + --strong-blue: #0d63f8; + --blue: #31a6fa; + --green: #57eb64; + --pink: #ff2965; + --red: #ff1231; + --splash: #00ffdb; + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + min-height: 90vh; +} + +.container section { + box-sizing: border-box; + width: 100%; + height: 101vh; + display: flex; + justify-content: flex-start; + overflow: hidden; + padding: 50px; + background: var(--green); +} + +.container section:nth-child(2) { + background: var(--splash); +} + +.container section:nth-child(3) { + background: var(--pink); +} + +.container section:nth-child(4) { + background: var(--yellow); +} + +.container section span { + display: block; +} + +.container * { + font-weight: 700; + font-family: 'Inter-Bold', 'Inter', sans-serif; + color: rgba(0, 0, 0, 0.9); + font-size: 48px; + letter-spacing: -2px; +} diff --git a/examples/src/components/animations/index.tsx b/examples/src/components/animations/index.tsx new file mode 100644 index 0000000..05d3012 --- /dev/null +++ b/examples/src/components/animations/index.tsx @@ -0,0 +1,5 @@ +const AnimationsIndex = () => { + return <>; +}; + +export default AnimationsIndex; diff --git a/examples/src/components/intersection-observer/global-observer/GlobalObserver.tsx b/examples/src/components/intersection-observer/global-observer/GlobalObserver.tsx index 45dcd7e..bbe7922 100644 --- a/examples/src/components/intersection-observer/global-observer/GlobalObserver.tsx +++ b/examples/src/components/intersection-observer/global-observer/GlobalObserver.tsx @@ -1,4 +1,4 @@ -import { useRef, ReactElement } from 'react'; +import { useRef, type ReactElement } from 'react'; import useIntersection from './useIntersection'; export const Section = ({ diff --git a/examples/src/components/intersection-observer/global-observer/useIntersection.ts b/examples/src/components/intersection-observer/global-observer/useIntersection.ts index d0b1e2c..fec5e84 100644 --- a/examples/src/components/intersection-observer/global-observer/useIntersection.ts +++ b/examples/src/components/intersection-observer/global-observer/useIntersection.ts @@ -13,8 +13,8 @@ const generateId = () => { function createObserver() { observer = new IntersectionObserver( - entries => - entries.forEach(entry => { + (entries) => + entries.forEach((entry) => { entryCallbacks[(entry.target as HTMLElement).dataset._ioid as string]( entry ); @@ -40,7 +40,7 @@ const useIntersection = ( const domId = generateId(); - entryCallbacks[domId.toString()] = entry => { + entryCallbacks[domId.toString()] = (entry) => { setIsIntersecting(entry.isIntersecting); callback?.([entry], observer); }; @@ -57,7 +57,7 @@ const useIntersection = ( delete entryCallbacks[domId]; observer.unobserve(node); }; - }, [ref]); + }, [callback, ref]); return isIntersecting; }; diff --git a/examples/src/components/resize-observer/measure-parent/useDoIFit.ts b/examples/src/components/resize-observer/measure-parent/useDoIFit.ts index da63e7e..f7d8cdc 100644 --- a/examples/src/components/resize-observer/measure-parent/useDoIFit.ts +++ b/examples/src/components/resize-observer/measure-parent/useDoIFit.ts @@ -10,13 +10,11 @@ const useDoIFit = (ref: React.RefObject) => { const parentElement = ref.current.parentElement; - const observer = new ResizeObserver(entries => { + const observer = new ResizeObserver((entries) => { const { width, height } = entries[0].contentRect; const childElement = parentElement.children[0] as HTMLElement; - const { - width: childWidth, - height: childHeight, - } = childElement.getBoundingClientRect(); + const { width: childWidth, height: childHeight } = + childElement.getBoundingClientRect(); setIFit(childWidth < width && childHeight < height); }); @@ -26,7 +24,7 @@ const useDoIFit = (ref: React.RefObject) => { return () => { observer.disconnect(); }; - }, []); + }, [ref]); return iFit; }; diff --git a/examples/src/index.tsx b/examples/src/index.tsx index 41b98fa..537d28c 100644 --- a/examples/src/index.tsx +++ b/examples/src/index.tsx @@ -7,6 +7,7 @@ import App from './App'; const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement ); + root.render( diff --git a/examples/src/tests/intersection-observer.test.tsx b/examples/src/tests/intersection-observer.test.tsx index e1c692d..9b8629a 100644 --- a/examples/src/tests/intersection-observer.test.tsx +++ b/examples/src/tests/intersection-observer.test.tsx @@ -3,9 +3,8 @@ import { render, act, screen } from '@testing-library/react'; import { mockIntersectionObserver, MockedIntersectionObserver, -} from '../../../src'; - -import type { IntersectionDescription } from '../../../src'; + type IntersectionDescription, +} from '../../../dist'; import App, { Section, diff --git a/examples/src/tests/resize-observer.test.tsx b/examples/src/tests/resize-observer.test.tsx index 808d494..a5219e9 100644 --- a/examples/src/tests/resize-observer.test.tsx +++ b/examples/src/tests/resize-observer.test.tsx @@ -3,7 +3,7 @@ import { render, act, screen } from '@testing-library/react'; import { mockResizeObserver, mockElementBoundingClientRect, -} from '../../../src'; +} from '../../../dist'; import MeasureParent from '../components/resize-observer/measure-parent/MeasureParent'; import PrintMySize from '../components/resize-observer/print-my-size/PrintMySize'; diff --git a/examples/src/tests/viewport.test.tsx b/examples/src/tests/viewport.test.tsx index 04536b9..df29b58 100644 --- a/examples/src/tests/viewport.test.tsx +++ b/examples/src/tests/viewport.test.tsx @@ -1,6 +1,6 @@ import { render, act, screen } from '@testing-library/react'; -import { mockViewport, mockViewportForTestGroup } from '../../../src'; +import { mockViewport, mockViewportForTestGroup } from '../../../dist'; import { MockedMediaQueryListEvent } from '../../../src/mocks/MediaQueryListEvent'; import CustomUseMedia from '../components/viewport/custom-use-media/CustomUseMedia'; diff --git a/jest.config.ts b/jest.config.ts index e7815c3..ae6727e 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -8,6 +8,9 @@ module.exports = { tsconfig: { jsx: 'react-jsx', }, + diagnostics: { + warnOnly: true, + }, }, }, }; diff --git a/package-lock.json b/package-lock.json index d1f2333..a54a3d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "jsdom-testing-mocks", - "version": "1.4.0", + "version": "1.5.0-beta.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "jsdom-testing-mocks", - "version": "1.4.0", + "version": "1.5.0-beta.1", "license": "MIT", "dependencies": { + "bezier-easing": "^2.1.0", "css-mediaquery": "^0.1.2" }, "devDependencies": { @@ -38,8 +39,7 @@ "tslib": "^2.4.0", "tsup": "^6.1.3", "type-fest": "^2.15.1", - "typescript": "^4.7.4", - "vitest": "^0.16.0" + "typescript": "^4.7.4" }, "engines": { "node": ">=14" @@ -1466,21 +1466,6 @@ "@babel/types": "^7.3.0" } }, - "node_modules/@types/chai": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.1.tgz", - "integrity": "sha512-/zPMqDkzSZ8t3VtxOa4KPq7uzzW978M9Tvh+j7GHKuo6k6GTLxPJ4J5gE5cjfJ26pnXst0N5Hax8Sr0T2Mi9zQ==", - "dev": true - }, - "node_modules/@types/chai-subset": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.3.tgz", - "integrity": "sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==", - "dev": true, - "dependencies": { - "@types/chai": "*" - } - }, "node_modules/@types/css-mediaquery": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@types/css-mediaquery/-/css-mediaquery-0.1.1.tgz", @@ -2055,15 +2040,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/ast-types-flow": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", @@ -2228,6 +2204,11 @@ "dev": true, "license": "MIT" }, + "node_modules/bezier-easing": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", + "integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==" + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -2377,24 +2358,6 @@ "dev": true, "license": "CC-BY-4.0" }, - "node_modules/chai": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.6.tgz", - "integrity": "sha512-bbcp3YfHCUzMOvKqsztczerVgBKSsEijCySNlHHbX3VG1nskvqjz5Rfso1gGwD6w6oOV3eI60pKuMOV5MV7p3Q==", - "dev": true, - "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.2", - "deep-eql": "^3.0.1", - "get-func-name": "^2.0.0", - "loupe": "^2.3.1", - "pathval": "^1.1.1", - "type-detect": "^4.0.5" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/chalk": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", @@ -2421,15 +2384,6 @@ "node": ">=10" } }, - "node_modules/check-error": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", - "integrity": "sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/chokidar": { "version": "3.5.1", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz", @@ -2813,18 +2767,6 @@ "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", "dev": true }, - "node_modules/deep-eql": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", - "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", - "dev": true, - "dependencies": { - "type-detect": "^4.0.0" - }, - "engines": { - "node": ">=0.12" - } - }, "node_modules/deep-is": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", @@ -4106,15 +4048,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-func-name": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", - "integrity": "sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/get-intrinsic": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.2.tgz", @@ -5944,18 +5877,6 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/local-pkg": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.1.tgz", - "integrity": "sha512-lL87ytIGP2FU5PWwNDo0w3WhIo2gopIAxPg9RxDYF7m4rr5ahuZxP22xnJHIvaLTe4Z9P6uKKY2UHiwyB4pcrw==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -6009,15 +5930,6 @@ "loose-envify": "cli.js" } }, - "node_modules/loupe": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.4.tgz", - "integrity": "sha512-OvKfgCC2Ndby6aSTREl5aCCPTNIzlDfQZvZxNUrBrihDhL3xcrYegTblhmEiCrg2kKQz4XsFIaemE5BF4ybSaQ==", - "dev": true, - "dependencies": { - "get-func-name": "^2.0.0" - } - }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -6239,6 +6151,8 @@ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", "dev": true, + "optional": true, + "peer": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -6592,20 +6506,13 @@ "node": ">=8" } }, - "node_modules/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/picomatch": { "version": "2.3.1", @@ -6666,6 +6573,8 @@ "url": "https://tidelift.com/funding/github/npm/postcss" } ], + "optional": true, + "peer": true, "dependencies": { "nanoid": "^3.3.4", "picocolors": "^1.0.0", @@ -7166,6 +7075,8 @@ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7464,24 +7375,6 @@ "node": ">=0.8" } }, - "node_modules/tinypool": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.2.1.tgz", - "integrity": "sha512-HFU5ZYVq3wBfhSaf8qdqGsneaqXm0FgJQpoUlJbVdHpRLzm77IneKAD3RjzJWZvIv0YpPB9S7LUW53f6BE6ZSg==", - "dev": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-0.3.3.tgz", - "integrity": "sha512-gRiUR8fuhUf0W9lzojPf1N1euJYA30ISebSfgca8z76FOvXtVXqd5ojEIaKLWbDQhAaC3ibxZIjqbyi4ybjcTw==", - "dev": true, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -7896,104 +7789,6 @@ "node": ">=10.12.0" } }, - "node_modules/vite": { - "version": "2.9.13", - "resolved": "https://registry.npmjs.org/vite/-/vite-2.9.13.tgz", - "integrity": "sha512-AsOBAaT0AD7Mhe8DuK+/kE4aWYFMx/i0ZNi98hJclxb4e0OhQcZYUrvLjIaQ8e59Ui7txcvKMiJC1yftqpQoDw==", - "dev": true, - "dependencies": { - "esbuild": "^0.14.27", - "postcss": "^8.4.13", - "resolve": "^1.22.0", - "rollup": "^2.59.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": ">=12.2.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - }, - "peerDependencies": { - "less": "*", - "sass": "*", - "stylus": "*" - }, - "peerDependenciesMeta": { - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "stylus": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/rollup": { - "version": "2.75.7", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.75.7.tgz", - "integrity": "sha512-VSE1iy0eaAYNCxEXaleThdFXqZJ42qDBatAwrfnPlENEZ8erQ+0LYX4JXOLPceWfZpV1VtZwZ3dFCuOZiSyFtQ==", - "dev": true, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=10.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/vitest": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.16.0.tgz", - "integrity": "sha512-Ntp6jrM8wf2NMtamMBLkRBBdeqHkgAH/WMh5Xryts1j2ft2D8QZQbiSVFkSl4WmEQzcPP0YM069g/Ga1vtnEtg==", - "dev": true, - "dependencies": { - "@types/chai": "^4.3.1", - "@types/chai-subset": "^1.3.3", - "@types/node": "*", - "chai": "^4.3.6", - "debug": "^4.3.4", - "local-pkg": "^0.4.1", - "tinypool": "^0.2.1", - "tinyspy": "^0.3.3", - "vite": "^2.9.12" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": ">=v14.16.0" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@vitest/ui": "*", - "c8": "*", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@vitest/ui": { - "optional": true - }, - "c8": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, "node_modules/w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", @@ -9291,21 +9086,6 @@ "@babel/types": "^7.3.0" } }, - "@types/chai": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.1.tgz", - "integrity": "sha512-/zPMqDkzSZ8t3VtxOa4KPq7uzzW978M9Tvh+j7GHKuo6k6GTLxPJ4J5gE5cjfJ26pnXst0N5Hax8Sr0T2Mi9zQ==", - "dev": true - }, - "@types/chai-subset": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.3.tgz", - "integrity": "sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==", - "dev": true, - "requires": { - "@types/chai": "*" - } - }, "@types/css-mediaquery": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@types/css-mediaquery/-/css-mediaquery-0.1.1.tgz", @@ -9715,12 +9495,6 @@ "es-shim-unscopables": "^1.0.0" } }, - "assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "dev": true - }, "ast-types-flow": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", @@ -9848,6 +9622,11 @@ "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", "dev": true }, + "bezier-easing": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", + "integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==" + }, "binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -9950,21 +9729,6 @@ "integrity": "sha512-ZcijQNqrcF8JNLjzvEiXqX4JUYxoZa7Pvcsd9UD8Kz4TvhTonOSNRsK+qtvpVL4l6+T1Rh4LFtLfnNWg6BGWCQ==", "dev": true }, - "chai": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.6.tgz", - "integrity": "sha512-bbcp3YfHCUzMOvKqsztczerVgBKSsEijCySNlHHbX3VG1nskvqjz5Rfso1gGwD6w6oOV3eI60pKuMOV5MV7p3Q==", - "dev": true, - "requires": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.2", - "deep-eql": "^3.0.1", - "get-func-name": "^2.0.0", - "loupe": "^2.3.1", - "pathval": "^1.1.1", - "type-detect": "^4.0.5" - } - }, "chalk": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", @@ -9981,12 +9745,6 @@ "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true }, - "check-error": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", - "integrity": "sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==", - "dev": true - }, "chokidar": { "version": "3.5.1", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz", @@ -10290,15 +10048,6 @@ "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", "dev": true }, - "deep-eql": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", - "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", - "dev": true, - "requires": { - "type-detect": "^4.0.0" - } - }, "deep-is": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", @@ -11148,12 +10897,6 @@ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true }, - "get-func-name": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", - "integrity": "sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==", - "dev": true - }, "get-intrinsic": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.2.tgz", @@ -12501,12 +12244,6 @@ "integrity": "sha512-iyT2MXws+dc2Wi6o3grCFtGXpeMvHmJqS27sMPGtV2eUu4PeFnG+33I8BlFK1t1NWMjOpcx9bridn5yxLDX2gQ==", "dev": true }, - "local-pkg": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.1.tgz", - "integrity": "sha512-lL87ytIGP2FU5PWwNDo0w3WhIo2gopIAxPg9RxDYF7m4rr5ahuZxP22xnJHIvaLTe4Z9P6uKKY2UHiwyB4pcrw==", - "dev": true - }, "locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -12549,15 +12286,6 @@ "js-tokens": "^3.0.0 || ^4.0.0" } }, - "loupe": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.4.tgz", - "integrity": "sha512-OvKfgCC2Ndby6aSTREl5aCCPTNIzlDfQZvZxNUrBrihDhL3xcrYegTblhmEiCrg2kKQz4XsFIaemE5BF4ybSaQ==", - "dev": true, - "requires": { - "get-func-name": "^2.0.0" - } - }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -12719,7 +12447,9 @@ "version": "3.3.4", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "natural-compare": { "version": "1.4.0", @@ -12965,17 +12695,13 @@ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true }, - "pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", - "dev": true - }, "picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "picomatch": { "version": "2.3.1", @@ -13012,6 +12738,8 @@ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz", "integrity": "sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==", "dev": true, + "optional": true, + "peer": true, "requires": { "nanoid": "^3.3.4", "picocolors": "^1.0.0", @@ -13336,7 +13064,9 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "source-map-resolve": { "version": "0.6.0", @@ -13556,18 +13286,6 @@ "thenify": ">= 3.1.0 < 4" } }, - "tinypool": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.2.1.tgz", - "integrity": "sha512-HFU5ZYVq3wBfhSaf8qdqGsneaqXm0FgJQpoUlJbVdHpRLzm77IneKAD3RjzJWZvIv0YpPB9S7LUW53f6BE6ZSg==", - "dev": true - }, - "tinyspy": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-0.3.3.tgz", - "integrity": "sha512-gRiUR8fuhUf0W9lzojPf1N1euJYA30ISebSfgca8z76FOvXtVXqd5ojEIaKLWbDQhAaC3ibxZIjqbyi4ybjcTw==", - "dev": true - }, "tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -13843,47 +13561,6 @@ "convert-source-map": "^1.6.0" } }, - "vite": { - "version": "2.9.13", - "resolved": "https://registry.npmjs.org/vite/-/vite-2.9.13.tgz", - "integrity": "sha512-AsOBAaT0AD7Mhe8DuK+/kE4aWYFMx/i0ZNi98hJclxb4e0OhQcZYUrvLjIaQ8e59Ui7txcvKMiJC1yftqpQoDw==", - "dev": true, - "requires": { - "esbuild": "^0.14.27", - "fsevents": "~2.3.2", - "postcss": "^8.4.13", - "resolve": "^1.22.0", - "rollup": "^2.59.0" - }, - "dependencies": { - "rollup": { - "version": "2.75.7", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.75.7.tgz", - "integrity": "sha512-VSE1iy0eaAYNCxEXaleThdFXqZJ42qDBatAwrfnPlENEZ8erQ+0LYX4JXOLPceWfZpV1VtZwZ3dFCuOZiSyFtQ==", - "dev": true, - "requires": { - "fsevents": "~2.3.2" - } - } - } - }, - "vitest": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.16.0.tgz", - "integrity": "sha512-Ntp6jrM8wf2NMtamMBLkRBBdeqHkgAH/WMh5Xryts1j2ft2D8QZQbiSVFkSl4WmEQzcPP0YM069g/Ga1vtnEtg==", - "dev": true, - "requires": { - "@types/chai": "^4.3.1", - "@types/chai-subset": "^1.3.3", - "@types/node": "*", - "chai": "^4.3.6", - "debug": "^4.3.4", - "local-pkg": "^0.4.1", - "tinypool": "^0.2.1", - "tinyspy": "^0.3.3", - "vite": "^2.9.12" - } - }, "w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", diff --git a/package.json b/package.json index 49a8b45..54fb1db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jsdom-testing-mocks", - "version": "1.4.0", + "version": "1.5.0-beta.1", "author": "Ivan Galiatin", "license": "MIT", "description": "A set of tools for emulating browser behavior in jsdom environment", @@ -9,7 +9,6 @@ "url": "git+https://github.com/trurl-master/jsdom-testing-mocks.git" }, "keywords": [ - "react", "testing", "jsdom", "jest", @@ -18,7 +17,8 @@ "Intersection Observer API", "Web Animations API", "matchMedia", - "viewport" + "viewport", + "react" ], "main": "dist/index.js", "typings": "dist/index.d.ts", @@ -33,7 +33,6 @@ "start": "tsup --watch", "build": "tsup", "test": "jest", - "coverage": "vitest run --coverage", "lint": "eslint src/ --ext .ts,.tsx", "prepare": "tsup" }, @@ -67,6 +66,7 @@ "legacyOutput": true }, "dependencies": { + "bezier-easing": "^2.1.0", "css-mediaquery": "^0.1.2" }, "devDependencies": { @@ -96,7 +96,6 @@ "tslib": "^2.4.0", "tsup": "^6.1.3", "type-fest": "^2.15.1", - "typescript": "^4.7.4", - "vitest": "^0.16.0" + "typescript": "^4.7.4" } } diff --git a/src/mocks/web-animations-api/Animation.test.ts b/src/mocks/web-animations-api/Animation.test.ts deleted file mode 100644 index 211c4c9..0000000 --- a/src/mocks/web-animations-api/Animation.test.ts +++ /dev/null @@ -1,787 +0,0 @@ -import { MockedAnimation } from './Animation'; -import { expectTime, wait } from './testHelpers'; - -describe('Animation', () => { - describe('constructor', () => { - it('should be defined', () => { - const animation = new Animation(); - - expect(animation).toBeInstanceOf(MockedAnimation); - }); - - it('should have correct properties if no keyframe effect is provided', async () => { - const animation = new Animation(); - - expect(animation.currentTime).toBeNull(); - expect(animation.effect).toBeNull(); - expect(animation.finished).toBeInstanceOf(Promise); - expect(animation.id).toBe(''); - expect(animation.oncancel).toBeNull(); - expect(animation.onfinish).toBeNull(); - expect(animation.onremove).toBeNull(); - expect(animation.pending).toBe(false); - expect(animation.playState).toBe('idle'); - expect(animation.playbackRate).toBe(1); - expect(await animation.ready).toBe(animation); - expect(animation.replaceState).toBe('active'); - expect(animation.startTime).toBeNull(); - expect(animation.timeline).toBeInstanceOf(AnimationTimeline); - }); - - it('should have correct properties if finite keyframe effect is provided', async () => { - const element = document.createElement('div'); - - const effect = new KeyframeEffect( - element, - [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], - { - duration: 3000, - fill: 'forwards', - iterations: 2, - delay: 100, - endDelay: 300, - } - ); - const animation = new Animation(effect); - - expect(animation.currentTime).toBeNull(); - expect(animation.effect).toBe(effect); - expect(animation.finished).toBeInstanceOf(Promise); - expect(animation.id).toBe(''); - expect(animation.oncancel).toBeNull(); - expect(animation.onfinish).toBeNull(); - expect(animation.onremove).toBeNull(); - expect(animation.pending).toBe(false); - expect(animation.playState).toBe('idle'); - expect(animation.playbackRate).toBe(1); - expect(await animation.ready).toBe(animation); - expect(animation.replaceState).toBe('active'); - expect(animation.startTime).toBeNull(); - expect(animation.timeline).toBeInstanceOf(AnimationTimeline); - }); - }); - - describe('currentTime', () => { - it('should be null if no keyframe effect is provided', () => { - const animation = new Animation(); - - expect(animation.currentTime).toBeNull(); - }); - - it('should be null if infinite keyframe effect is provided', async () => { - const element = document.createElement('div'); - - const effect = new KeyframeEffect( - element, - [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], - { duration: 100, iterations: Infinity } - ); - - const animation = new Animation(effect); - - expect(animation.currentTime).toBeNull(); - }); - }); - - describe('pause', () => { - test('during delay', async () => { - const element = document.createElement('div'); - - const effect = new KeyframeEffect( - element, - [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], - { - duration: 200, - fill: 'forwards', - delay: 100, - } - ); - - const animation = new Animation(effect); - - animation.play(); - - await wait(50); - - animation.pause(); - expect(animation.playState).toBe('paused'); - expectTime(animation.currentTime, 50); - - await wait(50); - - expect(animation.playState).toBe('paused'); - expectTime(animation.currentTime, 50); - - animation.play(); - expect(animation.playState).toBe('running'); - - await wait(50); - expectTime(animation.currentTime, 100); - - await animation.finished; - - expectTime(animation.currentTime, 300); - }); - - test('during active', async () => { - const element = document.createElement('div'); - - const effect = new KeyframeEffect( - element, - [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], - { - duration: 200, - fill: 'forwards', - delay: 100, - } - ); - - const animation = new Animation(effect); - - animation.play(); - - await wait(150); - - animation.pause(); - expect(animation.playState).toBe('paused'); - expectTime(animation.currentTime, 150); - - await wait(50); - - expect(animation.playState).toBe('paused'); - expectTime(animation.currentTime, 150); - - animation.play(); - expect(animation.playState).toBe('running'); - - await animation.finished; - - expectTime(animation.currentTime, 300); - }); - - test('during active', async () => { - const element = document.createElement('div'); - - const effect = new KeyframeEffect( - element, - [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], - { - duration: 200, - fill: 'forwards', - delay: 100, - endDelay: 100, - } - ); - - const animation = new Animation(effect); - - animation.play(); - - await wait(350); - - animation.pause(); - expect(animation.playState).toBe('paused'); - expectTime(animation.currentTime, 350); - - await wait(50); - - expect(animation.playState).toBe('paused'); - expectTime(animation.currentTime, 350); - - animation.play(); - expect(animation.playState).toBe('running'); - - await animation.finished; - - expectTime(animation.currentTime, 400); - }); - }); - - describe('cancel', () => { - it('it doesn\'t cancel if state is "idle"', () => { - const element = document.createElement('div'); - - const effect = new KeyframeEffect( - element, - { transform: 'translateX(100px)' }, - 200 - ); - - const animation = new Animation(effect); - - const finishedPromise = animation.finished; - - animation.cancel(); - animation.play(); - - expect(animation.playState).toBe('running'); - expect(finishedPromise === animation.finished).toBe(true); - }); - - it('rejects the finished promise with an error, if state is "running"', (done) => { - const element = document.createElement('div'); - - const effect = new KeyframeEffect( - element, - { transform: 'translateX(100px)' }, - 200 - ); - - const animation = new Animation(effect); - - animation.play(); - - const initialFinishedPromise = animation.finished; - - animation.finished.catch((error: unknown) => { - expect(error).toBeInstanceOf(Error); - - if (error instanceof Error) { - expect(error.message).toEqual( - 'DOMException: The user aborted a request.' - ); - } - - expect(animation.playState).toBe('idle'); - expect(animation.currentTime).toBeNull(); - expect(animation.finished !== initialFinishedPromise).toBe(true); - - done(); - }); - - animation.cancel(); - }); - }); - - describe('finish', () => { - it('throws an InvalidStateError when finishing an infinite animation', () => { - const element = document.createElement('div'); - - const effect = new KeyframeEffect( - element, - [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], - { duration: 100, iterations: Infinity } - ); - - const animation = new Animation(effect); - - expect(() => animation.finish()).toThrowError( - "Failed to execute 'finish' on 'Animation': Cannot finish Animation with an infinite target effect end." - ); - }); - }); - - describe('commitStyles', () => { - describe('fill, 1 keyframe', () => { - it('none', async () => { - const element = document.createElement('div'); - - const effect = new KeyframeEffect( - element, - { transform: 'translateX(100px)' }, - { - delay: 100, - duration: 100, - endDelay: 100, - // fill defaults to "auto", which is "none" - } - ); - - const animation = new Animation(effect); - - expect(element.style.transform).toBe(''); - - animation.play(); - - // delay - expect(element.style.transform).toBe(''); - - await wait(50); - - expect(element.style.transform).toBe(''); - - // delay -> active - await wait(100); - - expect(element.style.transform).toBe(''); - - // active -> endDelay - await wait(100); - - expect(element.style.transform).toBe(''); - - await animation.finished; - - expect(element.style.transform).toBe(''); - }); - - it('backwards', async () => { - const element = document.createElement('div'); - - const effect = new KeyframeEffect( - element, - { transform: 'translateX(100px)' }, - { - delay: 100, - duration: 100, - endDelay: 100, - fill: 'backwards', - } - ); - - const animation = new Animation(effect); - - expect(element.style.transform).toBe(''); - - animation.play(); - - // delay - expect(element.style.transform).toBe(''); - - await wait(50); - - expect(element.style.transform).toBe(''); - - // delay -> active - await wait(100); - - expect(element.style.transform).toBe(''); - - // active -> endDelay - await wait(100); - - expect(element.style.transform).toBe(''); - - await animation.finished; - - expect(element.style.transform).toBe(''); - }); - - it('forwards', async () => { - const element = document.createElement('div'); - - const effect = new KeyframeEffect( - element, - { transform: 'translateX(100px)' }, - { - delay: 100, - duration: 100, - endDelay: 100, - fill: 'forwards', - } - ); - - const animation = new Animation(effect); - - expect(element.style.transform).toBe(''); - - animation.play(); - - // delay - expect(element.style.transform).toBe(''); - - await wait(50); - - expect(element.style.transform).toBe(''); - - // delay -> active - await wait(100); - - expect(element.style.transform).toBe(''); - - // active -> endDelay - await wait(100); - - expect(element.style.transform).toBe('translateX(100px)'); - - await animation.finished; - - expect(element.style.transform).toBe('translateX(100px)'); - }); - - it('both', async () => { - const element = document.createElement('div'); - - const effect = new KeyframeEffect( - element, - { transform: 'translateX(100px)' }, - { - delay: 100, - duration: 100, - endDelay: 100, - fill: 'both', - } - ); - - const animation = new Animation(effect); - - expect(element.style.transform).toBe(''); - - animation.play(); - - // delay - expect(element.style.transform).toBe(''); - - await wait(50); - - expect(element.style.transform).toBe(''); - - // delay -> active - await wait(100); - - expect(element.style.transform).toBe(''); - - // active -> endDelay - await wait(100); - - expect(element.style.transform).toBe('translateX(100px)'); - - await animation.finished; - - expect(element.style.transform).toBe('translateX(100px)'); - }); - }); - - describe('fill, 2+ keyframes', () => { - it('none', async () => { - const element = document.createElement('div'); - - const effect = new KeyframeEffect( - element, - [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], - { - delay: 100, - duration: 100, - endDelay: 100, - // fill defaults to "auto", which is "none" - } - ); - - const animation = new Animation(effect); - - expect(element.style.transform).toBe(''); - - animation.play(); - - // delay - expect(element.style.transform).toBe(''); - - await wait(50); - - expect(element.style.transform).toBe(''); - - // delay -> active - await wait(100); - - expect(element.style.transform).toBe('translateX(0)'); - - // active -> endDelay - await wait(100); - - expect(element.style.transform).toBe(''); - - await animation.finished; - - expect(element.style.transform).toBe(''); - }); - - it('backwards', async () => { - const element = document.createElement('div'); - - const effect = new KeyframeEffect( - element, - [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], - { - delay: 100, - duration: 100, - endDelay: 100, - fill: 'backwards', - } - ); - - const animation = new Animation(effect); - - expect(element.style.transform).toBe(''); - - animation.play(); - - // delay - expect(element.style.transform).toBe('translateX(0)'); - - await wait(50); - - expect(element.style.transform).toBe('translateX(0)'); - - // delay -> active - await wait(100); - - expect(element.style.transform).toBe('translateX(0)'); - - // active -> endDelay - await wait(100); - - expect(element.style.transform).toBe(''); - - await animation.finished; - - expect(element.style.transform).toBe(''); - }); - - it('forwards', async () => { - const element = document.createElement('div'); - - const effect = new KeyframeEffect( - element, - [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], - { - delay: 100, - duration: 100, - endDelay: 100, - fill: 'forwards', - } - ); - - const animation = new Animation(effect); - - expect(element.style.transform).toBe(''); - - animation.play(); - - // delay - expect(element.style.transform).toBe(''); - - await wait(50); - - expect(element.style.transform).toBe(''); - - // delay -> active - await wait(100); - - expect(element.style.transform).toBe('translateX(0)'); - - // active -> endDelay - await wait(100); - - expect(element.style.transform).toBe('translateX(100px)'); - - await animation.finished; - - expect(element.style.transform).toBe('translateX(100px)'); - }); - - it('both', async () => { - const element = document.createElement('div'); - - const effect = new KeyframeEffect( - element, - [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], - { - delay: 100, - duration: 100, - endDelay: 100, - fill: 'both', - } - ); - - const animation = new Animation(effect); - - expect(element.style.transform).toBe(''); - - animation.play(); - - // delay - expect(element.style.transform).toBe('translateX(0)'); - - await wait(50); - - expect(element.style.transform).toBe('translateX(0)'); - - // delay -> active - await wait(100); - - expect(element.style.transform).toBe('translateX(0)'); - - // active -> endDelay - await wait(100); - - expect(element.style.transform).toBe('translateX(100px)'); - - await animation.finished; - - expect(element.style.transform).toBe('translateX(100px)'); - }); - }); - }); - - describe('effect', () => { - it('should calculate computed timing', () => { - const element = document.createElement('div'); - - const effect = new KeyframeEffect( - element, - [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], - { - duration: 3000, - fill: 'forwards', - iterations: 2, - delay: 100, - endDelay: 300, - } - ); - const animation = new Animation(effect); - - expect(animation.effect?.getComputedTiming()).toEqual({ - activeDuration: 6000, - currentIteration: null, - delay: 100, - direction: 'normal', - duration: 3000, - easing: 'linear', - endDelay: 300, - endTime: 6400, - fill: 'forwards', - iterationStart: 0, - iterations: 2, - localTime: null, - progress: null, - }); - }); - - describe('should calculate localTime and progress correctly', () => { - it('when just created', () => { - const element = document.createElement('div'); - - const effect = new KeyframeEffect( - element, - [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], - { - duration: 3000, - fill: 'forwards', - iterations: 2, - delay: 100, - endDelay: 300, - } - ); - const animation = new Animation(effect); - - expect(animation.effect?.getComputedTiming()).toEqual( - expect.objectContaining({ - localTime: null, - progress: null, - }) - ); - }); - - it('when running', async () => { - const element = document.createElement('div'); - - const effect = new KeyframeEffect( - element, - [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], - { - duration: 200, - fill: 'forwards', - iterations: 2, - delay: 100, - } - ); - - const animation = new Animation(effect); - - animation.play(); - - await wait(50); - - expectTime(animation.currentTime, 50); - - await wait(50); - - expectTime(animation.currentTime, 100); - - // first iteration starts, progress should be 0, localTime should be equal to "delay" - expect(animation.effect?.getComputedTiming().progress).toBeCloseTo( - 0, - 1 - ); - expectTime(animation.effect?.getComputedTiming().localTime, 100); - - // 100ms after that we're still in the first iteration - // progress should be 0.5, localTime should be equal to "delay" + "duration" / 2 - await wait(100); - - expect(animation.effect?.getComputedTiming().progress).toBeCloseTo( - 0.5, - 1 - ); - expectTime(animation.effect?.getComputedTiming().localTime, 200); - - // 200ms after that we're in the middle of the second iteration - // progress should be 0.5, localTime should be "delay" + "duration" + "duration" / 2 - await wait(200); - - expect(animation.effect?.getComputedTiming().progress).toBeCloseTo( - 0.5, - 1 - ); - expectTime(animation.effect?.getComputedTiming().localTime, 400); - - await animation.finished; - - expectTime(animation.currentTime, 500); - }); - }); - }); - - describe('events', () => { - it('should fire finish events', async () => { - const element = document.createElement('div'); - - const effect = new KeyframeEffect( - element, - { transform: 'translateX(0)' }, - 100 - ); - - const animation = new Animation(effect); - - const onfinish = jest.fn(); - - animation.onfinish = onfinish; - animation.addEventListener('finish', onfinish); - - animation.play(); - - await animation.finished; - - expect(onfinish).toHaveBeenCalledTimes(2); - }); - - it('should fire cancel events', (done) => { - const element = document.createElement('div'); - - const effect = new KeyframeEffect( - element, - { transform: 'translateX(0)' }, - 100 - ); - - const animation = new Animation(effect); - - const oncancel = jest.fn(); - - animation.oncancel = oncancel; - animation.addEventListener('cancel', oncancel); - - animation.play(); - - animation.finished.catch(() => { - expect(oncancel).toHaveBeenCalledTimes(2); - done(); - }); - - wait(50).then(() => { - animation.cancel(); - }); - }); - }); -}); diff --git a/src/mocks/web-animations-api/Animation.ts b/src/mocks/web-animations-api/Animation.ts index 30546c3..1fc4b41 100644 --- a/src/mocks/web-animations-api/Animation.ts +++ b/src/mocks/web-animations-api/Animation.ts @@ -1,7 +1,12 @@ import './AnimationEffect'; import './KeyframeEffect'; import './AnimationPlaybackEvent'; -import './AnimationTimeline'; +import './DocumentTimeline'; +import { getEasingFunctionFromString } from './easingFunctions'; + +type ActiveAnimationTimeline = AnimationTimeline & { + currentTime: NonNullable; +}; type ComputedKeyframeNonStylePropNames = | 'composite' @@ -17,12 +22,13 @@ type ComputedKeyframeStyleProps = Omit< type ComputedKeyframeWithOptionalNonStyleProps = ComputedKeyframeStyleProps & Partial>; -class InvalidStateError extends Error { - constructor(message: string) { - super(message); - this.name = 'InvalidStateError'; - } -} +type DefinedEffectTiming = Required; + +type DefinedComputedEffectTiming = Required< + Omit +> & { + duration: number; +}; export const NON_STYLE_KEYFRAME_PROPERTIES = ['offset', 'composite', 'easing']; export const RENAMED_KEYFRAME_PROPERTIES: { @@ -32,90 +38,149 @@ export const RENAMED_KEYFRAME_PROPERTIES: { cssOffset: 'offset', }; -let durationMultiplier = 1; - -function setDurationMultiplier(multiplier: number) { - durationMultiplier = multiplier; -} +// eslint-disable-next-line @typescript-eslint/no-empty-function +const noop = () => {}; +/** + * Implements https://www.w3.org/TR/web-animations-1 + * + * With the following differences: + * - There's no style interpolation + * - The implementation is based on requestAnimationFrame + */ class MockedAnimation extends EventTarget implements Animation { - // effect: AnimationEffect | null = null; - finished: Promise; id = ''; readonly pending = false; - playState: AnimationPlayState = 'idle'; - playbackRate = 1; - readonly ready = Promise.resolve(this); readonly replaceState = 'active'; - startTime: CSSNumberish | null = null; - timeline: AnimationTimeline | null; // implementation details + #finishedPromise: Promise; + #readyPromise: Promise; + #startTime: CSSNumberish | null = null; + #pendingPauseTask: (() => void) | null = null; + #pendingPlayTask: (() => void) | null = null; + #previousCurrentTime: number | null = null; + #previousPhase: 'before' | 'active' | 'after' | 'idle' = 'idle'; #effect: AnimationEffect | null = null; + #timeline: AnimationTimeline | null = null; + #rafId: number | null = null; #initialKeyframe: ComputedKeyframeStyleProps; #fillMode: Omit; - #resolve: ((value: Animation | PromiseLike) => void) | null = null; - #reject: ((reason: Error) => void) | null = null; - #timeout: NodeJS.Timeout | null = null; - #pauseTime: number | null = null; - #pausedTime = { - delay: 0, - active: 0, - endDelay: 0, + #promiseStates: { + finished: 'pending' | 'resolved' | 'rejected'; + ready: 'pending' | 'resolved' | 'rejected'; + } = { + finished: 'pending', + ready: 'resolved', }; - #phase: 'delay' | 'active' | 'endDelay' = 'delay'; + #resolvers: { + ready: { + resolve: (value: Animation | PromiseLike) => void; + reject: (reason: Error) => void; + }; + finished: { + resolve: (value: Animation | PromiseLike) => void; + reject: (reason: Error) => void; + }; + } = { + ready: { + resolve: noop, + reject: noop, + }, + finished: { + resolve: noop, + reject: noop, + }, + }; + #getRawComputedTiming: () => Omit< + ComputedEffectTiming, + 'localTime' | 'progress' + > = () => ({}); + #pendingPlaybackRate: number | null = null; + #playbackRate = 1; + #holdTime: number | null = null; constructor( effect: AnimationEffect | null = null, - timeline: AnimationTimeline = new AnimationTimeline() + timeline: AnimationTimeline = document.timeline ) { super(); - this.#effect = effect; - this.timeline = timeline; + this.effect = effect; + this.#timeline = timeline; this.#initialKeyframe = this.#calcInitialKeyframe(); this.#fillMode = effect?.getComputedTiming().fill ?? 'none'; + this.#finishedPromise = this.#getNewFinishedPromise(); + this.#readyPromise = Promise.resolve(this); + } - if (effect) { - const originalGetComputedTiming = effect.getComputedTiming; - - effect.getComputedTiming = () => { - const computedTiming = originalGetComputedTiming.call(effect); - const computedDelay = computedTiming.delay ?? 0; - const localTime = this.currentTime; + #getTiming() { + return this.#effect!.getTiming() as DefinedEffectTiming; + } - return { - ...computedTiming, - localTime, - progress: - // diration should always be a number here, there's an error with types (i think) - this.currentTime && typeof computedTiming.duration === 'number' - ? ((this.currentTime - computedDelay) / computedTiming.duration) % - 1 - : null, - }; - }; + #getComputedTiming() { + return this.#getRawComputedTiming.call( + this.effect + ) as DefinedComputedEffectTiming; + } - if (effect.getComputedTiming().delay === 0) { - this.#phase = 'active'; - } + get #localTime() { + // The local time of an animation effect at a given moment is based on the first matching condition from the following: + // If the animation effect is associated with an animation, + // the local time is the current time of the animation. + // Otherwise, + // the local time is unresolved. + if (this.#effect !== null) { + return this.currentTime; } - this.finished = this.#getNewFinished(); + return null; } - #getNewFinished() { + #getNewFinishedPromise() { + this.#promiseStates.finished = 'pending'; + return new Promise((resolve, reject) => { - this.#resolve = resolve; - this.#reject = reject; + this.#resolvers.finished.resolve = (animation) => { + this.#promiseStates.finished = 'resolved'; + resolve(animation); + }; + this.#resolvers.finished.reject = (error) => { + this.#promiseStates.finished = 'rejected'; + reject(error); + }; }); } - #clearTimeout() { - if (this.#timeout) { - clearTimeout(this.#timeout as NodeJS.Timeout); - this.#timeout = null; - } + #getNewReadyPromise() { + this.#promiseStates.ready = 'pending'; + + return new Promise((resolve, reject) => { + this.#resolvers.ready.resolve = (animation) => { + this.#promiseStates.ready = 'resolved'; + resolve(animation); + }; + this.#resolvers.ready.reject = (error) => { + this.#promiseStates.ready = 'rejected'; + reject(error); + }; + }); + } + + #hasPendingTask() { + return this.#pendingPauseTask || this.#pendingPlayTask; + } + + #isTimelineActive(): this is { timeline: ActiveAnimationTimeline } { + return this.#timeline?.currentTime !== null; + } + + #hasKeyframeEffect(): this is { effect: KeyframeEffect } { + return this.#effect instanceof KeyframeEffect; + } + + #isTimelineMonotonicallyIncreasing() { + return this.#timeline instanceof DocumentTimeline; } #calcInitialKeyframe() { @@ -151,353 +216,1476 @@ class MockedAnimation extends EventTarget implements Animation { return initialKeyframe; } - #flushPausedTime() { - if (this.playState !== 'paused' || this.#pauseTime === null) { - return; + #applyPendingPlaybackRate() { + if (this.#pendingPlaybackRate !== null) { + this.#playbackRate = this.#pendingPlaybackRate; + this.#pendingPlaybackRate = null; } + } - const now = performance.now(); - const timeDiff = now - this.#pauseTime; + // ‘backwards’ if the effect is associated with an animation and the associated animation’s playback rate is less than zero; in all other cases, the animation direction is ‘forwards’. + get #animationDirection() { + return this.#effect !== null && this.playbackRate < 0 + ? 'backwards' + : 'forwards'; + } - switch (this.#phase) { - case 'delay': - this.#pausedTime.delay += timeDiff; - break; - case 'active': - this.#pausedTime.active += timeDiff; - break; - case 'endDelay': - this.#pausedTime.endDelay += timeDiff; - break; + // An animation effect is in the before phase if the animation effect’s local time is not unresolved and either of the following conditions are met: + // the local time is less than the before-active boundary time, or + // the animation direction is ‘backwards’ and the local time is equal to the before-active boundary time. + // An animation effect is in the after phase if the animation effect’s local time is not unresolved and either of the following conditions are met: + // the local time is greater than the active-after boundary time, or + // the animation direction is ‘forwards’ and the local time is equal to the active-after boundary time. + // An animation effect is in the active phase if the animation effect’s local time is not unresolved and it is not in either the before phase nor the after phase. + // Furthermore, it is often convenient to refer to the case when an animation effect is in none of the above phases as being in the idle phase. + get #phase() { + const localTime = this.#localTime; + + if (localTime === null) { + return 'idle'; } - this.#pauseTime = now; + const { delay, activeDuration, endTime } = this.#getComputedTiming(); + + const beforeActiveBoundaryTime = Math.max(Math.min(delay, endTime), 0); + const activeAfterBoundaryTime = Math.max( + Math.min(delay + activeDuration, endTime), + 0 + ); + + if ( + localTime < beforeActiveBoundaryTime || + (this.#animationDirection === 'backwards' && + localTime === beforeActiveBoundaryTime) + ) { + return 'before'; + } + + if ( + localTime > activeAfterBoundaryTime || + (this.#animationDirection === 'forwards' && + localTime === activeAfterBoundaryTime) + ) { + return 'after'; + } + + return 'active'; } - get effect() { - return this.#effect; + // An animation effect is in play if all of the following conditions are met: + // the animation effect is in the active phase, and + // the animation effect is associated with an animation that is not finished. + get animationEffectStateInPlay() { + return this.#phase === 'active' && this.playState !== 'finished'; } - set effect(effect: AnimationEffect | null) { - const oldEffect = this.#effect; - this.#effect = effect; + // An animation effect is current if any of the following conditions are true: + // the animation effect is in play, or + // the animation effect is associated with an animation with a playback rate > 0 and the animation effect is in the before phase, or + // the animation effect is associated with an animation with a playback rate < 0 and the animation effect is in the after phase. + get animationEffectStateCurrent() { + const phase = this.#phase; + + return ( + this.animationEffectStateInPlay || + (this.playbackRate > 0 && phase === 'before') || + (this.playbackRate < 0 && phase === 'after') + ); + } - if (oldEffect && !effect) { - console.log( - 'DeveloperSuckError: Removing the effect is not implemented yet' - ); - return; - } + // An animation effect is in effect if its active time, as calculated according to the procedure in §4.8.3.1 Calculating the active time, is not unresolved. + get animationEffectStateInEffect() { + return this.#activeTime !== null; } - get currentTime(): CSSNumberish | null { - this.#flushPausedTime(); + get finished() { + return this.#finishedPromise; + } - const totalPausedTime = - this.#pausedTime.delay + - this.#pausedTime.active + - this.#pausedTime.endDelay; + get ready() { + return this.#readyPromise; + } - return this.startTime - ? (performance.now() - (this.startTime as number) - totalPausedTime) / - durationMultiplier - : null; + get timeline() { + return this.#timeline; } - set currentTime(time: number | null) { - if ( - this.playState !== 'idle' && - this.currentTime !== null && - time === null - ) { - throw new Error( - "Failed to set the 'currentTime' property on 'Animation': currentTime may not be changed from resolved to unresolved" - ); + // 4.4.1. Setting the timeline of an animation + set timeline(timeline: AnimationTimeline | null) { + if (this.#timeline === timeline) { + return; } - // this.clearTimeout(); + this.#timeline = timeline; - // this.playState = "idle"; - // this.startTime = null; + if (this.startTime !== null) { + this.#holdTime = null; + } - // this.play(); + this.#updateFinishedState(false, false); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - oncancel: ((this: Animation, ev: AnimationPlaybackEvent) => any) | null = - null; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onfinish: ((this: Animation, ev: AnimationPlaybackEvent) => any) | null = - null; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onremove: ((this: Animation, ev: Event) => any) | null = null; + get effect() { + return this.#effect; + } - cancel() { - if (this.playState === 'idle') { + // 4.4.2. Setting the associated effect of an animation + set effect(effect: AnimationEffect | null) { + // 1. Let old effect be the current associated effect of animation, if any. + const oldEffect = this.#effect; + + // 2. If new effect is the same object as old effect, abort this procedure. + if (effect === oldEffect) { return; } - this.playState = 'idle'; - this.startTime = null; - this.#pauseTime = null; - this.#pausedTime = { - delay: 0, - active: 0, - endDelay: 0, - }; - this.#phase = 'delay'; + // 3. If animation has a pending pause task, reschedule that task to run as soon as animation is ready. + if (this.#pendingPauseTask) { + this.ready.then(() => this.#pendingPauseTask?.()); + } - const cancelEvent = new AnimationPlaybackEvent('cancel'); + // 4. If animation has a pending play task, reschedule that task to run as soon as animation is ready to play new effect. + if (this.#pendingPlayTask) { + this.ready.then(() => this.#pendingPlayTask?.()); + } - this.#clearTimeout(); - this.oncancel?.call(this, cancelEvent); - this.dispatchEvent(cancelEvent); - this.#reject?.(new Error('DOMException: The user aborted a request.')); - this.finished = this.#getNewFinished(); - } + // 5. If new effect is not null and if new effect is the associated effect of another animation, previous animation, run the procedure to set the associated effect of an animation (this procedure) on previous animation passing null as new effect. + if (effect) { + const anotherAnimation = document + .getAnimations() + .find((anim) => anim.effect === effect); - #commitKeyframeStyles(keyframe: ComputedKeyframeWithOptionalNonStyleProps) { - if (!(this.#effect instanceof KeyframeEffect)) { - return; + if (anotherAnimation) { + anotherAnimation.effect = null; + } } - const element = this.#effect.target as HTMLElement; - const { composite, computedOffset, easing, offset, ...keyframeStyles } = - keyframe; + // 6. Let the associated effect of animation be new effect. + this.#effect = effect; - for (const property in keyframeStyles) { - const value = keyframeStyles[property]; + // 7. Run the procedure to update an animation’s finished state for animation with the did seek flag set to false, and the synchronously notify flag set to false. + this.#updateFinishedState(false, false); - if (typeof value === 'undefined' || value === null) { - element.style.removeProperty(property); - continue; - } + if (effect) { + this.#getRawComputedTiming = effect.getComputedTiming; - const valueAsString = - typeof value === 'string' ? value : value.toString(); + effect.getComputedTiming = () => { + const computedTiming = this.#getRawComputedTiming.call(effect); - element.style.setProperty(property, valueAsString); + return { + ...computedTiming, + localTime: this.#localTime, + progress: this.#transformedProgress, + }; + }; } } - // this mock doesn't calculate intermediate styles, - // only the ones defined in keyframes - commitStyles() { - if (!(this.#effect instanceof KeyframeEffect)) { + #resetPendingTasks() { + // 1. If animation does not have a pending play task or a pending pause task, abort this procedure. + if (!this.#hasPendingTask()) { return; } - const keyframes = this.#effect.getKeyframes(); - const currentProgress = this.#effect.getComputedTiming().progress; + // 2. If animation has a pending play task, cancel that task. + if (this.#pendingPlayTask) { + this.#pendingPlayTask = null; + } - if (keyframes.length === 0 || !currentProgress || !this.#effect.target) { - return; + // 3. If animation has a pending pause task, cancel that task. + if (this.#pendingPauseTask) { + this.#pendingPauseTask = null; } - // find the keyframe closest to the current progress - let closestKeyframe: ComputedKeyframe = keyframes[0]; - let smallestDistance = Infinity; + // 4. Apply any pending playback rate on animation. + this.#applyPendingPlaybackRate(); - for (const keyframe of keyframes) { - const distance = Math.abs(keyframe.computedOffset - currentProgress); + // 5. Reject animation’s current ready promise with a DOMException named "AbortError". + this.#resolvers.ready.reject(new DOMException('AbortError')); - if (distance < smallestDistance) { - smallestDistance = distance; - closestKeyframe = keyframe; + // 6. Set the [[PromiseIsHandled]] internal slot of animation’s current ready promise to true. + + // 7. Let animation’s current ready promise be the result of creating a new resolved Promise object with value animation in the relevant Realm of animation. + this.#readyPromise = Promise.resolve(this); + } + + #calculateCurrentTime() { + if ( + !this.#timeline || + this.#timeline.currentTime === null || + this.startTime === null + ) { + return null; + } else { + return (this.#timeline.currentTime - this.startTime) * this.playbackRate; + } + } + + #calculateStartTime(seekTime: number) { + let startTime = null; + + if (this.#timeline) { + const timelineTime = this.#timeline.currentTime; + + if (timelineTime !== null) { + startTime = timelineTime - seekTime / this.playbackRate; } } - this.#commitKeyframeStyles(closestKeyframe); + return startTime; } - #finish() { - const finishEvent = new AnimationPlaybackEvent('finish'); - this.onfinish?.call(this, finishEvent); - this.dispatchEvent(finishEvent); - this.playState = 'finished'; - this.#resolve?.(this); + #getCurrentTimeInternal() { + return this.#holdTime !== null + ? this.#holdTime + : this.#calculateCurrentTime(); } - finish() { - if (!(this.#effect instanceof KeyframeEffect)) { - return; - } + // The effective playback rate of an animation is its pending playback rate, if set, otherwise it is the animation’s playback rate. + get #effectivePlaybackRate() { + return this.#pendingPlaybackRate ?? this.playbackRate; + } - if (this.#effect?.getComputedTiming().iterations === Infinity) { - throw new InvalidStateError( - "Failed to execute 'finish' on 'Animation': Cannot finish Animation with an infinite target effect end." - ); + // https://www.w3.org/TR/web-animations-1/#the-current-time-of-an-animation + // 4.4.3. The current time of an animation + get currentTime(): CSSNumberish | null { + return this.#getCurrentTimeInternal(); + } + + // https://www.w3.org/TR/web-animations-1/#setting-the-current-time-of-an-animation + // 4.4.4. Setting the current time of an animation + set currentTime(seekTime: number | null) { + // 1. Run the steps to silently set the current time of animation to seek time. + this.#setCurrentTimeSilent(seekTime); + + // 2. If animation has a pending pause task, synchronously complete the pause operation by performing the following steps: + if (this.#pendingPauseTask) { + // 2.1 Set animation’s hold time to seek time. + this.#holdTime = seekTime; + // 2.2 Apply any pending playback rate to animation. + this.#applyPendingPlaybackRate(); + // 2.3 Make animation’s start time unresolved. + this.#startTime = null; + // 2.4 Cancel the pending pause task. + this.#pendingPauseTask = null; + // 2.5 Resolve animation’s current ready promise with animation. + this.#resolvers.ready.resolve(this); } - const computedEndDelay = - (this.#effect?.getComputedTiming().endDelay ?? 0) - - this.#pausedTime.endDelay; - - if (computedEndDelay > 0) { - switch (this.#fillMode) { - case 'none': - case 'backwards': - this.#commitKeyframeStyles(this.#initialKeyframe); - break; - case 'forwards': - case 'both': - { - const keyframes = this.#effect.getKeyframes(); - this.#commitKeyframeStyles(keyframes[keyframes.length - 1]); - } - break; + // 3. Run the procedure to update an animation’s finished state for animation with the did seek flag set to true, and the synchronously notify flag set to false. + this.#updateFinishedState(true, false); + } + + #setCurrentTimeSilent(seekTime: number | null) { + // 1. If seek time is an unresolved time value, then perform the following steps. + if (seekTime === null) { + if (this.currentTime !== null) { + throw new TypeError( + "Failed to set the 'currentTime' property on 'Animation': currentTime may not be changed from resolved to unresolved" + ); } - this.#timeout = setTimeout( - () => this.#finish(), - computedEndDelay * durationMultiplier - ); - this.#phase = 'endDelay'; + return; + } + + const startTime = this.startTime; + const holdTime = this.#holdTime; + + // 2. Update either animation’s hold time or start time as follows: + // 3. If animation has no associated timeline or the associated timeline is inactive, make animation’s start time unresolved. + if ( + holdTime || + startTime === null || + this.#timeline === null || + this.#timeline.currentTime === null || + this.playbackRate == 0 + ) { + this.#holdTime = seekTime; } else { - this.#finish(); + this.startTime = this.#calculateStartTime(seekTime); } - } - pause() { - this.playState = 'paused'; - this.#clearTimeout(); - this.#pauseTime = performance.now(); + // 4. Make animation’s previous current time unresolved. + this.#previousCurrentTime = null; } - persist() { - console.log("persist isn't implemented yet"); + get startTime() { + return this.#startTime; } - #resume() { - if (!(this.#effect instanceof KeyframeEffect)) { - return; + // 4.4.5. Setting the start time of an animation + set startTime(newTime: number | null) { + // 1. Let timeline time be the current time value of the timeline that animation is associated with. If there is no timeline associated with animation or the associated timeline is inactive, let the timeline time be unresolved. + const timelineTime = this.#timeline?.currentTime ?? null; + + // 2. If timeline time is unresolved and new start time is resolved, make animation’s hold time unresolved. + if (timelineTime === null && newTime !== null) { + this.#holdTime = null; } - const computedDuration = - this.#effect.getComputedTiming().activeDuration ?? 0; - const duration = computedDuration - this.#pausedTime.active; + // 3. Let previous current time be animation’s current time. + this.#previousCurrentTime = this.currentTime; - switch (this.#phase) { - case 'delay': - { - const computedDelay = this.#effect.getComputedTiming().delay ?? 0; - this.#timeout = setTimeout(() => { - this.#timeout = setTimeout( - () => this.finish(), - duration * this.playbackRate * durationMultiplier - ); - this.#phase = 'active'; - }, (computedDelay - this.#pausedTime.delay) * durationMultiplier); - } - break; - case 'active': - this.#timeout = setTimeout( - () => this.finish(), - duration * this.playbackRate * durationMultiplier - ); - break; - case 'endDelay': - this.finish(); - break; + // 4. Apply any pending playback rate on animation. + this.#applyPendingPlaybackRate(); + + // 5. Set animation’s start time to new start time. + this.#startTime = newTime; + + // 6. Update animation’s hold time based on the first matching condition from the following, + if (newTime !== null) { + // If animation’s playback rate is not zero, make animation’s hold time unresolved. + if (this.playbackRate !== 0) { + this.#holdTime = null; + } + } else { + // Set animation’s hold time to previous current time even if previous current time is unresolved. + this.#holdTime = this.#previousCurrentTime; + } + + // 7. If animation has a pending play task or a pending pause task, cancel that task and resolve animation’s current ready promise with animation. + if (this.#hasPendingTask()) { + this.#pendingPlayTask = null; + this.#pendingPauseTask = null; + this.#resolvers.ready.resolve(this); } - this.#pauseTime = null; + + // 8. Run the procedure to update an animation’s finished state for animation with the did seek flag set to true, and the synchronously notify flag set to false. + this.#updateFinishedState(true, false); } - play() { - if (!(this.#effect instanceof KeyframeEffect)) { + #iteration() { + if (!this.#hasKeyframeEffect()) { return; } - switch (this.playState) { - case 'idle': - if (this.#fillMode === 'backwards' || this.#fillMode === 'both') { - const keyframes = this.#effect.getKeyframes(); - if (keyframes.length > 1) { - this.#commitKeyframeStyles(keyframes[0]); + const playState = this.playState; + const phase = this.#phase; + const fillMode = this.#fillMode; + const keyframes = this.effect.getKeyframes(); + + if (playState === 'running' || playState === 'finished') { + if (this.#previousPhase !== phase) { + // describes the beginning of the animation + // either a change from idle to before if moving forwards + // either a change from idle to after if moving backwards + if (this.#previousPhase === 'idle') { + // going forwards + if (this.playbackRate > 0) { + if ( + phase === 'before' && + (fillMode === 'backwards' || fillMode === 'both') + ) { + if (keyframes.length > 1) { + this.#commitKeyframeStyles(keyframes[0]); + } + } + } + // going backwards + else { + if ( + phase === 'after' && + (fillMode === 'forwards' || fillMode === 'both') + ) { + this.#commitKeyframeStyles(keyframes[keyframes.length - 1]); + } + } + } else if (this.#previousPhase === 'active') { + if (phase === 'after') { + if (fillMode === 'backwards' || fillMode === 'none') { + this.#commitKeyframeStyles(this.#initialKeyframe); + } + } else if (phase === 'before') { + if (fillMode === 'forwards' || fillMode === 'none') { + this.#commitKeyframeStyles(this.#initialKeyframe); + } } } - break; - case 'running': - return; - case 'finished': - this.finished = this.#getNewFinished(); - break; + } + + if (phase === 'active') { + this.commitStyles(); + } + + if (playState === 'running') { + this.#rafId = requestAnimationFrame(() => { + this.#iteration(); + }); + } + + this.#previousPhase = phase; + } + + this.#updateFinishedState(false, true); + } + + #cancelIteration() { + if (this.#rafId !== null) { + cancelAnimationFrame(this.#rafId); + this.#rafId = null; } + } - const computedTiming = this.#effect.getComputedTiming(); + #playTask() { + this.#pendingPlayTask = null; + + // assert timeline + if (!this.#isTimelineActive()) { + throw new Error( + "Failed to play an 'Animation': the animation's timeline is inactive" + ); + } - const computedDuration = computedTiming.activeDuration ?? 0; + // 1. Assert that at least one of animation’s start time or hold time is resolved. + if (this.#startTime === null && this.#holdTime === null) { + throw new Error( + "Failed to play an 'Animation': the start time or hold time must be resolved" + ); + } - if (computedDuration > 0) { - if (this.playState === 'paused') { - this.#flushPausedTime(); - this.#resume(); + // 2. Let ready time be the time value of the timeline associated with animation at the moment when animation became ready. + const readyTime = this.timeline.currentTime; + + // console.log('readyTime', readyTime, this.#holdTime, this.startTime); + + // 3. Perform the steps corresponding to the first matching condition below, if any: + // If animation’s hold time is resolved, + // Apply any pending playback rate on animation. + // Let new start time be the result of evaluating ready time - hold time / playback rate for animation. If the playback rate is zero, let new start time be simply ready time. + // Set the start time of animation to new start time. + // If animation’s playback rate is not 0, make animation’s hold time unresolved. + // If animation’s start time is resolved and animation has a pending playback rate, + // Let current time to match be the result of evaluating (ready time - start time) × playback rate for animation. + // Apply any pending playback rate on animation. + // If animation’s playback rate is zero, let animation’s hold time be current time to match. + // Let new start time be the result of evaluating ready time - current time to match / playback rate for animation. If the playback rate is zero, let new start time be simply ready time. + // Set the start time of animation to new start time. + + if (this.#holdTime !== null) { + this.#applyPendingPlaybackRate(); + const newStartTime = + this.#playbackRate === 0 + ? readyTime + : readyTime - this.#holdTime / this.#playbackRate; + + this.startTime = newStartTime; + } else if (this.#startTime !== null && this.#pendingPlaybackRate !== null) { + const currentTimeToMatch = + (readyTime - this.#startTime) * this.playbackRate; + + this.#applyPendingPlaybackRate(); + + if (this.#playbackRate === 0) { + this.#holdTime = currentTimeToMatch; } else { - const computedDelay = computedTiming.delay ?? 0; + const newStartTime = + readyTime - currentTimeToMatch / this.#playbackRate; + this.startTime = newStartTime; + } + } - this.startTime = performance.now(); - this.#timeout = setTimeout(() => { - if (!(this.#effect instanceof KeyframeEffect)) { - return; - } + // 4. Resolve animation’s current ready promise with animation. + this.#resolvers.ready.resolve(this); - const keyframes = this.#effect.getKeyframes(); - if (keyframes.length > 1) { - this.#commitKeyframeStyles(keyframes[0]); - } + // 5. Run the procedure to update an animation’s finished state for animation with the did seek flag set to false, and the synchronously notify flag set to false. + this.#updateFinishedState(false, false); + } - if (computedTiming.iterations === Infinity) { - this.#timeout = setInterval( - () => this.finish(), - computedDuration * this.playbackRate * durationMultiplier - ); - } else { - this.#timeout = setTimeout( - () => this.finish(), - computedDuration * this.playbackRate * durationMultiplier - ); - } + #play(autoRewind: boolean) { + // 1. Let aborted pause be a boolean flag that is true if animation has a pending pause task, and false otherwise. + const abortedPause = this.#pendingPauseTask !== null; - this.#phase = 'active'; - }, computedDelay * durationMultiplier); + // 2. Let has pending ready promise be a boolean flag that is initially false. + let hasPendingReadyPromise = false; + + // 3. Let seek time be a time value that is initially unresolved. + let seekTime: number | null = null; + + // 4. Let has finite timeline be true if animation has an associated timeline that is not monotonically increasing. + const hasFiniteTimeline = + this.#timeline && !this.#isTimelineMonotonicallyIncreasing(); + + // 5. Perform the steps corresponding to the first matching condition from the following, if any: + const currentTime = this.currentTime; + const effectEnd = this.#getComputedTiming().endTime; + + // condition 1 + if ( + this.#effectivePlaybackRate > 0 && + autoRewind && + (currentTime === null || currentTime < 0 || currentTime >= effectEnd) + ) { + seekTime = 0; + } + // condition 2 + else if ( + this.#effectivePlaybackRate < 0 && + autoRewind && + (currentTime === null || currentTime <= 0 || currentTime > effectEnd) + ) { + if (effectEnd === Infinity) { + throw new DOMException( + "Failed to execute 'play' on 'Animation': Cannot play reversed Animation with infinite target effect end.", + 'InvalidStateError' + ); } - this.playState = 'running'; + seekTime = effectEnd; + } + // condition 3 + else if (this.#effectivePlaybackRate === 0 && currentTime === null) { + seekTime = 0; } - } - reverse() { - if (!(this.#effect instanceof KeyframeEffect)) { - return; + // 6. If seek time is resolved, + if (seekTime !== null) { + if (hasFiniteTimeline) { + this.startTime = seekTime; + this.#holdTime = null; + this.#applyPendingPlaybackRate(); + } else { + this.#holdTime = seekTime; + } } - this.playbackRate = -this.playbackRate; + // 7. If animation’s hold time is resolved, let its start time be unresolved. + if (this.#holdTime !== null) { + this.startTime = null; + } - if (this.playState === 'running' || this.playState === 'paused') { - const computedDuration = - this.#effect.getComputedTiming().activeDuration ?? 0; + // 8. If animation has a pending play task or a pending pause task, + if (this.#hasPendingTask()) { + this.#pendingPauseTask = null; + this.#pendingPlayTask = null; + hasPendingReadyPromise = true; + } - this.#clearTimeout(); + // 9. If the following four conditions are all satisfied: + // If the following four conditions are all satisfied: + // animation’s hold time is unresolved, and + // seek time is unresolved, and + // aborted pause is false, and + // animation does not have a pending playback rate, + // abort this procedure. + if ( + this.#holdTime === null && + seekTime === null && + !abortedPause && + this.#pendingPlaybackRate === null + ) { + return; + } - this.#timeout = setTimeout( - () => this.finish(), - Math.abs( - (computedDuration - (this.currentTime as number)) * this.playbackRate - ) * durationMultiplier - ); + // 10. If has pending ready promise is false, let animation’s current ready promise be a new promise in the relevant Realm of animation. + if (!hasPendingReadyPromise) { + this.#readyPromise = this.#getNewReadyPromise(); } + + // 11. Schedule a task to run as soon as animation is ready. The task shall perform the following steps: + this.#pendingPlayTask = () => { + this.#playTask(); + }; + + this.ready.then(() => { + this.#pendingPlayTask?.(); + this.#iteration(); + }); + + queueMicrotask(() => { + this.#resolvers.ready.resolve(this); + }); + + // 12. Run the procedure to update an animation’s finished state for animation with the did seek flag set to false, and the synchronously notify flag set to false. + this.#updateFinishedState(false, false); } - updatePlaybackRate(playbackRate: number) { - this.playbackRate = playbackRate; + // 4.4.8. Playing an animation + // https://www.w3.org/TR/web-animations-1/#playing-an-animation-section + play() { + this.#play(true); } - // addEventListener() { - // console.log("addEventListener isn't implemented yet"); - // } - // removeEventListener() { - // console.log("removeEventListener isn't implemented yet"); - // } + #pauseTask() { + this.#pendingPauseTask = null; + + // assert timeline + if (!this.#isTimelineActive()) { + throw new Error( + "Failed to pause an 'Animation': the animation's timeline is inactive" + ); + } + + // 1. Let ready time be the time value of the timeline associated with animation at the moment when the user agent completed processing necessary to suspend playback of animation’s associated effect. + const readyTime = this.timeline.currentTime; + + // 2. If animation’s start time is resolved and its hold time is not resolved, let animation’s hold time be the result of evaluating (ready time - start time) × playback rate. + if (this.#startTime !== null && this.#holdTime === null) { + this.#holdTime = (readyTime - this.#startTime) * this.#playbackRate; + } + + // Note: The hold time might be already set if the animation is finished, or if the animation has a pending play task. In either case we want to preserve the hold time as we enter the paused state. + + // 3. Apply any pending playback rate on animation. + this.#applyPendingPlaybackRate(); + + // 4. Make animation’s start time unresolved. + this.#startTime = null; + + // 5. Resolve animation’s current ready promise with animation. + this.#resolvers.ready.resolve(this); + + // 6. Run the procedure to update an animation’s finished state for animation with the did seek flag set to false, and the synchronously notify flag set to false. + this.#updateFinishedState(false, false); + } + + // 4.4.9. Pausing an animation + // https://www.w3.org/TR/web-animations-1/#pausing-an-animation-section + pause() { + // 1. If animation has a pending pause task, abort these steps. + if (this.#pendingPauseTask !== null) { + return; + } + + // 2. If the play state of animation is paused, abort these steps. + if (this.playState === 'paused') { + return; + } + + // 3. Let seek time be a time value that is initially unresolved. + let seekTime: number | null = null; + + // 4. Let has finite timeline be true if animation has an associated timeline that is not monotonically increasing. + const hasFiniteTimeline = + this.#timeline && !this.#isTimelineMonotonicallyIncreasing(); + + // 5. If the animation’s current time is unresolved, perform the steps according to the first matching condition from below: + // If animation’s playback rate is ≥ 0, + // Set seek time to zero. + // Otherwise, + // If associated effect end for animation is positive infinity, + // throw an "InvalidStateError" DOMException and abort these steps. + // Otherwise, + // Set seek time to animation’s associated effect end. + const currentTime = this.currentTime; + const effectEnd = this.#getComputedTiming().endTime; + + if (currentTime === null) { + if (this.#playbackRate >= 0) { + seekTime = 0; + } else { + if (effectEnd === Infinity) { + throw new DOMException( + "Failed to execute 'pause' on 'Animation': Cannot play reversed Animation with infinite target effect end.", + 'InvalidStateError' + ); + } else { + seekTime = effectEnd; + } + } + } + + // 6. If seek time is resolved, + // If has finite timeline is true, + // Set animation’s start time to seek time. + // Otherwise, + // Set animation’s hold time to seek time. + if (seekTime !== null) { + if (hasFiniteTimeline) { + this.startTime = seekTime; + } else { + this.#holdTime = seekTime; + } + } + + // 7. Let has pending ready promise be a boolean flag that is initially false. + let hasPendingReadyPromise = false; + + // 8. If animation has a pending play task, cancel that task and let has pending ready promise be true. + if (this.#pendingPlayTask !== null) { + this.#pendingPlayTask = null; + hasPendingReadyPromise = true; + } + + // 9. If has pending ready promise is false, set animation’s current ready promise to a new promise in the relevant Realm of animation. + if (!hasPendingReadyPromise) { + this.#readyPromise = this.#getNewReadyPromise(); + } + + // 10. Schedule a task to be executed at the first possible moment when + // the animation is associated with a timeline that is not inactive. + this.#pendingPauseTask = () => { + this.#pauseTask(); + }; + + queueMicrotask(() => { + this.#pendingPauseTask?.(); + + // 11. Run the procedure to update an animation’s finished state for animation with the did seek flag set to false, and the synchronously notify flag set to false. + this.#updateFinishedState(false, false); + }); + + this.#cancelIteration(); + } + + // 4.4.12. Updating the finished state + // https://www.w3.org/TR/web-animations-1/#updating-the-finished-state + #finishNotification() { + // 1. If animation’s play state is not equal to finished, abort these steps. + if (this.playState !== 'finished') { + return; + } + + // 2. Resolve animation’s current finished promise object with animation. + this.#resolvers.finished.resolve(this); + + // 3. Create an AnimationPlaybackEvent, finishEvent. + // Set finishEvent’s type attribute to finish. + // Set finishEvent’s currentTime attribute to the current time of animation. + // Set finishEvent’s timelineTime attribute to the current time of the timeline with which animation is associated. If animation is not associated with a timeline, or the timeline is inactive, let timelineTime be null. + const finishEvent = new AnimationPlaybackEvent('finish', { + currentTime: this.currentTime, + timelineTime: this.timeline ? this.timeline.currentTime : null, + }); + + // 7. If animation has a document for timing, then append finishEvent to its document for timing's pending animation event queue along with its target, animation. For the scheduled event time, use the result of converting animation’s associated effect end to an origin-relative time. + // Otherwise, queue a task to dispatch finishEvent at animation. The task source for this task is the DOM manipulation task source. + + this.dispatchEvent(finishEvent); + this.onfinish?.(finishEvent); + } + + #queuedFinishNotificationMicrotask: (() => void) | null = null; + + #cancelFinishNotificationMicrotask() { + this.#queuedFinishNotificationMicrotask = null; + } + + #queueFinishNotificationMicrotask() { + if (this.#queuedFinishNotificationMicrotask === null) { + this.#queuedFinishNotificationMicrotask = () => { + this.#finishNotification(); + this.#queuedFinishNotificationMicrotask = null; + }; + + queueMicrotask(() => this.#queuedFinishNotificationMicrotask?.()); + } + } + + #updateFinishedState( + // indicates if the update is being performed after setting the current time + didSeek: boolean, + // indicates the update was called in a context where we expect finished event queueing and finished promise resolution to happen immediately, if at all + synchronouslyNotify: boolean + ) { + // 1. Let the unconstrained current time be the result of calculating the current time substituting an unresolved time value for the hold time if did seek is false. If did seek is true, the unconstrained current time is equal to the current time. + const unconstrainedCurrentTime = didSeek + ? this.#calculateCurrentTime() + : this.currentTime; + + // 2. If all three of the following conditions are true, + // the unconstrained current time is resolved, and + // animation’s start time is resolved, and + // animation does not have a pending play task or a pending pause task, then update animation’s hold time based on the first matching condition for animation from below, if any: + // If playback rate > 0 and unconstrained current time is greater than or equal to associated effect end, + // If did seek is true, let the hold time be the value of unconstrained current time. + // If did seek is false, let the hold time be the maximum value of previous current time and associated effect end. If the previous current time is unresolved, let the hold time be associated effect end. + // If playback rate < 0 and unconstrained current time is less than or equal to 0, + // If did seek is true, let the hold time be the value of unconstrained current time. + // If did seek is false, let the hold time be the minimum value of previous current time and zero. If the previous current time is unresolved, let the hold time be zero. + // If playback rate ≠ 0, and animation is associated with an active timeline, + // Perform the following steps: + // If did seek is true and the hold time is resolved, let animation’s start time be equal to the result of evaluating timeline time - (hold time / playback rate) where timeline time is the current time value of timeline associated with animation. + // Let the hold time be unresolved. + const startTime = this.startTime; + const effectEnd = this.#getComputedTiming().endTime; + + if ( + unconstrainedCurrentTime !== null && + startTime !== null && + !this.#hasPendingTask() + ) { + // If playback rate > 0 and unconstrained current time is greater than or equal to associated effect end, + // If did seek is true, let the hold time be the value of unconstrained current time. + // If did seek is false, let the hold time be the maximum value of previous current time and associated effect end. If the previous current time is unresolved, let the hold time be associated effect end. + // If playback rate < 0 and unconstrained current time is less than or equal to 0, + // If did seek is true, let the hold time be the value of unconstrained current time. + // If did seek is false, let the hold time be the minimum value of previous current time and zero. If the previous current time is unresolved, let the hold time be zero. + // If playback rate ≠ 0, and animation is associated with an active timeline, + // Perform the following steps: + // 1. If did seek is true and the hold time is resolved, let animation’s start time be equal to the result of evaluating timeline time - (hold time / playback rate) where timeline time is the current time value of timeline associated with animation. + // 2. Let the hold time be unresolved. + const playbackRate = this.playbackRate; + + if (playbackRate > 0 && unconstrainedCurrentTime >= effectEnd) { + if (didSeek) { + this.#holdTime = unconstrainedCurrentTime; + } else { + if (this.#previousCurrentTime === null) { + this.#holdTime = effectEnd; + } else { + this.#holdTime = Math.max(this.#previousCurrentTime, effectEnd); + } + } + } else if (playbackRate < 0 && unconstrainedCurrentTime <= 0) { + if (didSeek) { + this.#holdTime = unconstrainedCurrentTime; + } else { + if (this.#previousCurrentTime === null) { + this.#holdTime = 0; + } else { + this.#holdTime = Math.min(this.#previousCurrentTime, 0); + } + } + } else if (playbackRate !== 0 && this.#isTimelineActive()) { + if (didSeek && this.#holdTime !== null) { + this.startTime = + this.timeline.currentTime - this.#holdTime / playbackRate; + } + this.#holdTime = null; + } + } + + // 3. Set the previous current time of animation be the result of calculating its current time. + this.#previousCurrentTime = this.#calculateCurrentTime(); + + // 4. Let current finished state be true if the play state of animation is finished. Otherwise, let it be false. + const currentFinishedState = this.playState === 'finished'; + + // 5. If current finished state is true and the current finished promise is not yet resolved, perform the following steps + // If synchronously notify is true, cancel any queued microtask to run the finish notification steps for this animation, and run the finish notification steps immediately. + // Otherwise, if synchronously notify is false, queue a microtask to run finish notification steps for animation unless there is already a microtask queued to run those steps for animation. + // console.log( + // 'finishing!', + // currentFinishedState, + // this.#promiseStates.finished !== 'resolved', + // synchronouslyNotify + // ); + + if (currentFinishedState && this.#promiseStates.finished !== 'resolved') { + if (synchronouslyNotify) { + this.#cancelFinishNotificationMicrotask(); + this.#finishNotification(); + } else { + this.#queueFinishNotificationMicrotask(); + // this.#queueMicrotask(() => this.#finishNotification()); + } + } + + // 6. If current finished state is false and animation’s current finished promise is already resolved, set animation’s current finished promise to a new promise in the relevant Realm of animation. + if (!currentFinishedState && this.#promiseStates.finished === 'resolved') { + this.#finishedPromise = this.#getNewFinishedPromise(); + } + } + + // 4.4.13. Finishing an animation + finish() { + // 1. If animation’s effective playback rate is zero, or if animation’s effective playback rate > 0 and associated effect end is infinity, throw an "InvalidStateError" DOMException and abort these steps. + const effectivePlaybackRate = this.#effectivePlaybackRate; + const effectEnd = this.#getComputedTiming().endTime; + + if ( + effectivePlaybackRate === 0 || + (effectivePlaybackRate > 0 && effectEnd === Infinity) + ) { + throw new DOMException( + "Failed to execute 'finish' on 'Animation': Cannot finish Animation with an infinite target effect end.", + 'InvalidStateError' + ); + } + + // 2. Apply any pending playback rate to animation. + this.#applyPendingPlaybackRate(); + + // 3. Set limit as follows: + // If playback rate > 0, + // Let limit be associated effect end. + // Otherwise, + // Let limit be zero. + const limit = this.#playbackRate > 0 ? effectEnd : 0; + + // 4. Silently set the current time to limit. + this.#setCurrentTimeSilent(limit); + + // 5. If animation’s start time is unresolved and animation has an associated active timeline, let the start time be the result of evaluating timeline time - (limit / playback rate) where timeline time is the current time value of the associated timeline. + if (this.#startTime === null && this.#isTimelineActive()) { + this.#startTime = this.timeline.currentTime - limit / this.#playbackRate; + } + + // 6. If there is a pending pause task and start time is resolved, + // 6.1 Let the hold time be unresolved. + // > Typically the hold time will already be unresolved except in the case when the animation was previously idle. + // 6.2 Cancel the pending pause task. + // 6.3 Resolve the current ready promise of animation with animation. + if (this.#pendingPauseTask !== null && this.#startTime !== null) { + this.#holdTime = null; + this.#pendingPauseTask = null; + this.#resolvers.ready.resolve(this); + } + + // 7. If there is a pending play task and start time is resolved, cancel that task and resolve the current ready promise of animation with animation. + if (this.#pendingPlayTask !== null && this.#startTime !== null) { + this.#pendingPlayTask = null; + this.#resolvers.ready.resolve(this); + } + + // 8. Run the procedure to update an animation’s finished state for animation with the did seek flag set to true, and the synchronously notify flag set to true. + this.#updateFinishedState(true, true); + } + + // 4.4.14. Canceling an animation + // https://www.w3.org/TR/web-animations-1/#canceling-an-animation-section + cancel() { + if (!this.#hasKeyframeEffect()) { + return; + } + + // 1. If animation’s play state is not idle, perform the following steps: + if (this.playState !== 'idle') { + // Run the procedure to reset an animation’s pending tasks on animation. + this.#resetPendingTasks(); + + // Reject the current finished promise with a DOMException named "AbortError". + // this.#resolvers.finished.reject(new DOMException('AbortError')); + this.#resolvers.finished.reject( + new DOMException('The user aborted a request.', 'AbortError') + ); + + // Set the [[PromiseIsHandled]] internal slot of the current finished promise to true. + + // Let current finished promise be a new promise in the relevant Realm of animation. + this.#finishedPromise = this.#getNewFinishedPromise(); + + // Let timeline time be the current time of the timeline with which animation is associated. If animation is not associated with an active timeline, let timeline time be n unresolved time value. + const timelineTime = this.timeline?.currentTime ?? null; + + // Create an AnimationPlaybackEvent, cancelEvent. + // Set cancelEvent’s type attribute to cancel. + // Set cancelEvent’s currentTime to null. + // Set cancelEvent’s timelineTime to timeline time. If timeline time is unresolved, set it to null. + const cancelEvent = new AnimationPlaybackEvent('cancel', { + currentTime: null, + timelineTime, + }); + + // If animation has a document for timing, then append cancelEvent to its document for timing's pending animation event queue along with its target, animation. If animation is associated with an active timeline that defines a procedure to convert timeline times to origin-relative time, let the scheduled event time be the result of applying that procedure to timeline time. Otherwise, the scheduled event time is an unresolved time value. + + // Otherwise, queue a task to dispatch cancelEvent at animation. The task source for this task is the DOM manipulation task source. + this.dispatchEvent(cancelEvent); + this.oncancel?.(cancelEvent); + } else { + // it's not in the spec, but chrome does it + this.#pendingPlaybackRate = null; + this.#pendingPauseTask = this.#pendingPlayTask = null; + } + + // 2. Make animation’s hold time unresolved. + this.#holdTime = null; + + // 3. Make animation’s start time unresolved. + this.#startTime = null; + } + + get playbackRate() { + return this.#playbackRate; + } + + // 4.4.15.1. Setting the playback rate of an animation + set playbackRate(rate: number) { + // 1. Clear any pending playback rate on animation. + this.#pendingPlaybackRate = null; + + // 2. Let previous time be the value of the current time of animation before changing the playback rate. + const previousTime = this.currentTime; + + // 3. Set the playback rate to new playback rate. + this.#playbackRate = rate; + + // 4. If previous time is resolved, set the current time of animation to previous time + if (previousTime !== null) { + this.currentTime = previousTime; + } + } + + // 4.4.15.2. Seamlessly updating the playback rate of an animation + // https://www.w3.org/TR/web-animations-1/#seamlessly-updating-the-playback-rate-of-an-animation + updatePlaybackRate(playbackRate: number) { + // 1. Let previous play state be animation’s play state. + const previousPlayState = this.playState; + + // 2. Let animation’s pending playback rate be new playback rate. + this.#pendingPlaybackRate = playbackRate; + + // 3. Perform the steps corresponding to the first matching condition from below: + // If animation has a pending play task or a pending pause task, abort these steps. + if (this.#hasPendingTask()) { + return; + } + + switch (previousPlayState) { + // If previous play state is idle or paused, apply any pending playback rate on animation. + case 'idle': + case 'paused': + this.#applyPendingPlaybackRate(); + break; + + case 'finished': + { + // 1. Let the unconstrained current time be the result of calculating the current time of animation substituting an unresolved time value for the hold time. + const unconstrainedCurrentTime = + this.#calculateCurrentTime() ?? this.#holdTime; + + // 2. Let animation’s start time be the result of evaluating the following expression: + // timeline time - (unconstrained current time / pending playback rate) + // Where timeline time is the current time value of the timeline associated with animation. + const timelineTime = this.timeline?.currentTime ?? null; + + if (this.#pendingPlaybackRate !== 0) { + if (timelineTime) { + this.#startTime = unconstrainedCurrentTime + ? timelineTime - + unconstrainedCurrentTime / this.#pendingPlaybackRate + : null; + } + } else { + // 3. If pending playback rate is zero, let animation’s start time be timeline time. + this.#startTime = timelineTime; + } + + // 4. Apply any pending playback rate on animation. + this.#applyPendingPlaybackRate(); + + // 5. Run the procedure to update an animation’s finished state for animation with the did seek flag set to false, and the synchronously notify flag set to false. + this.#updateFinishedState(false, false); + } + break; + + case 'running': + this.#cancelIteration(); + + // Run the procedure to play an animation for animation with the auto-rewind flag set to false. + this.#play(false); + break; + } + } + + // 4.4.16. Reversing an animation + // https://www.w3.org/TR/web-animations-1/#reversing-an-animation-section + reverse() { + // 1. If there is no timeline associated with animation, or the associated timeline is inactive throw an "InvalidStateError" DOMException and abort these steps. + if (this.timeline === null || !this.#isTimelineActive()) { + throw new DOMException( + 'Cannot reverse an animation with no active timeline', + 'InvalidStateError' + ); + } + + // 2. Let original pending playback rate be animation’s pending playback rate. + const originalPendingPlaybackRate = this.#pendingPlaybackRate; + + // 3. Let animation’s pending playback rate be the additive inverse of its effective playback rate (i.e. -effective playback rate). + this.#pendingPlaybackRate = -this.#effectivePlaybackRate; + + this.#cancelIteration(); + + // 4. Run the steps to play an animation for animation with the auto-rewind flag set to true. + // If the steps to play an animation throw an exception, set animation’s pending playback rate to original pending playback rate and propagate the exception. + try { + this.#play(true); + } catch (error) { + this.#pendingPlaybackRate = originalPendingPlaybackRate; + throw error; + } + } + + // 4.4.17. Play states + // https://www.w3.org/TR/web-animations-1/#play-states + get playState() { + // The play state of animation, animation, at a given moment is the state corresponding to the first matching condition from the following: + + const currentTime = this.currentTime; + + // All of the following conditions are true: + // The current time of animation is unresolved, and + // the start time of animation is unresolved, and + // animation does not have either a pending play task or a pending pause task, + // → idle + if ( + currentTime === null && + this.#startTime === null && + !this.#hasPendingTask() + ) { + return 'idle'; + } + + // Either of the following conditions are true: + // animation has a pending pause task, or + // both the start time of animation is unresolved and it does not have a pending play task, + // → paused + else if ( + this.#pendingPauseTask !== null || + (this.startTime === null && this.#pendingPlayTask === null) + ) { + return 'paused'; + } + + // For animation, current time is resolved and either of the following conditions are true: + // animation’s effective playback rate > 0 and current time ≥ associated effect end; or + // animation’s effective playback rate < 0 and current time ≤ 0, + // → finished + else if ( + currentTime !== null && + ((this.#effectivePlaybackRate > 0 && + currentTime >= this.#getComputedTiming().endTime) || + (this.#effectivePlaybackRate < 0 && currentTime <= 0)) + ) { + return 'finished'; + } + + // Otherwise, + // → running + else { + return 'running'; + } + } + + set playState(_newPlayState: AnimationPlayState) { + throw new TypeError( + 'Cannot set property playState of # which has only a getter' + ); + } + + // 4.8.3.1. Calculating the active time + // If the animation effect is in the before phase, + // The result depends on the first matching condition from the following, + // If the fill mode is backwards or both, + // Return the result of evaluating max(local time - start delay, 0). + // Otherwise, + // Return an unresolved time value. + // If the animation effect is in the active phase, + // Return the result of evaluating local time - start delay. + // If the animation effect is in the after phase, + // The result depends on the first matching condition from the following, + // If the fill mode is forwards or both, + // Return the result of evaluating max(min(local time - start delay, active duration), 0). + // Otherwise, + // Return an unresolved time value. + // Otherwise (the local time is unresolved), + // Return an unresolved time value. + get #activeTime() { + const computedTiming = this.#getComputedTiming(); + const localTime = this.#localTime; + + if (localTime === null) { + return null; + } + + switch (this.#phase) { + case 'before': + if (this.#fillMode === 'backwards' || this.#fillMode === 'both') { + return Math.max(localTime - computedTiming.delay, 0); + } else { + return null; + } + case 'active': + return localTime - computedTiming.delay; + case 'after': + if (this.#fillMode === 'forwards' || this.#fillMode === 'both') { + return Math.max( + Math.min( + localTime - computedTiming.delay, + computedTiming.activeDuration + ), + 0 + ); + } else { + return null; + } + default: + return null; + } + } + + // 4.8.3.2. Calculating the overall progress + // The overall progress describes the number of iterations that have completed (including partial iterations) and is defined as follows: + // 1. If the active time is unresolved, return unresolved. + // 2. Calculate an initial value for overall progress based on the first matching condition from below, + // If the iteration duration is zero, + // If the animation effect is in the before phase, let overall progress be zero, otherwise, let it be equal to the iteration count. + // Otherwise, + // Let overall progress be the result of calculating active time / iteration duration. + // 3. Return the result of calculating overall progress + iteration start. + get #overallProgress() { + const activeTime = this.#activeTime; + + if (activeTime === null) { + return null; + } + + const computedTiming = this.#getComputedTiming(); + + let overallProgress: number; + + if (computedTiming.duration === 0) { + if (this.#phase === 'before') { + overallProgress = 0; + } else { + overallProgress = computedTiming.iterations; + } + } else { + overallProgress = activeTime / computedTiming.duration; + } + + return overallProgress + computedTiming.iterationStart; + } + + // 4.8.3.3. Calculating the simple iteration progress + // https://www.w3.org/TR/web-animations-1/#calculating-the-simple-iteration-progress + // The simple iteration progress is a fraction of the progress through the current iteration that ignores transformations to the time introduced by the playback direction or timing functions applied to the effect, and is calculated as follows: + // 1. If the overall progress is unresolved, return unresolved. + // 2. If overall progress is infinity, let the simple iteration progress be iteration start % 1.0, otherwise, let the simple iteration progress be overall progress % 1.0. + // 3. If all of the following conditions are true, + // the simple iteration progress calculated above is zero, and + // the animation effect is in the active phase or the after phase, and + // the active time is equal to the active duration, and + // the iteration count is not equal to zero. + // let the simple iteration progress be 1.0. + // 4. Return simple iteration progress. + get #iterationProgress() { + const overallProgress = this.#overallProgress; + + if (overallProgress === null) { + return null; + } + + const computedTiming = this.#getComputedTiming(); + + let iterationProgress: number; + + if (overallProgress === Infinity) { + iterationProgress = computedTiming.iterationStart % 1.0; + } else { + iterationProgress = overallProgress % 1.0; + } + + if ( + iterationProgress === 0 && + (this.#phase === 'active' || this.#phase === 'after') && + this.#activeTime === computedTiming.activeDuration && + computedTiming.iterations !== 0 + ) { + iterationProgress = 1.0; + } + + return iterationProgress; + } + + // 4.8.4. Calculating the current iteration + // https://www.w3.org/TR/web-animations-1/#calculating-the-current-iteration + // The current iteration can be calculated using the following steps: + // If the active time is unresolved, return unresolved. + // If the animation effect is in the after phase and the iteration count is infinity, return infinity. + // If the simple iteration progress is 1.0, return floor(overall progress) - 1. + // Otherwise, return floor(overall progress). + get #currentIteration() { + const activeTime = this.#activeTime; + + if (activeTime === null) { + return null; + } + + const timing = this.#getTiming(); + + if (this.#phase === 'after' && timing.iterations === Infinity) { + return Infinity; + } + + const iterationProgress = this.#iterationProgress; + + // overall progress should be defined here, see #overallProgress getter + const overallProgress = this.#overallProgress!; + + if (iterationProgress === 1.0) { + return Math.floor(overallProgress) - 1; + } else { + return Math.floor(overallProgress); + } + } + + // 4.9.1. Calculating the directed progress + // https://www.w3.org/TR/web-animations-1/#calculating-the-directed-progress + // The directed progress is calculated from the simple iteration progress using the following steps: + // 1. If the simple iteration progress is unresolved, return unresolved. + // 2. Calculate the current direction using the first matching condition from the following list: + // If playback direction is normal, + // Let the current direction be forwards. + // If playback direction is reverse, + // Let the current direction be reverse. + // Otherwise, + // Let d be the current iteration. + // If playback direction is alternate-reverse increment d by 1. + // If d % 2 == 0, let the current direction be forwards, otherwise let the current direction be reverse. If d is infinity, let the current direction be forwards. + // 3. If the current direction is forwards then return the simple iteration progress. + // Otherwise, return 1.0 - simple iteration progress. + get #currentDirection() { + if (this.#currentIteration === null) { + return null; + } + + const timing = this.#getTiming(); + + let currentDirection: 'forwards' | 'reverse'; + const playbackDirection = timing.direction; + + if (playbackDirection === 'normal') { + currentDirection = 'forwards'; + } else if (playbackDirection === 'reverse') { + currentDirection = 'reverse'; + } else { + let currentIteration = this.#currentIteration; + + if (playbackDirection === 'alternate-reverse') { + currentIteration += 1; + } + + if (currentIteration === Infinity || currentIteration % 2 === 0) { + currentDirection = 'forwards'; + } else { + currentDirection = 'reverse'; + } + } + + return currentDirection; + } + + get #directedProgress() { + const iterationProgress = this.#iterationProgress; + + if (iterationProgress === null) { + return null; + } + + const currentDirection = this.#currentDirection; + + if (currentDirection === 'forwards') { + return iterationProgress; + } else { + return 1.0 - iterationProgress; + } + } + + // 4.10.1. Calculating the transformed progress + // https://www.w3.org/TR/web-animations-1/#calculating-the-transformed-progress + // The transformed progress is calculated from the directed progress using the following steps: + // 1. If the directed progress is unresolved, return unresolved. + // 2. Calculate the value of the before flag as follows: + // 2.1. Determine the current direction using the procedure defined in §4.9.1 Calculating the directed progress. + // 2.2. If the current direction is forwards, let going forwards be true, otherwise it is false. + // 2.3. The before flag is set if the animation effect is in the before phase and going forwards is true; or if the animation effect is in the after phase and going forwards is false. + // 3. Return the result of evaluating the animation effect’s timing function passing directed progress as the input progress value and before flag as the before flag. + get #transformedProgress() { + const directedProgress = this.#directedProgress; + const timing = this.#getTiming(); + + if (directedProgress === null || typeof timing === 'undefined') { + return null; + } + + const timingFunction = getEasingFunctionFromString(timing.easing); + + const currentDirection = directedProgress >= 0 ? 'forwards' : 'reverse'; + + const beforeFlag = + (this.#phase === 'before' && currentDirection === 'forwards') || + (this.#phase === 'after' && currentDirection === 'reverse'); + + return timingFunction(directedProgress, beforeFlag); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + oncancel: ((this: Animation, ev: AnimationPlaybackEvent) => any) | null = + null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onfinish: ((this: Animation, ev: AnimationPlaybackEvent) => any) | null = + null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onremove: ((this: Animation, ev: Event) => any) | null = null; + + #commitKeyframeStyles(keyframe: ComputedKeyframeWithOptionalNonStyleProps) { + if (!(this.#effect instanceof KeyframeEffect)) { + return; + } + + const element = this.#effect.target as HTMLElement; + const { composite, computedOffset, easing, offset, ...keyframeStyles } = + keyframe; + + for (const property in keyframeStyles) { + const value = keyframeStyles[property]; + + if (typeof value === 'undefined' || value === null) { + element.style.removeProperty(property); + continue; + } + + const valueAsString = + typeof value === 'string' ? value : value.toString(); + + element.style.setProperty(property, valueAsString); + } + } + + // this mock doesn't calculate intermediate styles, + // only the ones defined in keyframes + commitStyles() { + if ( + !(this.#effect instanceof KeyframeEffect) || + !this.#isTimelineActive() + ) { + return; + } + + const keyframes = this.#effect.getKeyframes(); + // const currentProgress = this.#effect.getComputedTiming().progress; + const currentProgress = this.#transformedProgress; + const currentDirection = this.#currentDirection; + + if ( + keyframes.length === 0 || + currentProgress === null || + currentDirection === null || + !this.#effect.target + ) { + return; + } + + if ( + keyframes.length === 1 && + ((currentDirection === 'forwards' && currentProgress <= 0.5) || + (currentDirection === 'reverse' && currentProgress >= 0.5)) + ) { + this.#commitKeyframeStyles(this.#initialKeyframe); + return; + } + + // find the keyframe closest to the current progress + let closestKeyframe: ComputedKeyframe = keyframes[0]; + let smallestDistance = Infinity; + + for (const keyframe of keyframes) { + const distance = Math.abs(keyframe.computedOffset - currentProgress); + + if (distance < smallestDistance) { + smallestDistance = distance; + closestKeyframe = keyframe; + } + } + + this.#commitKeyframeStyles(closestKeyframe); + } + + persist() { + console.log("persist isn't implemented yet"); + } } if (typeof Animation === 'undefined') { @@ -508,4 +1696,4 @@ if (typeof Animation === 'undefined') { }); } -export { MockedAnimation, setDurationMultiplier }; +export { MockedAnimation }; diff --git a/src/mocks/web-animations-api/AnimationEffect.test.ts b/src/mocks/web-animations-api/AnimationEffect.test.ts new file mode 100644 index 0000000..1c2e4bb --- /dev/null +++ b/src/mocks/web-animations-api/AnimationEffect.test.ts @@ -0,0 +1,16 @@ +import './AnimationEffect'; + +describe('AnimationEffect', () => { + it('should be defined', () => { + expect(AnimationEffect).toBeDefined(); + }); + + it('should throw "TypeError: Illegal constructor" if instantiated directly', () => { + expect(() => { + new AnimationEffect(); + }).toThrow(TypeError); + expect(() => { + new AnimationEffect(); + }).toThrow('Illegal constructor'); + }); +}); diff --git a/src/mocks/web-animations-api/AnimationEffect.ts b/src/mocks/web-animations-api/AnimationEffect.ts index bac7076..4c3a314 100644 --- a/src/mocks/web-animations-api/AnimationEffect.ts +++ b/src/mocks/web-animations-api/AnimationEffect.ts @@ -10,6 +10,12 @@ class MockedAnimationEffect implements AnimationEffect { iterations: 1, }; + constructor() { + if (this.constructor === MockedAnimationEffect) { + throw new TypeError('Illegal constructor'); + } + } + #getNormalizedDuration(): number { // the only possible value is "auto" if (typeof this.#timing.duration === 'string') { @@ -33,13 +39,16 @@ class MockedAnimationEffect implements AnimationEffect { ? Infinity : duration * (this.#timing.iterations ?? 1); - // Calculated as (start_delay + active_duration + end_delay) + // The end time of an animation effect is the result of evaluating max(start delay + active duration + end delay, 0). const endTime = this.#timing.iterations === Infinity ? Infinity - : (this.#timing.delay ?? 0) + - activeDuration + - (this.#timing.endDelay ?? 0); + : Math.max( + (this.#timing.delay ?? 0) + + activeDuration + + (this.#timing.endDelay ?? 0), + 0 + ); // must be linked to the animation const currentIteration = null; diff --git a/src/mocks/web-animations-api/AnimationTimeline.test.ts b/src/mocks/web-animations-api/AnimationTimeline.test.ts new file mode 100644 index 0000000..26e301e --- /dev/null +++ b/src/mocks/web-animations-api/AnimationTimeline.test.ts @@ -0,0 +1,16 @@ +import './AnimationTimeline'; + +describe('AnimationTimeline', () => { + it('should be defined', () => { + expect(AnimationTimeline).toBeDefined(); + }); + + it('should throw "TypeError: Illegal constructor" if instantiated directly', () => { + expect(() => { + new AnimationTimeline(); + }).toThrow(TypeError); + expect(() => { + new AnimationTimeline(); + }).toThrow('Illegal constructor'); + }); +}); diff --git a/src/mocks/web-animations-api/AnimationTimeline.ts b/src/mocks/web-animations-api/AnimationTimeline.ts index 1365a7d..3386ed2 100644 --- a/src/mocks/web-animations-api/AnimationTimeline.ts +++ b/src/mocks/web-animations-api/AnimationTimeline.ts @@ -1,11 +1,17 @@ class MockedAnimationTimeline implements AnimationTimeline { + constructor() { + if (this.constructor === MockedAnimationTimeline) { + throw new TypeError('Illegal constructor'); + } + } + get currentTime() { return performance.now(); } } -if (typeof AnimationTimeline === "undefined") { - Object.defineProperty(window, "AnimationTimeline", { +if (typeof AnimationTimeline === 'undefined') { + Object.defineProperty(window, 'AnimationTimeline', { writable: true, configurable: true, value: MockedAnimationTimeline, diff --git a/src/mocks/web-animations-api/DocumentTimeline.test.ts b/src/mocks/web-animations-api/DocumentTimeline.test.ts new file mode 100644 index 0000000..03a275e --- /dev/null +++ b/src/mocks/web-animations-api/DocumentTimeline.test.ts @@ -0,0 +1,28 @@ +import './DocumentTimeline'; + +jest.useFakeTimers(); + +describe('DocumentTimeline', () => { + it('should be defined', () => { + expect(DocumentTimeline).toBeDefined(); + }); + + it('should add a default timeline to the document', () => { + expect(document.timeline).toBeInstanceOf(DocumentTimeline); + expect(document.timeline.currentTime).toBe(0); + }); + + it('should set default origin time to 0', () => { + const timeline = new DocumentTimeline(); + + expect(timeline.currentTime).toBe(document.timeline.currentTime); + }); + + it('should set origin time to the given value', () => { + const timeline = new DocumentTimeline({ originTime: 100 }); + + expect(timeline.currentTime).toBe( + (document.timeline.currentTime ?? 0) - 100 + ); + }); +}); diff --git a/src/mocks/web-animations-api/DocumentTimeline.ts b/src/mocks/web-animations-api/DocumentTimeline.ts new file mode 100644 index 0000000..c181439 --- /dev/null +++ b/src/mocks/web-animations-api/DocumentTimeline.ts @@ -0,0 +1,32 @@ +import './AnimationTimeline'; + +class MockedDocumentTimeline + extends AnimationTimeline + implements DocumentTimeline +{ + #originTime = 0; + + constructor(options?: DocumentTimelineOptions) { + super(); + + this.#originTime = options?.originTime ?? 0; + } + + get currentTime() { + return performance.now() - this.#originTime; + } +} + +if (typeof DocumentTimeline === 'undefined') { + Object.defineProperty(window, 'DocumentTimeline', { + writable: true, + configurable: true, + value: MockedDocumentTimeline, + }); + + Object.defineProperty(Document.prototype, 'timeline', { + writable: true, + configurable: true, + value: new MockedDocumentTimeline(), + }); +} diff --git a/src/mocks/web-animations-api/easingFunctions.ts b/src/mocks/web-animations-api/easingFunctions.ts new file mode 100644 index 0000000..74870f3 --- /dev/null +++ b/src/mocks/web-animations-api/easingFunctions.ts @@ -0,0 +1,46 @@ +import BezierEasing from 'bezier-easing'; + +const ease = BezierEasing(0.25, 0.1, 0.25, 1.0); +const easeIn = BezierEasing(0.42, 0.0, 1.0, 1.0); +const easeOut = BezierEasing(0.0, 0.0, 0.58, 1.0); +const easeInOut = BezierEasing(0.42, 0.0, 0.58, 1.0); + +// easing functions +const easingFunctions: { + [key: string]: (value: number, before: boolean) => number; +} = { + linear: (value) => value, + ease: ease, + 'ease-in': easeIn, + 'ease-out': easeOut, + 'ease-in-out': easeInOut, +}; + +function getEasingFunctionFromString(easing: string) { + if (easingFunctions[easing]) { + return easingFunctions[easing]; + } + + // convert "cubic-bezier(x1, y1, x2, y2)" string to bezier easing function + if (easing.indexOf('cubic-bezier(') === 0) { + const bezierString = easing.replace('cubic-bezier(', '').replace(')', ''); + const bezierArray = bezierString.split(',').map(Number); + easingFunctions[easing] = BezierEasing( + bezierArray[0], + bezierArray[1], + bezierArray[2], + bezierArray[3] + ); + + return easingFunctions[easing]; + } + + // convert "steps(x)" string + if (easing.indexOf('steps(') === 0) { + throw new Error('steps() is not implemented yet'); + } + + throw new Error(`Unknown easing function "${easing}"`); +} + +export { getEasingFunctionFromString, easingFunctions }; diff --git a/src/mocks/web-animations-api/index.test.ts b/src/mocks/web-animations-api/index.test.ts index 25f896f..19b34be 100644 --- a/src/mocks/web-animations-api/index.test.ts +++ b/src/mocks/web-animations-api/index.test.ts @@ -1,7 +1,6 @@ import { mockAnimationsApi } from './index'; -import { expectTime } from './testHelpers'; -const { setDurationMultiplier } = mockAnimationsApi(); +mockAnimationsApi(); describe('Animations API', () => { it('should be defined', () => { @@ -36,34 +35,4 @@ describe('Animations API', () => { expect(element.getAnimations().length).toBe(0); expect(document.getAnimations().length).toBe(0); }); - - it('should set duration multiplier', async () => { - // speed up animations 10 times - setDurationMultiplier(0.1); - - const element = document.createElement('div'); - - const timeBefore = performance.now(); - const animation = element.animate({ opacity: 0 }, 1000); - - await animation.finished; - const timeAfter = performance.now(); - - expectTime(timeAfter - timeBefore, 100); - expectTime(animation.currentTime, 1000); - }); - - // this test should be run after the one that modifies the duration multiplier - it('should set duration multiplier back to the global after each test', async () => { - const element = document.createElement('div'); - - const timeBefore = performance.now(); - const animation = element.animate({ opacity: 0 }, 1000); - - await animation.finished; - const timeAfter = performance.now(); - - expectTime(timeAfter - timeBefore, 1000); - expectTime(animation.currentTime, 1000); - }); }); diff --git a/src/mocks/web-animations-api/index.ts b/src/mocks/web-animations-api/index.ts index d6628ed..d25b872 100644 --- a/src/mocks/web-animations-api/index.ts +++ b/src/mocks/web-animations-api/index.ts @@ -1,4 +1,4 @@ -import { setDurationMultiplier } from './Animation'; +import './Animation'; const elementAnimations = new Map(); @@ -42,15 +42,9 @@ function getAllAnimations() { return Array.from(elementAnimations.values()).flat(); } -type MockAnimationsApiOptions = { - durationMultiplier?: number; -}; - -function mockAnimationsApi({ - durationMultiplier = 1, -}: MockAnimationsApiOptions = {}) { - setDurationMultiplier(durationMultiplier); +// type MockAnimationsApiOptions = {}; +function mockAnimationsApi(/* {}: MockAnimationsApiOptions = {} */) { const savedAnimate = Element.prototype.animate; const savedGetAnimations = Element.prototype.getAnimations; const savedGetAllAnimations = Document.prototype.getAnimations; @@ -75,7 +69,6 @@ function mockAnimationsApi({ }); afterEach(() => { - setDurationMultiplier(durationMultiplier); elementAnimations.clear(); }); @@ -85,9 +78,7 @@ function mockAnimationsApi({ Document.prototype.getAnimations = savedGetAllAnimations; }); - return { - setDurationMultiplier, - }; + // return {}; } export { mockAnimationsApi }; diff --git a/src/mocks/web-animations-api/testHelpers.ts b/src/mocks/web-animations-api/testHelpers.ts index 2ad6223..5956932 100644 --- a/src/mocks/web-animations-api/testHelpers.ts +++ b/src/mocks/web-animations-api/testHelpers.ts @@ -5,3 +5,13 @@ export const expectTime = ( ) => { expect((time ?? 0) / 1000).toBeCloseTo(expected / 1000, 1); }; + +// export function flushPromises(): Promise { +// return new Promise(jest.requireActual('timers').setImmediate); +// } + +const tick = () => + new Promise((res) => jest.requireActual('timers').setImmediate(res)); + +export const advanceTimersByTime = async (time: number) => + jest.advanceTimersByTime(time) && (await tick()); diff --git a/src/mocks/web-animations-api/tests/cancel.test.ts b/src/mocks/web-animations-api/tests/cancel.test.ts new file mode 100644 index 0000000..606ab0d --- /dev/null +++ b/src/mocks/web-animations-api/tests/cancel.test.ts @@ -0,0 +1,69 @@ +import { playAnimation, FRAME_DURATION } from './tools'; +import { mockAnimationsApi } from '../index'; + +mockAnimationsApi(); + +jest.useFakeTimers(); + +describe('Animation', () => { + beforeEach(() => { + const syncShift = FRAME_DURATION - (performance.now() % FRAME_DURATION); + + jest.advanceTimersByTime(syncShift); + }); + + describe('cancel', () => { + it('it doesn\'t cancel if state is "idle"', () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + { transform: 'translateX(100px)' }, + 200 + ); + + const animation = new Animation(effect); + + const finishedPromise = animation.finished; + + animation.cancel(); + // animation.play(); + + // expect(animation.playState).toBe('running'); + expect(finishedPromise === animation.finished).toBe(true); + }); + + it('rejects the finished promise with an error, if state is "running"', (done) => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + { transform: 'translateX(100px)' }, + 200 + ); + + const animation = new Animation(effect); + + playAnimation(animation).then(() => { + const initialFinishedPromise = animation.finished; + + animation.finished.catch((error: unknown) => { + expect(error).toBeInstanceOf(DOMException); + + if (error instanceof DOMException) { + expect(error.name).toBe('AbortError'); + expect(error.message).toEqual('The user aborted a request.'); + } + + expect(animation.playState).toBe('idle'); + expect(animation.currentTime).toBeNull(); + expect(animation.finished !== initialFinishedPromise).toBe(true); + + done(); + }); + + animation.cancel(); + }); + }); + }); +}); diff --git a/src/mocks/web-animations-api/tests/commitStyles.test.ts b/src/mocks/web-animations-api/tests/commitStyles.test.ts new file mode 100644 index 0000000..bfee3c7 --- /dev/null +++ b/src/mocks/web-animations-api/tests/commitStyles.test.ts @@ -0,0 +1,940 @@ +import { + framesToTime, + playAnimation, + playAnimationInReverse, + FRAME_DURATION, +} from './tools'; +import { mockAnimationsApi } from '../index'; + +mockAnimationsApi(); + +jest.useFakeTimers(); + +describe('Animation', () => { + beforeEach(() => { + const syncShift = FRAME_DURATION - (performance.now() % FRAME_DURATION); + + jest.advanceTimersByTime(syncShift); + }); + + describe('commitStyles', () => { + const DELAY = framesToTime(6); + const DURATION = framesToTime(6); + const END_DELAY = framesToTime(6); + + describe('normal', () => { + it('no delays, 1 keyframe', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + { transform: 'translateX(100px)' }, + { duration: DURATION } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // active -> + await playAnimation(animation); + + expect(element.style.transform).toBe(''); + + // -> active | + jest.advanceTimersByTime(DURATION - 1); + + // console.log('-> active |', animation.currentTime, performance.now()); + + expect(element.style.transform).toBe('translateX(100px)'); + + // | finished + jest.advanceTimersByTime(1); + + await animation.finished; + + expect(element.style.transform).toBe(''); + }); + + it('no delays, 2 keyframes', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [ + { transform: 'translateX(50px)' }, + { transform: 'translateX(100px)' }, + ], + { duration: DURATION } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // active -> + await playAnimation(animation); + + expect(element.style.transform).toBe('translateX(50px)'); + + // -> active | + jest.advanceTimersByTime(DURATION - 1); + + // console.log('-> active |', animation.currentTime, performance.now()); + + expect(element.style.transform).toBe('translateX(100px)'); + + // | finished + jest.advanceTimersByTime(1); + + await animation.finished; + + expect(element.style.transform).toBe(''); + }); + + describe('fill, 1 keyframe', () => { + it('none', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + { transform: 'translateX(100px)' }, + { + delay: DELAY, + duration: DURATION, + endDelay: END_DELAY, + // fill defaults to "auto", which is "none" + } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // delay -> + await playAnimation(animation); + + expect(element.style.transform).toBe(''); + + // -> delay | active -> + jest.advanceTimersByTime(DELAY); + + expect(element.style.transform).toBe(''); + + // console.log('advancing by', DURATION - 1, 'from', performance.now()); + + // -> active | + jest.advanceTimersByTime(DURATION - 1); + + // console.log('-> active |', animation.currentTime, performance.now()); + + expect(element.style.transform).toBe('translateX(100px)'); + + // | endDelay -> + jest.advanceTimersByTime(1); + + // console.log('| endDelay ->', animation.currentTime, performance.now()); + + expect(element.style.transform).toBe(''); + + // -> endDelay | finished -> + jest.advanceTimersByTime(END_DELAY); + + expect(element.style.transform).toBe(''); + + await animation.finished; + + expect(element.style.transform).toBe(''); + }); + + it('backwards', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + { transform: 'translateX(100px)' }, + { + delay: DELAY, + duration: DURATION, + endDelay: END_DELAY, + fill: 'backwards', + } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // delay -> + await playAnimation(animation); + + expect(element.style.transform).toBe(''); + + // -> delay | active -> + jest.advanceTimersByTime(DELAY); + + expect(element.style.transform).toBe(''); + + // -> active | + jest.advanceTimersByTime(DURATION - 1); + + expect(element.style.transform).toBe('translateX(100px)'); + + // | endDelay -> + jest.advanceTimersByTime(1); + + expect(element.style.transform).toBe(''); + + // -> endDelay | finished -> + jest.advanceTimersByTime(END_DELAY); + + expect(element.style.transform).toBe(''); + + await animation.finished; + + expect(element.style.transform).toBe(''); + }); + + it('forwards', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + { transform: 'translateX(100px)' }, + { + delay: DELAY, + duration: DURATION, + endDelay: END_DELAY, + fill: 'forwards', + } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // delay -> + await playAnimation(animation); + + expect(element.style.transform).toBe(''); + + // -> delay | active -> + jest.advanceTimersByTime(DELAY); + + expect(element.style.transform).toBe(''); + + // -> active | + jest.advanceTimersByTime(DURATION - 1); + + expect(element.style.transform).toBe('translateX(100px)'); + + // | endDelay -> + jest.advanceTimersByTime(1); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> endDelay | finished -> + jest.advanceTimersByTime(END_DELAY); + + expect(element.style.transform).toBe('translateX(100px)'); + + await animation.finished; + + expect(element.style.transform).toBe('translateX(100px)'); + }); + + it('both', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + { transform: 'translateX(100px)' }, + { + delay: DELAY, + duration: DURATION, + endDelay: END_DELAY, + fill: 'both', + } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // delay -> + await playAnimation(animation); + + expect(element.style.transform).toBe(''); + + // -> delay | active -> + jest.advanceTimersByTime(DELAY); + + expect(element.style.transform).toBe(''); + + // -> active | endDelay -> + jest.advanceTimersByTime(DURATION - 1); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> active | endDelay -> + jest.advanceTimersByTime(1); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> endDelay | finished -> + jest.advanceTimersByTime(END_DELAY); + + expect(element.style.transform).toBe('translateX(100px)'); + + await animation.finished; + + expect(element.style.transform).toBe('translateX(100px)'); + }); + }); + + describe('fill, 2+ keyframes', () => { + it('none', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [ + { transform: 'translateX(50px)' }, + { transform: 'translateX(100px)' }, + ], + { + delay: DELAY, + duration: DURATION, + endDelay: END_DELAY, + // fill defaults to "auto", which is "none" + } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // delay -> + await playAnimation(animation); + + expect(element.style.transform).toBe(''); + + // -> delay | active -> + jest.advanceTimersByTime(DELAY); + + expect(element.style.transform).toBe('translateX(50px)'); + + // -> active | + jest.advanceTimersByTime(DURATION - 1); + + expect(element.style.transform).toBe('translateX(100px)'); + + // | endDelay -> + jest.advanceTimersByTime(1); + + expect(element.style.transform).toBe(''); + + // -> endDelay | finished -> + jest.advanceTimersByTime(END_DELAY); + + expect(element.style.transform).toBe(''); + + await animation.finished; + + expect(element.style.transform).toBe(''); + }); + + it('backwards', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [ + { transform: 'translateX(50px)' }, + { transform: 'translateX(100px)' }, + ], + { + delay: DELAY, + duration: DURATION, + endDelay: END_DELAY, + fill: 'backwards', + } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // delay -> + await playAnimation(animation); + + expect(element.style.transform).toBe('translateX(50px)'); + + // -> delay | active -> + jest.advanceTimersByTime(DELAY); + + expect(element.style.transform).toBe('translateX(50px)'); + + // -> active | + jest.advanceTimersByTime(DURATION - 1); + + expect(element.style.transform).toBe('translateX(100px)'); + + // | endDelay -> + jest.advanceTimersByTime(1); + + expect(element.style.transform).toBe(''); + + // -> endDelay | finished -> + jest.advanceTimersByTime(END_DELAY); + + expect(element.style.transform).toBe(''); + + await animation.finished; + + expect(element.style.transform).toBe(''); + }); + + it('forwards', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [ + { transform: 'translateX(50px)' }, + { transform: 'translateX(100px)' }, + ], + { + delay: DELAY, + duration: DURATION, + endDelay: END_DELAY, + fill: 'forwards', + } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // delay -> + await playAnimation(animation); + + expect(element.style.transform).toBe(''); + + // -> delay | active -> + jest.advanceTimersByTime(DELAY); + + expect(element.style.transform).toBe('translateX(50px)'); + + // -> active | endDelay -> + jest.advanceTimersByTime(DURATION - 1); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> active | endDelay -> + jest.advanceTimersByTime(1); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> endDelay | finished -> + jest.advanceTimersByTime(END_DELAY); + + expect(element.style.transform).toBe('translateX(100px)'); + + await animation.finished; + + expect(element.style.transform).toBe('translateX(100px)'); + }); + + it('both', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [ + { transform: 'translateX(50px)' }, + { transform: 'translateX(100px)' }, + ], + { + delay: DELAY, + duration: DURATION, + endDelay: END_DELAY, + fill: 'both', + } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // delay -> + await playAnimation(animation); + + expect(element.style.transform).toBe('translateX(50px)'); + + // -> delay | active -> + jest.advanceTimersByTime(DELAY); + + expect(element.style.transform).toBe('translateX(50px)'); + + // -> active | + jest.advanceTimersByTime(DURATION - 1); + + expect(element.style.transform).toBe('translateX(100px)'); + + // | endDelay -> + jest.advanceTimersByTime(1); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> endDelay | finished -> + jest.advanceTimersByTime(END_DELAY); + + expect(element.style.transform).toBe('translateX(100px)'); + + await animation.finished; + + expect(element.style.transform).toBe('translateX(100px)'); + }); + }); + }); + + describe('reversed', () => { + it('no delays, 1 keyframe', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + { transform: 'translateX(100px)' }, + { duration: DURATION } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // active -> + await playAnimationInReverse(animation); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> active | + jest.advanceTimersByTime(DURATION - 1); + + expect(element.style.transform).toBe(''); + + // | finished + jest.advanceTimersByTime(1); + + await animation.finished; + + expect(element.style.transform).toBe(''); + }); + + it('no delays, 2 keyframes', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [ + { transform: 'translateX(50px)' }, + { transform: 'translateX(100px)' }, + ], + { duration: DURATION } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // active -> + await playAnimationInReverse(animation); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> active | + jest.advanceTimersByTime(DURATION - 1); + + expect(element.style.transform).toBe('translateX(50px)'); + + // | finished + jest.advanceTimersByTime(1); + + await animation.finished; + + expect(element.style.transform).toBe(''); + }); + + describe('fill, 1 keyframe', () => { + it('none', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + { transform: 'translateX(100px)' }, + { + delay: DELAY, + duration: DURATION, + endDelay: END_DELAY, + // fill defaults to "auto", which is "none" + } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // delay -> + await playAnimationInReverse(animation); + + expect(element.style.transform).toBe(''); + + // -> delay | active -> + jest.advanceTimersByTime(DELAY); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> active | endDelay -> + jest.advanceTimersByTime(DURATION); + + expect(element.style.transform).toBe(''); + + // -> endDelay | finished -> + jest.advanceTimersByTime(END_DELAY); + + expect(element.style.transform).toBe(''); + + await animation.finished; + + expect(element.style.transform).toBe(''); + }); + + it('backwards', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + { transform: 'translateX(100px)' }, + { + delay: DELAY, + duration: DURATION, + endDelay: END_DELAY, + fill: 'backwards', + } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // delay -> + await playAnimationInReverse(animation); + + expect(element.style.transform).toBe(''); + + // -> delay | active -> + jest.advanceTimersByTime(DELAY); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> active | endDelay -> + jest.advanceTimersByTime(DURATION); + + expect(element.style.transform).toBe(''); + + // -> endDelay | finished -> + jest.advanceTimersByTime(END_DELAY); + + expect(element.style.transform).toBe(''); + + await animation.finished; + + expect(element.style.transform).toBe(''); + }); + + it('forwards', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + { transform: 'translateX(100px)' }, + { + delay: DELAY, + duration: DURATION, + endDelay: END_DELAY, + fill: 'forwards', + } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // delay -> + // await playAnimation(animation); + await playAnimationInReverse(animation); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> delay | active -> + jest.advanceTimersByTime(DELAY); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> active | endDelay -> + jest.advanceTimersByTime(DURATION); + + expect(element.style.transform).toBe(''); + + // -> endDelay | finished -> + jest.advanceTimersByTime(END_DELAY); + + expect(element.style.transform).toBe(''); + + await animation.finished; + + expect(element.style.transform).toBe(''); + }); + + it('both', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + { transform: 'translateX(100px)' }, + { + delay: DELAY, + duration: DURATION, + endDelay: END_DELAY, + fill: 'both', + } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // delay -> + await playAnimationInReverse(animation); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> delay | active -> + jest.advanceTimersByTime(DELAY); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> active | endDelay -> + jest.advanceTimersByTime(DURATION); + + expect(element.style.transform).toBe(''); + + // -> endDelay | finished -> + jest.advanceTimersByTime(END_DELAY); + + expect(element.style.transform).toBe(''); + + await animation.finished; + + expect(element.style.transform).toBe(''); + }); + }); + + describe('fill, 2+ keyframes', () => { + it('none', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [ + { transform: 'translateX(50px)' }, + { transform: 'translateX(100px)' }, + ], + { + delay: DELAY, + duration: DURATION, + endDelay: END_DELAY, + // fill defaults to "auto", which is "none" + } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // delay -> + await playAnimationInReverse(animation); + + expect(element.style.transform).toBe(''); + + // -> delay | active -> + jest.advanceTimersByTime(DELAY); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> active | endDelay -> + jest.advanceTimersByTime(DURATION - 1); + + expect(element.style.transform).toBe('translateX(50px)'); + + // -> active | endDelay -> + jest.advanceTimersByTime(1); + + expect(element.style.transform).toBe(''); + + // -> endDelay | finished -> + jest.advanceTimersByTime(END_DELAY); + + expect(element.style.transform).toBe(''); + + await animation.finished; + + expect(element.style.transform).toBe(''); + }); + + it('backwards', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [ + { transform: 'translateX(50px)' }, + { transform: 'translateX(100px)' }, + ], + { + delay: DELAY, + duration: DURATION, + endDelay: END_DELAY, + fill: 'backwards', + } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // delay -> + await playAnimationInReverse(animation); + + expect(element.style.transform).toBe(''); + + // -> delay | active -> + jest.advanceTimersByTime(DELAY); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> active | endDelay -> + jest.advanceTimersByTime(DURATION); + + expect(element.style.transform).toBe('translateX(50px)'); + + // -> endDelay | finished -> + jest.advanceTimersByTime(END_DELAY); + + expect(element.style.transform).toBe('translateX(50px)'); + + await animation.finished; + + expect(element.style.transform).toBe('translateX(50px)'); + }); + + it('forwards', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [ + { transform: 'translateX(50px)' }, + { transform: 'translateX(100px)' }, + ], + { + delay: DELAY, + duration: DURATION, + endDelay: END_DELAY, + fill: 'forwards', + } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // delay -> + await playAnimationInReverse(animation); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> delay | active -> + jest.advanceTimersByTime(DELAY); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> active | endDelay -> + jest.advanceTimersByTime(DURATION - 1); + + expect(element.style.transform).toBe('translateX(50px)'); + + // -> active | endDelay -> + jest.advanceTimersByTime(1); + + expect(element.style.transform).toBe(''); + + // -> endDelay | finished -> + jest.advanceTimersByTime(END_DELAY); + + expect(element.style.transform).toBe(''); + + await animation.finished; + + expect(element.style.transform).toBe(''); + }); + + it('both', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [ + { transform: 'translateX(50px)' }, + { transform: 'translateX(100px)' }, + ], + { + delay: DELAY, + duration: DURATION, + endDelay: END_DELAY, + fill: 'both', + } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // delay -> + await playAnimationInReverse(animation); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> delay | active -> + jest.advanceTimersByTime(DELAY); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> active | endDelay -> + jest.advanceTimersByTime(DURATION); + + expect(element.style.transform).toBe('translateX(50px)'); + + // -> endDelay | finished -> + jest.advanceTimersByTime(END_DELAY); + + expect(element.style.transform).toBe('translateX(50px)'); + + await animation.finished; + + expect(element.style.transform).toBe('translateX(50px)'); + }); + }); + }); + }); +}); diff --git a/src/mocks/web-animations-api/tests/effect.test.ts b/src/mocks/web-animations-api/tests/effect.test.ts new file mode 100644 index 0000000..b785b58 --- /dev/null +++ b/src/mocks/web-animations-api/tests/effect.test.ts @@ -0,0 +1,139 @@ +import { playAnimation, FRAME_DURATION } from './tools'; +import { mockAnimationsApi } from '../index'; + +mockAnimationsApi(); + +jest.useFakeTimers(); + +describe('Animation', () => { + beforeEach(() => { + const syncShift = FRAME_DURATION - (performance.now() % FRAME_DURATION); + + jest.advanceTimersByTime(syncShift); + }); + + describe('effect', () => { + it('should calculate computed timing', () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], + { + duration: 3000, + fill: 'forwards', + iterations: 2, + delay: 100, + endDelay: 300, + } + ); + const animation = new Animation(effect); + + expect(animation.effect?.getComputedTiming()).toEqual({ + activeDuration: 6000, + currentIteration: null, + delay: 100, + direction: 'normal', + duration: 3000, + easing: 'linear', + endDelay: 300, + endTime: 6400, + fill: 'forwards', + iterationStart: 0, + iterations: 2, + localTime: null, + progress: null, + }); + }); + + describe('should calculate localTime and progress correctly', () => { + it('when just created', () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], + { + duration: 3000, + fill: 'forwards', + iterations: 2, + delay: 100, + endDelay: 300, + } + ); + const animation = new Animation(effect); + + expect(animation.effect?.getComputedTiming()).toEqual( + expect.objectContaining({ + localTime: null, + progress: null, + }) + ); + }); + + it('when running', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], + { + duration: 200, + fill: 'forwards', + iterations: 2, + delay: 100, + } + ); + + const animation = new Animation(effect); + + await playAnimation(animation); + // animation.play(); + // jest.advanceTimersByTime(0); + // await expect(animation.ready).resolves.toBeInstanceOf(Animation); + + jest.advanceTimersByTime(50); + + expect(animation.currentTime).toBe(50); + expect(animation.effect?.getComputedTiming().localTime).toBe(50); + + jest.advanceTimersByTime(50); + + expect(animation.currentTime).toBe(100); + + // first iteration starts, progress should be 0, localTime should be equal to "delay" + expect(animation.effect?.getComputedTiming().progress).toBeCloseTo( + 0, + 1 + ); + expect(animation.effect?.getComputedTiming().localTime).toBe(100); + + // 100ms after that we're still in the first iteration + // progress should be 0.5, localTime should be equal to "delay" + "duration" / 2 + jest.advanceTimersByTime(100); + + expect(animation.effect?.getComputedTiming().progress).toBeCloseTo( + 0.5, + 1 + ); + expect(animation.effect?.getComputedTiming().localTime).toBe(200); + + // 200ms after that we're in the middle of the second iteration + // progress should be 0.5, localTime should be "delay" + "duration" + "duration" / 2 + jest.advanceTimersByTime(200); + + expect(animation.effect?.getComputedTiming().progress).toBeCloseTo( + 0.5, + 1 + ); + expect(animation.effect?.getComputedTiming().localTime).toBe(400); + + jest.advanceTimersByTime(200); + + await animation.finished; + + expect(animation.currentTime).toBe(500); + }); + }); + }); +}); diff --git a/src/mocks/web-animations-api/tests/events.test.ts b/src/mocks/web-animations-api/tests/events.test.ts new file mode 100644 index 0000000..ae68bc4 --- /dev/null +++ b/src/mocks/web-animations-api/tests/events.test.ts @@ -0,0 +1,69 @@ +import { playAnimation, FRAME_DURATION } from './tools'; +import { mockAnimationsApi } from '../index'; + +mockAnimationsApi(); + +jest.useFakeTimers(); + +describe('Animation', () => { + beforeEach(() => { + const syncShift = FRAME_DURATION - (performance.now() % FRAME_DURATION); + + jest.advanceTimersByTime(syncShift); + }); + + describe('events', () => { + it('should fire finish events', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + { transform: 'translateX(0)' }, + 100 + ); + + const animation = new Animation(effect); + + const onfinish = jest.fn(); + + animation.onfinish = onfinish; + animation.addEventListener('finish', onfinish); + + await playAnimation(animation); + + jest.advanceTimersByTime(150); + + await expect(animation.finished).resolves.toBeInstanceOf(Animation); + + expect(onfinish).toHaveBeenCalledTimes(2); + }); + + it('should fire cancel events', (done) => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + { transform: 'translateX(0)' }, + 100 + ); + + const animation = new Animation(effect); + + const oncancel = jest.fn(); + + animation.oncancel = oncancel; + animation.addEventListener('cancel', oncancel); + + animation.play(); + + animation.finished.catch(() => { + expect(oncancel).toHaveBeenCalledTimes(2); + done(); + }); + + jest.advanceTimersByTime(50); + + animation.cancel(); + }); + }); +}); diff --git a/src/mocks/web-animations-api/tests/main.test.ts b/src/mocks/web-animations-api/tests/main.test.ts new file mode 100644 index 0000000..a9743d0 --- /dev/null +++ b/src/mocks/web-animations-api/tests/main.test.ts @@ -0,0 +1,107 @@ +import { MockedAnimation } from '../Animation'; +import { mockAnimationsApi } from '../index'; + +mockAnimationsApi(); + +jest.useFakeTimers(); + +describe('Animation', () => { + describe('constructor', () => { + it('should be defined', () => { + const animation = new Animation(); + + expect(animation).toBeInstanceOf(MockedAnimation); + }); + + it('should have correct properties if no keyframe effect is provided', async () => { + const animation = new Animation(); + + expect(animation.currentTime).toBeNull(); + expect(animation.effect).toBeNull(); + expect(animation.finished).toBeInstanceOf(Promise); + expect(animation.id).toBe(''); + expect(animation.oncancel).toBeNull(); + expect(animation.onfinish).toBeNull(); + expect(animation.onremove).toBeNull(); + expect(animation.pending).toBe(false); + expect(animation.playState).toBe('idle'); + expect(animation.playbackRate).toBe(1); + expect(await animation.ready).toBe(animation); + expect(animation.replaceState).toBe('active'); + expect(animation.startTime).toBeNull(); + expect(animation.timeline).toBeInstanceOf(AnimationTimeline); + }); + + it('should have correct properties if finite keyframe effect is provided', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], + { + duration: 3000, + fill: 'forwards', + iterations: 2, + delay: 100, + endDelay: 300, + } + ); + const animation = new Animation(effect); + + expect(animation.currentTime).toBeNull(); + expect(animation.effect).toBe(effect); + expect(animation.finished).toBeInstanceOf(Promise); + expect(animation.id).toBe(''); + expect(animation.oncancel).toBeNull(); + expect(animation.onfinish).toBeNull(); + expect(animation.onremove).toBeNull(); + expect(animation.pending).toBe(false); + expect(animation.playState).toBe('idle'); + expect(animation.playbackRate).toBe(1); + expect(await animation.ready).toBe(animation); + expect(animation.replaceState).toBe('active'); + expect(animation.startTime).toBeNull(); + expect(animation.timeline).toBeInstanceOf(AnimationTimeline); + }); + }); + + describe('currentTime', () => { + it('should be null if no keyframe effect is provided', () => { + const animation = new Animation(); + + expect(animation.currentTime).toBeNull(); + }); + + it('should be null if infinite keyframe effect is provided', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], + { duration: 100, iterations: Infinity } + ); + + const animation = new Animation(effect); + + expect(animation.currentTime).toBeNull(); + }); + }); + + describe('finish', () => { + it('throws an InvalidStateError when finishing an infinite animation', () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], + { duration: 100, iterations: Infinity } + ); + + const animation = new Animation(effect); + + expect(() => animation.finish()).toThrowError( + "Failed to execute 'finish' on 'Animation': Cannot finish Animation with an infinite target effect end." + ); + }); + }); +}); diff --git a/src/mocks/web-animations-api/tests/pause.test.ts b/src/mocks/web-animations-api/tests/pause.test.ts new file mode 100644 index 0000000..b2b36b1 --- /dev/null +++ b/src/mocks/web-animations-api/tests/pause.test.ts @@ -0,0 +1,138 @@ +import { framesToTime, playAnimation, FRAME_DURATION } from './tools'; +import { mockAnimationsApi } from '../index'; + +mockAnimationsApi(); + +jest.useFakeTimers(); + +describe('Animation', () => { + beforeEach(() => { + const syncShift = FRAME_DURATION - (performance.now() % FRAME_DURATION); + + jest.advanceTimersByTime(syncShift); + }); + + describe('pause', () => { + test('during before', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], + { + duration: 200, + fill: 'forwards', + delay: 100, + } + ); + + const animation = new Animation(effect); + + await playAnimation(animation); + + jest.advanceTimersByTime(50); + + animation.pause(); + expect(animation.playState).toBe('paused'); + expect(animation.currentTime).toEqual(50); + + jest.advanceTimersByTime(50); + + expect(animation.playState).toBe('paused'); + expect(animation.currentTime).toEqual(50); + + await playAnimation(animation); + + expect(animation.playState).toBe('running'); + + jest.advanceTimersByTime(50); + + expect(animation.currentTime).toEqual(100); + + jest.advanceTimersByTime(200 + framesToTime(1)); + + expect(animation.playState).toBe('finished'); + + await animation.finished; + + expect(animation.currentTime).toEqual(300); + }); + + test('during active', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], + { + duration: 200, + fill: 'forwards', + delay: 100, + } + ); + + const animation = new Animation(effect); + + await playAnimation(animation); + + jest.advanceTimersByTime(150); + + animation.pause(); + expect(animation.playState).toBe('paused'); + expect(animation.currentTime).toBe(150); + + jest.advanceTimersByTime(50); + + expect(animation.playState).toBe('paused'); + expect(animation.currentTime).toBe(150); + + await playAnimation(animation); + + expect(animation.playState).toBe('running'); + + jest.advanceTimersByTime(200); + + await animation.finished; + + expect(animation.currentTime).toBe(300); + }); + + test('during after', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }], + { + duration: 200, + fill: 'forwards', + delay: 100, + endDelay: 100, + } + ); + + const animation = new Animation(effect); + + await playAnimation(animation); + + jest.advanceTimersByTime(350); + + animation.pause(); + expect(animation.playState).toBe('paused'); + expect(animation.currentTime).toBe(350); + + jest.advanceTimersByTime(50); + + expect(animation.playState).toBe('paused'); + expect(animation.currentTime).toBe(350); + + await playAnimation(animation); + + expect(animation.playState).toBe('running'); + + jest.advanceTimersByTime(200); + + await animation.finished; + }); + }); +}); diff --git a/src/mocks/web-animations-api/tests/reverse.test.ts b/src/mocks/web-animations-api/tests/reverse.test.ts new file mode 100644 index 0000000..4d575d4 --- /dev/null +++ b/src/mocks/web-animations-api/tests/reverse.test.ts @@ -0,0 +1,274 @@ +import { + framesToTime, + playAnimation, + playAnimationInReverse, + updateAnimationPlaybackRate, + FRAME_DURATION, +} from './tools'; +import { mockAnimationsApi } from '../index'; + +mockAnimationsApi(); + +jest.useFakeTimers(); + +describe('Animation', () => { + beforeEach(() => { + const syncShift = FRAME_DURATION - (performance.now() % FRAME_DURATION); + + jest.advanceTimersByTime(syncShift); + }); + + describe('reverse', () => { + describe('normal', () => { + test('during active', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [ + { transform: 'translateX(50px)' }, + { transform: 'translateX(75px)' }, + { transform: 'translateX(100px)' }, + ], + { duration: framesToTime(6) } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // active -> + await playAnimation(animation); + + expect(element.style.transform).toBe('translateX(50px)'); + + // -> half way | + jest.advanceTimersByTime(framesToTime(3)); + + expect(element.style.transform).toBe('translateX(75px)'); + + // half way -> back to start | + await playAnimationInReverse(animation); + + await animation.ready; + + jest.advanceTimersByTime(framesToTime(3) - 1); + + expect(element.style.transform).toBe('translateX(50px)'); + + jest.advanceTimersByTime(1); + + await animation.finished; + + expect(element.style.transform).toBe(''); + }); + + test('by setting playbackRatio', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [ + { transform: 'translateX(50px)' }, + { transform: 'translateX(75px)' }, + { transform: 'translateX(100px)' }, + ], + { duration: framesToTime(6) } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // active -> + await playAnimation(animation); + + expect(element.style.transform).toBe('translateX(50px)'); + + // -> half way | + jest.advanceTimersByTime(framesToTime(3)); + + expect(element.style.transform).toBe('translateX(75px)'); + + // half way -> back to start | + animation.playbackRate = -animation.playbackRate; + + jest.advanceTimersByTime(framesToTime(3) - 1); + + expect(element.style.transform).toBe('translateX(50px)'); + + jest.advanceTimersByTime(1); + + await animation.finished; + + expect(element.style.transform).toBe(''); + }); + + test('by calling updatePlaybackRate', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [ + { transform: 'translateX(50px)' }, + { transform: 'translateX(75px)' }, + { transform: 'translateX(100px)' }, + ], + { duration: framesToTime(6) } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // active -> + await playAnimation(animation); + + expect(element.style.transform).toBe('translateX(50px)'); + + // -> half way | + jest.advanceTimersByTime(framesToTime(3)); + + expect(element.style.transform).toBe('translateX(75px)'); + + // half way -> back to start | + await updateAnimationPlaybackRate(animation, -1); + + jest.advanceTimersByTime(framesToTime(3) - 1); + + expect(element.style.transform).toBe('translateX(50px)'); + + jest.advanceTimersByTime(1); + + await animation.finished; + + expect(element.style.transform).toBe(''); + }); + }); + + describe('reversed', () => { + test('during active', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [ + { transform: 'translateX(50px)' }, + { transform: 'translateX(75px)' }, + { transform: 'translateX(100px)' }, + ], + { duration: framesToTime(6) } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // active -> + await playAnimationInReverse(animation); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> half way | + jest.advanceTimersByTime(framesToTime(3)); + + expect(element.style.transform).toBe('translateX(75px)'); + + // half way -> back to start | + await playAnimationInReverse(animation); + + jest.advanceTimersByTime(framesToTime(3) - 1); + + expect(element.style.transform).toBe('translateX(100px)'); + + jest.advanceTimersByTime(1); + + await animation.finished; + + expect(element.style.transform).toBe(''); + }); + + test('by setting playbackRatio', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [ + { transform: 'translateX(50px)' }, + { transform: 'translateX(75px)' }, + { transform: 'translateX(100px)' }, + ], + { duration: framesToTime(6) } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // active -> + await playAnimationInReverse(animation); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> half way | + jest.advanceTimersByTime(framesToTime(3)); + + expect(element.style.transform).toBe('translateX(75px)'); + + // half way -> back to start | + animation.playbackRate = -animation.playbackRate; + + jest.advanceTimersByTime(framesToTime(3) - 1); + + expect(element.style.transform).toBe('translateX(100px)'); + + jest.advanceTimersByTime(1); + + await animation.finished; + + expect(element.style.transform).toBe(''); + }); + + test('by calling updatePlaybackRate', async () => { + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + [ + { transform: 'translateX(50px)' }, + { transform: 'translateX(75px)' }, + { transform: 'translateX(100px)' }, + ], + { duration: framesToTime(6) } + ); + + const animation = new Animation(effect); + + expect(element.style.transform).toBe(''); + + // active -> + await playAnimationInReverse(animation); + + expect(element.style.transform).toBe('translateX(100px)'); + + // -> half way | + jest.advanceTimersByTime(framesToTime(3)); + + expect(element.style.transform).toBe('translateX(75px)'); + + // half way -> back to start | + await updateAnimationPlaybackRate(animation, 1); + + jest.advanceTimersByTime(framesToTime(3) - 1); + + expect(element.style.transform).toBe('translateX(100px)'); + + jest.advanceTimersByTime(1); + + await animation.finished; + + expect(element.style.transform).toBe(''); + }); + }); + }); +}); diff --git a/src/mocks/web-animations-api/tests/tools.ts b/src/mocks/web-animations-api/tests/tools.ts new file mode 100644 index 0000000..96d6838 --- /dev/null +++ b/src/mocks/web-animations-api/tests/tools.ts @@ -0,0 +1,32 @@ +async function playAnimation(animation: Animation) { + animation.play(); + jest.advanceTimersByTime(0); + await expect(animation.ready); +} + +async function playAnimationInReverse(animation: Animation) { + animation.reverse(); + jest.advanceTimersByTime(0); + await expect(animation.ready); +} + +async function updateAnimationPlaybackRate(animation: Animation, rate: number) { + animation.updatePlaybackRate(rate); + jest.advanceTimersByTime(0); + await expect(animation.ready); +} + +// https://github.com/sinonjs/fake-timers/blob/3a77a0978eaccd73ccc87dd42204b54e2bac0f6f/src/fake-timers-src.js#L1066 +const FRAME_DURATION = 16; + +function framesToTime(frames: number) { + return frames * FRAME_DURATION; +} + +export { + playAnimation, + playAnimationInReverse, + updateAnimationPlaybackRate, + FRAME_DURATION, + framesToTime, +}; diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index 47770b9..0000000 --- a/vitest.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -/// - -// Configure Vitest (https://vitest.dev/config/) - -import { defineConfig } from 'vite'; - -export default defineConfig({ - test: { - /* for example, use global to avoid globals imports (describe, test, expect): */ - globals: true, - environment: 'jsdom', - }, -});