├── .github └── workflows │ ├── npm-publish.yml │ ├── redeploy-docs.yml │ └── version-update.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── animations.ts └── index.ts ├── tests ├── config │ ├── vitest.config.ts │ └── vitest.setup.ts └── unit │ ├── animations.test.ts │ └── plugin.test.ts └── tsconfig.json /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow publishes a package to NPM 2 | 3 | name: Publish package 4 | 5 | on: 6 | release: 7 | types: [published] 8 | 9 | jobs: 10 | publish-npm: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: 16 17 | registry-url: https://registry.npmjs.org 18 | - run: npm ci 19 | - run: npm publish --access public 20 | env: 21 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 22 | -------------------------------------------------------------------------------- /.github/workflows/redeploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: Redeploy Docs 2 | on: 3 | push: 4 | branches: [master] 5 | 6 | jobs: 7 | redeploy-docs: 8 | uses: swup/.github/.github/workflows/redeploy-docs.yml@master 9 | secrets: inherit 10 | -------------------------------------------------------------------------------- /.github/workflows/version-update.yml: -------------------------------------------------------------------------------- 1 | # This workflow bumps the package.json version and creates a PR 2 | 3 | name: Update package version 4 | 5 | on: 6 | workflow_dispatch: 7 | inputs: 8 | segment: 9 | description: 'Semver segment to update' 10 | required: true 11 | default: 'patch' 12 | type: choice 13 | options: 14 | - minor 15 | - patch 16 | 17 | jobs: 18 | update-version: 19 | name: Update package version 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v2 23 | - uses: actions/setup-node@v2 24 | with: 25 | node-version: 16 26 | - run: echo "Updating ${{ inputs.segment }} version" 27 | - name: Update version 28 | run: npm --no-git-tag-version version ${{ inputs.segment }} 29 | - uses: peter-evans/create-pull-request@v4 30 | with: 31 | base: 'master' 32 | branch: 'version/automated' 33 | title: 'Update package version (automated)' 34 | commit-message: 'Update package version' 35 | body: 'Automated update to ${{ inputs.segment }} version' 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.DS_Store 3 | !.gitignore 4 | !.htaccess 5 | !web.config 6 | node_modules 7 | bower_components 8 | Thumbs.db 9 | wiki-common 10 | wiki-images files 11 | wiki-wishlist 12 | *.sublime-project 13 | *.sublime-workspace 14 | .editorconfig 15 | .idea 16 | /dist 17 | /tests/fixtures/dist 18 | /tests/reports 19 | /tests/results 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [3.2.0] - 2024-07-22 4 | 5 | - Implement tests 6 | - Refactor for testability 7 | 8 | ## [3.1.1] - 2023-07-30 9 | 10 | - Fix missing `dist/` folder 11 | 12 | ## [3.1.0] - 2023-07-30 13 | 14 | - Port to TypeScript 15 | 16 | ## [3.0.0] - 2023-07-26 17 | 18 | - Allow returning Promises and async/await animation functions 19 | - Breaking: the first matching rule is used 20 | - Update for swup 4 compatibility 21 | 22 | ## [2.0.0] - 2023-03-13 23 | 24 | - Switch to microbundle 25 | - Export native ESM module 26 | 27 | ## [1.0.5] - 2022-08-21 28 | 29 | - Bail early if no animation found 30 | 31 | ## [1.0.4] - 2022-08-15 32 | 33 | - Remove reference to global swup instance 34 | 35 | ## [1.0.3] - 2019-05-23 36 | 37 | - Improve how animations are ranked 38 | 39 | ## [1.0.2] - 2019-05-13 40 | 41 | - Fix plugin name 42 | 43 | ## [1.0.1] - 2019-05-02 44 | 45 | - Update readme 46 | 47 | ## [1.0.0] - 2019-05-02 48 | 49 | - Initial release 50 | 51 | [3.2.0]: https://github.com/swup/js-plugin/releases/tag/3.2.0 52 | [3.1.1]: https://github.com/swup/js-plugin/releases/tag/3.1.1 53 | [3.1.0]: https://github.com/swup/js-plugin/releases/tag/3.1.0 54 | [3.0.0]: https://github.com/swup/js-plugin/releases/tag/3.0.0 55 | [2.0.0]: https://github.com/swup/js-plugin/releases/tag/2.0.0 56 | [1.0.5]: https://github.com/swup/js-plugin/releases/tag/1.0.5 57 | [1.0.4]: https://github.com/swup/js-plugin/releases/tag/1.0.4 58 | [1.0.3]: https://github.com/swup/js-plugin/releases/tag/1.0.3 59 | [1.0.2]: https://github.com/swup/js-plugin/releases/tag/1.0.2 60 | [1.0.1]: https://github.com/swup/js-plugin/releases/tag/1.0.1 61 | [1.0.0]: https://github.com/swup/js-plugin/releases/tag/1.0.0 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Georgy Marchuk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Swup JS Plugin 2 | 3 | A [swup](https://swup.js.org) plugin for managing animations in JS. 4 | 5 | - Use JavaScript for timing animations instead of CSS 6 | - Successor to the deprecated [swupjs](https://github.com/swup/swupjs) library 7 | 8 | ## Installation 9 | 10 | Install the plugin from npm and import it into your bundle. 11 | 12 | ```bash 13 | npm install @swup/js-plugin 14 | ``` 15 | 16 | ```js 17 | import SwupJsPlugin from '@swup/js-plugin'; 18 | ``` 19 | 20 | Or include the minified production file from a CDN: 21 | 22 | ```html 23 | 24 | ``` 25 | 26 | ## Usage 27 | 28 | To run this plugin, include an instance in the swup options. 29 | 30 | ```js 31 | const swup = new Swup({ 32 | plugins: [ 33 | new SwupJsPlugin({ animations: [ /* your custom animation functions */ ] }) 34 | ] 35 | }); 36 | ``` 37 | 38 | ## Options 39 | 40 | The plugin expects an `array` of animation objects. 41 | The example below is the default setup and defines two animations, where `out` is the 42 | animation function being executed before the content is being replaced, and `in` is 43 | the animation being executed after the content is replaced: 44 | 45 | ```js 46 | { 47 | animations: [ 48 | { 49 | from: '(.*)', // matches any route 50 | to: '(.*)', // matches any route 51 | out: done => done(), // immediately continues 52 | in: done => done() // immediately continues 53 | } 54 | ] 55 | } 56 | ``` 57 | 58 | This is also the fallback animation in case no other matching animations were found. 59 | 60 | Animations are chosen based on the `from` and `to` properties of the object, which are 61 | compared against the current visit (urls of current and next page). 62 | Learn more on [choosing the animation](#choosing-the-animation) below. 63 | 64 | ## Animation function 65 | 66 | The animation function is executed for each corresponding animation phase. Inside the animation 67 | function, you manage the animation yourself and signal when it has finished. It receives two 68 | arguments: a `done` function and a `data` object. 69 | 70 | ```js 71 | out: (done, data) => { 72 | // Signal the end of the animation by calling done() 73 | // Access info about the animation inside the data argument 74 | } 75 | ``` 76 | 77 | ### Signaling the end of an animation 78 | 79 | Calling the `done()` function signals to swup that the animation has finished and it can proceed 80 | to the next step: replacing the content or finishing the visit. You can pass along the `done()` 81 | function as a callback to your animation library. The example below will wait for two seconds before replacing the content. 82 | 83 | ```js 84 | out: (done) => { 85 | setTimeout(done, 2000); 86 | } 87 | ``` 88 | 89 | #### Promises and async/await 90 | 91 | If your animation library returns Promises, you can also return the Promise directly from your 92 | animation function. Swup will consider the animation to be finished when the Promise resolves. 93 | The `done` function is then no longer required. 94 | 95 | ```js 96 | out: () => { 97 | return myAnimationLibrary.animate(/* */).then(() => {}); 98 | } 99 | ``` 100 | 101 | This also allows `async/await` syntax for convenience. 102 | 103 | ```js 104 | out: async () => { 105 | await myAnimationLibrary.animate(/* */); 106 | } 107 | ``` 108 | 109 | ### Data object 110 | 111 | The second parameter is an object that contains useful data about the animation, such as the visit 112 | object (containing actual before/after routes), the `from` and `to` parameters of the 113 | animation object, and the route params. 114 | 115 | ```js 116 | { 117 | visit: { /* */ }, // swup global visit object 118 | direction: 'in', 119 | from: { 120 | url: '/', 121 | pattern: '(.*)', 122 | params: {} 123 | }, 124 | to: { 125 | url: '/about', 126 | pattern: '(.*)', 127 | params: {} 128 | } 129 | } 130 | ``` 131 | 132 | ## Examples 133 | 134 | Basic usage examples for a fade transition implemented in popular animation libraries: 135 | 136 | ### [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API) 137 | 138 | ```js 139 | { 140 | from: '(.*)', 141 | to: '(.*)', 142 | in: async () => { 143 | const container = document.querySelector('#swup'); 144 | await container.animate([{ opacity: 0 }, { opacity: 1 }], 250).finished; 145 | }, 146 | out: async () => { 147 | const container = document.querySelector('#swup'); 148 | await container.animate([{ opacity: 1 }, { opacity: 0 }], 250).finished; 149 | } 150 | } 151 | ``` 152 | 153 | ### [GSAP](https://greensock.com/gsap/) 154 | 155 | ```js 156 | { 157 | from: '(.*)', 158 | to: '(.*)', 159 | out: async () => { 160 | await gsap.to('#swup', { opacity: 0, duration: 0.25 }); 161 | }, 162 | in: async () => { 163 | await gsap.fromTo('#swup', { opacity: 0 }, { opacity: 1, duration: 0.25 }); 164 | } 165 | } 166 | ``` 167 | 168 | ### [anime.js](https://animejs.com/) 169 | 170 | ```js 171 | { 172 | from: '(.*)', 173 | to: '(.*)', 174 | out: async () => { 175 | await anime({ targets: '#swup', opacity: 0, duration: 250, easing: 'linear' }).finished; 176 | }, 177 | in: async () => { 178 | await anime({ targets: '#swup', opacity: [0, 1], duration: 250, easing: 'linear' }).finished; 179 | } 180 | } 181 | ``` 182 | 183 | ## Choosing the animation 184 | 185 | As mentioned above, the animation is chosen based on the `from` and `to` properties of the animation object. 186 | Those properties can take several forms: 187 | 188 | - a string (matching a route exactly) 189 | - a regular expression 190 | - A route pattern like `/foo/:bar`) parsed by [path-to-regexp](https://github.com/pillarjs/path-to-regexp) 191 | - a custom animation name taken from the `data-swup-animation` attribute of the clicked link 192 | 193 | The most fitting route is always chosen. 194 | Keep in mind, that two routes can be evaluated as "same fit". 195 | In this case, the first one defined in the options is used, so usually you would like to define the more specific routes first. 196 | See the example below for more info. 197 | 198 | ```js 199 | [ 200 | // animation 1 201 | { from: '/', to: 'custom' }, 202 | // animation 2 203 | { from: '/', to: '/post' }, 204 | // animation 3 205 | { from: '/', to: '/post/:id' }, 206 | // animation 4 207 | { from: '/', to: /pos(.*)/ }, 208 | // animation 5 209 | { from: '(.*)', to: '(.*)' }, 210 | ]; 211 | ``` 212 | 213 | - from `/` to `/post` → animation **2** 214 | - from `/` to `/posting` → animation **4** 215 | - from `/` to `/post/12` → animation **3** 216 | - from `/` to `/some-route` → animation **5** 217 | - from `/` to `/post` with `data-swup-animation="custom"` → animation **1** 218 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@swup/js-plugin", 3 | "amdName": "SwupJsPlugin", 4 | "version": "3.2.0", 5 | "description": "A swup plugin for managing animations in JS", 6 | "type": "module", 7 | "source": "src/index.ts", 8 | "main": "./dist/index.cjs", 9 | "module": "./dist/index.module.js", 10 | "unpkg": "./dist/index.umd.js", 11 | "types": "./dist/index.d.ts", 12 | "exports": { 13 | ".": { 14 | "types": "./dist/index.d.ts", 15 | "import": "./dist/index.modern.js", 16 | "require": "./dist/index.cjs" 17 | } 18 | }, 19 | "files": [ 20 | "dist" 21 | ], 22 | "scripts": { 23 | "build": "swup package:build", 24 | "dev": "swup package:dev", 25 | "lint": "swup package:lint", 26 | "format": "swup package:format", 27 | "prepublishOnly": "npm run build", 28 | "test": "npm run test:unit", 29 | "test:unit": "vitest run --config ./tests/config/vitest.config.ts", 30 | "test:unit:watch": "vitest --config ./tests/config/vitest.config.ts" 31 | }, 32 | "author": { 33 | "name": "Georgy Marchuk", 34 | "email": "gmarcuk@gmail.com", 35 | "url": "https://gmrchk.com/" 36 | }, 37 | "contributors": [ 38 | { 39 | "name": "Rasso Hilber", 40 | "email": "mail@rassohilber.com", 41 | "url": "https://rassohilber.com" 42 | }, 43 | { 44 | "name": "Philipp Daun", 45 | "email": "daun@daun.ltd", 46 | "url": "https://philippdaun.net" 47 | } 48 | ], 49 | "license": "MIT", 50 | "repository": { 51 | "type": "git", 52 | "url": "https://github.com/swup/js-plugin.git" 53 | }, 54 | "dependencies": { 55 | "@swup/plugin": "^4.0.0" 56 | }, 57 | "devDependencies": { 58 | "@swup/cli": "^5.0.1", 59 | "@types/jsdom": "^21.1.4", 60 | "jsdom": "^24.0.0", 61 | "vitest": "^1.2.2" 62 | }, 63 | "peerDependencies": { 64 | "swup": "^4.6.0" 65 | }, 66 | "browserslist": [ 67 | "extends @swup/browserslist-config" 68 | ], 69 | "prettier": "@swup/prettier-config" 70 | } 71 | -------------------------------------------------------------------------------- /src/animations.ts: -------------------------------------------------------------------------------- 1 | import { isPromise, matchPath } from 'swup'; 2 | import type { Path, Visit } from 'swup'; 3 | 4 | import { MatchFunction, MatchOptions } from './index.js'; 5 | 6 | /** 7 | * Animation object as supplied by plugin users. 8 | * Contains path patterns and handler functions for in/out animation. 9 | */ 10 | export type Animation = { 11 | /** The path pattern to match the current url against. */ 12 | from: Path; 13 | /** The path pattern to match the next url against. */ 14 | to: Path; 15 | /** The function to call when the animation is triggered. */ 16 | out: (done: () => void, data: AnimationData) => void | Promise; 17 | /** The function to call when the animation is triggered. */ 18 | in: (done: () => void, data: AnimationData) => void | Promise; 19 | }; 20 | 21 | /** 22 | * Compiled animation object with pre-optimized match functions. 23 | */ 24 | export type CompiledAnimation = Animation & { 25 | /** Match function to check if the `from` pattern matches a given URL */ 26 | matchesFrom: MatchFunction; 27 | /** Match function to check if the `to` pattern matches a given URL */ 28 | matchesTo: MatchFunction; 29 | }; 30 | 31 | /** 32 | * Data object passed into the animation handler functions. 33 | */ 34 | export type AnimationData = { 35 | visit: Visit; 36 | direction: 'in' | 'out'; 37 | from: { 38 | url: string; 39 | pattern: Path; 40 | params: object; 41 | }; 42 | to: { 43 | url: string; 44 | pattern: Path; 45 | params: object; 46 | }; 47 | }; 48 | 49 | /** 50 | * The animation object to use when no other animation matches. 51 | */ 52 | export const defaultAnimation: Animation = { 53 | from: '(.*)', 54 | to: '(.*)', 55 | out: (done) => done(), 56 | in: (done) => done() 57 | }; 58 | 59 | /** 60 | * Compile animations to match functions and transitions 61 | */ 62 | export function compileAnimations( 63 | animations: Animation[], 64 | matchOptions?: MatchOptions 65 | ): CompiledAnimation[] { 66 | return animations.map( 67 | (animation): CompiledAnimation => compileAnimation(animation, matchOptions) 68 | ); 69 | } 70 | 71 | /** 72 | * Compile path patterns to match functions and transitions 73 | */ 74 | export function compileAnimation( 75 | animation: Animation, 76 | matchOptions?: MatchOptions 77 | ): CompiledAnimation { 78 | const matchesFrom = matchPath(animation.from, matchOptions); 79 | const matchesTo = matchPath(animation.to, matchOptions); 80 | return { ...animation, matchesFrom, matchesTo }; 81 | } 82 | 83 | /** 84 | * Rate animation based on the match 85 | */ 86 | export function rateAnimation( 87 | animation: CompiledAnimation, 88 | from: string, 89 | to: string, 90 | name?: string 91 | ): number { 92 | let rating = 0; 93 | 94 | // Check if route patterns match 95 | const fromMatched = animation.matchesFrom(from); 96 | const toMatched = animation.matchesTo(to); 97 | if (fromMatched) { 98 | rating += 1; 99 | } 100 | if (toMatched) { 101 | rating += 1; 102 | } 103 | 104 | // Beat all others if custom name fits 105 | if (fromMatched && animation.to === name) { 106 | rating += 2; 107 | } 108 | 109 | return rating; 110 | } 111 | 112 | /** 113 | * Find the best matching animation given a visit object 114 | */ 115 | export function findAnimationForVisit( 116 | animations: CompiledAnimation[], 117 | visit: Visit 118 | ): CompiledAnimation | null { 119 | return findAnimation(animations, visit.from.url, visit.to.url, visit.animation.name); 120 | } 121 | 122 | /** 123 | * Find the best matching animation by ranking them against each other 124 | */ 125 | export function findAnimation( 126 | animations: CompiledAnimation[], 127 | from: string, 128 | to: string, 129 | name?: string 130 | ): CompiledAnimation | null { 131 | let topRating = 0; 132 | 133 | const animation: CompiledAnimation | null = animations.reduceRight( 134 | (bestMatch, animation) => { 135 | const rating = rateAnimation(animation, from, to, name); 136 | if (rating >= topRating) { 137 | topRating = rating; 138 | return animation; 139 | } else { 140 | return bestMatch; 141 | } 142 | }, 143 | null as CompiledAnimation | null 144 | ); 145 | 146 | return animation; 147 | } 148 | 149 | /** 150 | * Create an object with all the data passed into the animation handler function 151 | */ 152 | export function assembleAnimationData( 153 | animation: CompiledAnimation, 154 | visit: Visit, 155 | direction: 'in' | 'out' 156 | ): AnimationData { 157 | const matchFrom = animation.matchesFrom(visit.from.url); 158 | const matchTo = animation.matchesTo(visit.to.url!); 159 | 160 | return { 161 | visit, 162 | direction, 163 | from: { 164 | url: visit.from.url, 165 | pattern: animation.from, 166 | params: matchFrom ? matchFrom.params : {} 167 | }, 168 | to: { 169 | url: visit.to.url!, 170 | pattern: animation.to, 171 | params: matchTo ? matchTo.params : {} 172 | } 173 | }; 174 | } 175 | 176 | /** 177 | * Run an animation handler function and resolve when it's done. 178 | */ 179 | export function runAnimation(animation: CompiledAnimation, data: AnimationData): Promise { 180 | const { direction } = data; 181 | const animationFn = animation[direction]; 182 | if (!animationFn) { 183 | console.warn(`Missing animation function for '${direction}' phase`); 184 | return Promise.resolve(); 185 | } 186 | 187 | return new Promise((resolve) => { 188 | /* Sync API: Pass `done` callback into animation handler so it can resolve manually */ 189 | const result = animationFn(() => resolve(), data); 190 | /* Async API: Receive a promise from animation handler so we resolve it here */ 191 | if (isPromise(result)) { 192 | result.then(resolve); 193 | } 194 | }); 195 | } 196 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '@swup/plugin'; 2 | import { matchPath } from 'swup'; 3 | import type { Handler, Visit } from 'swup'; 4 | 5 | import { 6 | assembleAnimationData, 7 | compileAnimations, 8 | defaultAnimation, 9 | findAnimationForVisit, 10 | runAnimation 11 | } from './animations.js'; 12 | import type { Animation, CompiledAnimation } from './animations.js'; 13 | 14 | type RequireKeys = Partial & Pick; 15 | 16 | type Options = { 17 | /** The selector for matching the main content area of the page. */ 18 | animations: Animation[]; 19 | /** Options for matching paths. Directly passed into `path-to-regexp`. */ 20 | matchOptions: MatchOptions; 21 | }; 22 | 23 | type InitOptions = RequireKeys; 24 | 25 | export type MatchOptions = Parameters[1]; 26 | 27 | export type MatchFunction = ReturnType; 28 | 29 | export default class SwupJsPlugin extends Plugin { 30 | name = 'SwupJsPlugin'; 31 | 32 | requires = { swup: '>=4' }; 33 | 34 | defaults: Options = { 35 | animations: [], 36 | matchOptions: {} 37 | }; 38 | 39 | options: Options; 40 | 41 | animations: CompiledAnimation[] = []; 42 | 43 | constructor(options: InitOptions) { 44 | super(); 45 | 46 | // Backward compatibility: allow passing an array of animations directly 47 | if (Array.isArray(options)) { 48 | options = { animations: options as Animation[] }; 49 | } 50 | 51 | this.options = { ...this.defaults, ...options }; 52 | this.options.animations.push(defaultAnimation); 53 | this.animations = compileAnimations(this.options.animations, this.options.matchOptions); 54 | } 55 | 56 | mount() { 57 | this.replace('animation:out:await', this.awaitOutAnimation, { priority: -1 }); 58 | this.replace('animation:in:await', this.awaitInAnimation, { priority: -1 }); 59 | } 60 | 61 | /** 62 | * Replace swup's internal out-animation handler. 63 | * Finds and runs the 'in' animation for the current visit. 64 | */ 65 | awaitOutAnimation: Handler<'animation:out:await'> = async (visit, { skip }) => { 66 | if (skip) return; 67 | await this.findAndRunAnimation(visit, 'out'); 68 | }; 69 | 70 | /** 71 | * Replace swup's internal in-animation handler handler. 72 | * Finds and runs the 'in' animation for the current visit. 73 | */ 74 | awaitInAnimation: Handler<'animation:in:await'> = async (visit, { skip }) => { 75 | if (skip) return; 76 | await this.findAndRunAnimation(visit, 'in'); 77 | }; 78 | 79 | /** 80 | * Find the best matching animation for the visit and run its handler function. 81 | */ 82 | async findAndRunAnimation(visit: Visit, direction: 'in' | 'out'): Promise { 83 | const animation = findAnimationForVisit(this.animations, visit); 84 | if (animation) { 85 | const data = assembleAnimationData(animation, visit, direction); 86 | await runAnimation(animation, data); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /tests/config/vitest.config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Vitest config file 3 | * @see https://vitest.dev/config/ 4 | */ 5 | 6 | import { defineConfig } from 'vitest/config'; 7 | 8 | export default defineConfig({ 9 | test: { 10 | environment: 'jsdom', 11 | environmentOptions: { 12 | jsdom: { 13 | console: true, 14 | resources: 'usable' 15 | } 16 | }, 17 | include: ['tests/unit/**/*.test.ts'], 18 | setupFiles: ['tests/config/vitest.setup.ts'], 19 | testTimeout: 1000 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /tests/config/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | // import { afterAll, afterEach, beforeAll } from 'vitest'; 2 | -------------------------------------------------------------------------------- /tests/unit/animations.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { 3 | assembleAnimationData, 4 | compileAnimation, 5 | compileAnimations, 6 | findAnimation, 7 | rateAnimation, 8 | runAnimation 9 | } from '../../src/animations.js'; 10 | import type { Animation } from '../../src/animations.js'; 11 | 12 | const example: Animation = { 13 | from: 'from', 14 | to: 'to', 15 | in: (done) => done(), 16 | out: (done) => done() 17 | }; 18 | 19 | describe('compileAnimation', () => { 20 | it('compiles route matcher functions', () => { 21 | const compiled = compileAnimation({ 22 | ...example, 23 | from: 'from/:slug', 24 | to: 'to/:slug' 25 | }); 26 | 27 | expect(typeof compiled.matchesFrom).toBe('function'); 28 | expect(typeof compiled.matchesTo).toBe('function'); 29 | expect(compiled.matchesFrom('to/test')).toBe(false); 30 | expect(compiled.matchesTo('from/test')).toBe(false); 31 | expect(compiled.matchesFrom('from/test')).toMatchObject({ 32 | index: 0, 33 | params: { slug: 'test' }, 34 | path: 'from/test' 35 | }); 36 | expect(compiled.matchesTo('to/test')).toMatchObject({ 37 | index: 0, 38 | params: { slug: 'test' }, 39 | path: 'to/test' 40 | }); 41 | }); 42 | 43 | it('keeps existing keys', () => { 44 | const uncompiled = { ...example, from: 'from', to: 'to' }; 45 | const compiled = compileAnimation(uncompiled); 46 | 47 | expect(compiled.from).toBe(uncompiled.from); 48 | expect(compiled.to).toBe(uncompiled.to); 49 | expect(compiled.in).toBe(uncompiled.in); 50 | expect(compiled.out).toBe(uncompiled.out); 51 | }); 52 | }); 53 | 54 | describe('compileAnimations', () => { 55 | it('compiles each member of the array passed in', () => { 56 | const compiled = compileAnimations([ 57 | { ...example, from: 'a', to: 'b' }, 58 | { ...example, from: 'b', to: 'c' } 59 | ]); 60 | 61 | expect(compiled).toHaveLength(2); 62 | expect(typeof compiled[0].matchesFrom).toBe('function'); 63 | expect(typeof compiled[1].matchesTo).toBe('function'); 64 | expect(compiled[0].from).toBe('a'); 65 | expect(compiled[1].from).toBe('b'); 66 | }); 67 | }); 68 | 69 | describe('rateAnimation', () => { 70 | it('ranks by from and to', () => { 71 | const compiled = compileAnimation(example); 72 | expect(rateAnimation(compiled, '', '')).toBe(0); 73 | expect(rateAnimation(compiled, 'from', '')).toBe(1); 74 | expect(rateAnimation(compiled, '', 'to')).toBe(1); 75 | expect(rateAnimation(compiled, 'from', 'to')).toBe(2); 76 | }); 77 | it('outranks by name', () => { 78 | const compiled = compileAnimation(example); 79 | expect(rateAnimation(compiled, '', '', '')).toBe(0); 80 | expect(rateAnimation(compiled, '', 'to', 'to')).toBe(1); 81 | expect(rateAnimation(compiled, 'from', 'to', 'to')).toBe(4); 82 | expect(rateAnimation(compiled, 'from', '', 'to')).toBe(3); 83 | }); 84 | }); 85 | 86 | describe('findAnimation', () => { 87 | it('finds the best ranked animation', () => { 88 | const animations = compileAnimations([ 89 | { ...example, from: 'a', to: 'b' }, 90 | { ...example, from: 'b', to: 'c' }, 91 | { ...example, from: '(.*)', to: 'c' }, 92 | { ...example, from: 'd', to: '(.*)' }, 93 | { ...example, from: 'c', to: 'd' }, 94 | { ...example, from: '(.*)', to: '(.*)' }, 95 | { ...example, from: 'c', to: 'd' } 96 | ]); 97 | expect(findAnimation(animations, 'a', 'b')).toBe(animations[0]); 98 | expect(findAnimation(animations, 'a', 'c')).toBe(animations[2]); 99 | expect(findAnimation(animations, 'b', 'c')).toBe(animations[1]); 100 | expect(findAnimation(animations, 'b', 'a')).toBe(animations[5]); 101 | expect(findAnimation(animations, 'd', 'c')).toBe(animations[2]); 102 | expect(findAnimation(animations, 'd', 'd')).toBe(animations[3]); 103 | expect(findAnimation(animations, 'c', 'd')).toBe(animations[4]); 104 | }); 105 | }); 106 | 107 | describe('runAnimation', () => { 108 | it('returns a promise', () => { 109 | const animation = compileAnimation({ ...example, from: 'a', to: 'b' }); 110 | const data = { direction: 'out', test: 'data' }; 111 | // @ts-ignore - we're testing the function, not the type 112 | const run = runAnimation(animation, data); 113 | 114 | expect(typeof run).toBe('object'); 115 | expect(typeof run.then).toBe('function'); 116 | }); 117 | 118 | it('calls the out animation handler', () => { 119 | const fn = vi.fn(); 120 | const animation = compileAnimation({ out: fn, in: () => {}, from: 'a', to: 'b' }); 121 | const data = { direction: 'out', test: 'data' }; 122 | 123 | // @ts-ignore - we're testing the function, not the type 124 | const run = runAnimation(animation, data); 125 | 126 | expect(fn).toHaveBeenCalledTimes(1); 127 | expect(fn).toHaveBeenCalledWith(expect.any(Function), data); 128 | }); 129 | 130 | it('calls the in animation handler', () => { 131 | const fn = vi.fn(); 132 | const animation = compileAnimation({ out: () => {}, in: fn, from: 'a', to: 'b' }); 133 | const data = { direction: 'in', test: 'data' }; 134 | 135 | // @ts-ignore - we're testing the function, not the type 136 | const run = runAnimation(animation, data); 137 | 138 | expect(fn).toHaveBeenCalledTimes(1); 139 | expect(fn).toHaveBeenCalledWith(expect.any(Function), data); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /tests/unit/plugin.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; 2 | import Swup from 'swup'; 3 | import type { Visit } from 'swup'; 4 | 5 | import SwupJsPlugin from '../../src/index.js'; 6 | import { compileAnimation, type Animation } from '../../src/animations.js'; 7 | 8 | // vi.mock('../../src/animations.js'); 9 | 10 | const example: Animation = { 11 | from: 'from', 12 | to: 'to', 13 | in: (done) => done(), 14 | out: (done) => done() 15 | }; 16 | 17 | describe('SwupJsPlugin', () => { 18 | let swup: Swup; 19 | let plugin: SwupJsPlugin; 20 | let visit: Visit; 21 | 22 | beforeEach(() => { 23 | vi.resetModules(); 24 | swup = new Swup(); 25 | plugin = new SwupJsPlugin({ animations: [example] }); 26 | // @ts-ignore - createVisit is marked internal 27 | visit = swup.createVisit({ url: '/' }); 28 | }); 29 | 30 | afterEach(() => { 31 | swup.unuse(plugin); 32 | swup.destroy(); 33 | }); 34 | 35 | it('compiles animations', () => { 36 | swup.use(plugin); 37 | 38 | expect(Array.isArray(plugin.animations)).toBe(true); 39 | expect(typeof plugin.animations[0].matchesTo).toBe('function'); 40 | expect(typeof plugin.animations[0].matchesFrom).toBe('function'); 41 | }); 42 | 43 | it('adds fallback animation', () => { 44 | swup.use(plugin); 45 | 46 | expect(plugin.animations).toHaveLength(2); 47 | expect(plugin.animations[1].from).toBe('(.*)'); 48 | expect(plugin.animations[1].to).toBe('(.*)'); 49 | }); 50 | 51 | it('replaces the out animation handler', async () => { 52 | const defaultHandler = vi.fn(); 53 | const newHandler = vi.spyOn(plugin, 'awaitOutAnimation').mockImplementation(async () => {}); 54 | swup.use(plugin); 55 | 56 | await swup.hooks.call('animation:out:await', visit, { skip: false }, defaultHandler); 57 | 58 | expect(defaultHandler).toHaveBeenCalledTimes(0); 59 | expect(newHandler).toHaveBeenCalledTimes(1); 60 | expect(newHandler).toHaveBeenCalledWith(visit, { skip: false }, defaultHandler); 61 | }); 62 | 63 | it('replaces the in animation handler', async () => { 64 | const defaultHandler = vi.fn(); 65 | const newHandler = vi.spyOn(plugin, 'awaitInAnimation').mockImplementation(async () => {}); 66 | swup.use(plugin); 67 | 68 | await swup.hooks.call('animation:in:await', visit, { skip: false }, defaultHandler); 69 | 70 | expect(defaultHandler).toHaveBeenCalledTimes(0); 71 | expect(newHandler).toHaveBeenCalledTimes(1); 72 | expect(newHandler).toHaveBeenCalledWith(visit, { skip: false }, defaultHandler); 73 | }); 74 | 75 | it('finds and runs animation from replaced animation handlers', async () => { 76 | const spy = vi.spyOn(plugin, 'findAndRunAnimation').mockImplementation(async () => {}); 77 | swup.use(plugin); 78 | 79 | await swup.hooks.call('animation:out:await', visit, { skip: false }, () => {}); 80 | 81 | expect(spy).toHaveBeenCalledTimes(1); 82 | expect(spy).toHaveBeenCalledWith(visit, 'out'); 83 | 84 | await swup.hooks.call('animation:in:await', visit, { skip: false }, () => {}); 85 | 86 | expect(spy).toHaveBeenCalledTimes(2); 87 | expect(spy).toHaveBeenCalledWith(visit, 'in'); 88 | }); 89 | 90 | it('respects the skip flag in arguments', async () => { 91 | const spy = vi.spyOn(plugin, 'findAndRunAnimation').mockImplementation(async () => {}); 92 | swup.use(plugin); 93 | 94 | await swup.hooks.call('animation:out:await', visit, { skip: true }, () => {}); 95 | await swup.hooks.call('animation:in:await', visit, { skip: true }, () => {}); 96 | 97 | expect(spy).toHaveBeenCalledTimes(0); 98 | }); 99 | 100 | it('finds and runs animation with generated data', async () => { 101 | const data = { test: 'data' }; 102 | const compiled = compileAnimation(example); 103 | 104 | vi.doMock('../../src/animations.js', async (importOriginal) => ({ 105 | ...(await importOriginal()), 106 | findAnimationForVisit: vi.fn(() => compiled), 107 | assembleAnimationData: vi.fn(() => data), 108 | runAnimation: vi.fn() 109 | })); 110 | 111 | const { findAnimationForVisit, runAnimation, assembleAnimationData } = await import( 112 | '../../src/animations.js' 113 | ); 114 | const { default: Plugin } = await import('../../src/index.js'); 115 | plugin = new Plugin({ animations: [example] }); 116 | swup.use(plugin); 117 | 118 | await plugin.findAndRunAnimation(visit, 'out'); 119 | 120 | expect(findAnimationForVisit).toHaveBeenCalledWith(plugin.animations, visit); 121 | expect(assembleAnimationData).toHaveBeenCalledWith(compiled, visit, 'out'); 122 | expect(runAnimation).toHaveBeenCalledWith(compiled, data); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "compilerOptions": { 4 | "target": "es2022", 5 | "module": "NodeNext", 6 | "moduleResolution": "NodeNext", 7 | "rootDirs": ["./src"], 8 | "resolveJsonModule": true, 9 | "allowJs": true, 10 | "outDir": "./dist", 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "noImplicitAny": true 15 | } 16 | } 17 | --------------------------------------------------------------------------------