├── .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 |
--------------------------------------------------------------------------------