swup 4.0.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ import { FetchOptions } from './fetchPage.js';
3
3
  import { VisitInitOptions } from './Visit.js';
4
4
  export type HistoryAction = 'push' | 'replace';
5
5
  export type HistoryDirection = 'forwards' | 'backwards';
6
+ export type NavigationToSelfAction = 'scroll' | 'navigate';
6
7
  /** Define how to navigate to a page. */
7
8
  type NavigationOptions = {
8
9
  /** Whether this visit is animated. Default: `true` */
@@ -30,5 +31,5 @@ export declare function navigate(this: Swup, url: string, options?: NavigationOp
30
31
  * @param options Options for how to perform this visit.
31
32
  * @returns Promise<void>
32
33
  */
33
- export declare function performNavigation(this: Swup, url: string, options?: NavigationOptions & FetchOptions): Promise<void>;
34
+ export declare function performNavigation(this: Swup, options?: NavigationOptions & FetchOptions): Promise<void>;
34
35
  export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "swup",
3
3
  "amdName": "Swup",
4
- "version": "4.0.1",
4
+ "version": "4.2.0",
5
5
  "description": "Versatile and extensible page transition library for server-rendered websites",
6
6
  "type": "module",
7
7
  "source": "./src/Swup.ts",
@@ -11,9 +11,9 @@
11
11
  "types": "./dist/types/index.d.ts",
12
12
  "exports": {
13
13
  ".": {
14
- "require": "./dist/Swup.cjs",
14
+ "types": "./dist/types/index.d.ts",
15
15
  "import": "./dist/Swup.modern.js",
16
- "types": "./dist/types/index.d.ts"
16
+ "require": "./dist/Swup.cjs"
17
17
  }
18
18
  },
19
19
  "files": [
package/src/Swup.ts CHANGED
@@ -11,7 +11,7 @@ import { Visit, createVisit } from './modules/Visit.js';
11
11
  import { Hooks } from './modules/Hooks.js';
12
12
  import { getAnchorElement } from './modules/getAnchorElement.js';
13
13
  import { awaitAnimations } from './modules/awaitAnimations.js';
14
- import { navigate, performNavigation } from './modules/navigate.js';
14
+ import { navigate, performNavigation, NavigationToSelfAction } from './modules/navigate.js';
15
15
  import { fetchPage } from './modules/fetchPage.js';
16
16
  import { animatePageOut } from './modules/animatePageOut.js';
17
17
  import { replaceContent } from './modules/replaceContent.js';
@@ -38,6 +38,8 @@ export type Options = {
38
38
  ignoreVisit: (url: string, { el, event }: { el?: Element; event?: Event }) => boolean;
39
39
  /** Selector for links that trigger visits. Default: `'a[href]'` */
40
40
  linkSelector: string;
41
+ /** How swup handles links to the same page. Default: `scroll` */
42
+ linkToSelf: NavigationToSelfAction;
41
43
  /** Plugins to register on startup. */
42
44
  plugins: Plugin[];
43
45
  /** Custom headers sent along with fetch requests. */
@@ -56,6 +58,7 @@ const defaults: Options = {
56
58
  containers: ['#swup'],
57
59
  ignoreVisit: (url, { el, event } = {}) => !!el?.closest('[data-no-swup]'),
58
60
  linkSelector: 'a[href]',
61
+ linkToSelf: 'scroll',
59
62
  plugins: [],
60
63
  resolveUrl: (url) => url,
61
64
  requestHeaders: {
@@ -138,7 +141,7 @@ export default class Swup {
138
141
  this.cache = new Cache(this);
139
142
  this.classes = new Classes(this);
140
143
  this.hooks = new Hooks(this);
141
- this.visit = this.createVisit({ to: undefined });
144
+ this.visit = this.createVisit({ to: '' });
142
145
 
143
146
  if (!this.checkRequirements()) {
144
147
  return;
@@ -259,16 +262,24 @@ export default class Swup {
259
262
 
260
263
  event.preventDefault();
261
264
 
262
- // Handle links to the same page: with or without hash
265
+ // Handle links to the same page
263
266
  if (!url || url === from) {
264
267
  if (hash) {
268
+ // With hash: scroll to anchor
265
269
  this.hooks.callSync('link:anchor', { hash }, () => {
266
270
  updateHistoryRecord(url + hash);
267
271
  this.scrollToContent();
268
272
  });
269
273
  } else {
274
+ // Without hash: scroll to top or load/reload page
270
275
  this.hooks.callSync('link:self', undefined, () => {
271
- this.scrollToContent();
276
+ switch (this.options.linkToSelf) {
277
+ case 'navigate':
278
+ return this.performNavigation();
279
+ case 'scroll':
280
+ default:
281
+ return this.scrollToContent();
282
+ }
272
283
  });
273
284
  }
274
285
  return;
@@ -280,7 +291,7 @@ export default class Swup {
280
291
  }
281
292
 
282
293
  // Finally, proceed with loading the page
283
- this.performNavigation(url);
294
+ this.performNavigation();
284
295
  });
285
296
  }
286
297
 
@@ -297,11 +308,6 @@ export default class Swup {
297
308
  return;
298
309
  }
299
310
 
300
- // Exit early if the link should be ignored
301
- if (this.shouldIgnoreVisit(href, { event })) {
302
- return;
303
- }
304
-
305
311
  const { url, hash } = Location.fromUrl(href);
306
312
  const animate = this.options.animateHistoryBrowsing;
307
313
  const resetScroll = this.options.animateHistoryBrowsing;
@@ -330,7 +336,7 @@ export default class Swup {
330
336
  // }
331
337
 
332
338
  this.hooks.callSync('history:popstate', { event }, () => {
333
- this.performNavigation(url);
339
+ this.performNavigation();
334
340
  });
335
341
  }
336
342
 
@@ -6,19 +6,18 @@ export type DelegateEventUnsubscribe = {
6
6
  };
7
7
 
8
8
  /** Register a delegated event listener. */
9
- export const delegateEvent = <Selector extends string, TEvent extends EventType>(
9
+ export const delegateEvent = <
10
+ Selector extends string,
11
+ TElement extends Element = ParseSelector<Selector, HTMLElement>,
12
+ TEvent extends EventType = EventType
13
+ >(
10
14
  selector: Selector,
11
15
  type: TEvent,
12
- callback: DelegateEventHandler<GlobalEventHandlersEventMap[TEvent]>,
16
+ callback: DelegateEventHandler<GlobalEventHandlersEventMap[TEvent], TElement>,
13
17
  options?: DelegateOptions
14
18
  ): DelegateEventUnsubscribe => {
15
19
  const controller = new AbortController();
16
20
  options = { ...options, signal: controller.signal };
17
- delegate<string, ParseSelector<Selector, HTMLElement>, TEvent>(
18
- selector,
19
- type,
20
- callback,
21
- options
22
- );
21
+ delegate<Selector, TElement, TEvent>(selector, type, callback, options);
23
22
  return { destroy: () => controller.abort() };
24
23
  };
@@ -25,7 +25,11 @@ export class Cache {
25
25
 
26
26
  /** All cached pages. */
27
27
  get all() {
28
- return this.pages;
28
+ const copy = new Map();
29
+ this.pages.forEach((page, key) => {
30
+ copy.set(key, { ...page });
31
+ });
32
+ return copy;
29
33
  }
30
34
 
31
35
  /** Check if the given URL has been cached. */
@@ -33,9 +37,11 @@ export class Cache {
33
37
  return this.pages.has(this.resolve(url));
34
38
  }
35
39
 
36
- /** Return the cached page object if cached. */
40
+ /** Return a shallow copy of the cached page object if available. */
37
41
  get(url: string): CacheData | undefined {
38
- return this.pages.get(this.resolve(url));
42
+ const result = this.pages.get(this.resolve(url));
43
+ if (!result) return result;
44
+ return { ...result };
39
45
  }
40
46
 
41
47
  /** Create a cache record for the specified URL. */
@@ -47,9 +53,9 @@ export class Cache {
47
53
  }
48
54
 
49
55
  /** Update a cache record, overwriting or adding custom data. */
50
- update(url: string, page: CacheData) {
56
+ update(url: string, payload: Record<string, any>) {
51
57
  url = this.resolve(url);
52
- page = { ...this.get(url), ...page, url };
58
+ const page = { ...this.get(url), ...payload, url } as CacheData;
53
59
  this.pages.set(url, page);
54
60
  }
55
61
 
@@ -26,7 +26,9 @@ export interface VisitFrom {
26
26
 
27
27
  export interface VisitTo {
28
28
  /** The URL of the next page */
29
- url?: string;
29
+ url: string;
30
+ /** The hash of the next page */
31
+ hash?: string;
30
32
  /** The HTML content of the next page */
31
33
  html?: string;
32
34
  }
@@ -68,7 +70,7 @@ export interface VisitHistory {
68
70
  }
69
71
 
70
72
  export interface VisitInitOptions {
71
- to: string | undefined;
73
+ to: string;
72
74
  from?: string;
73
75
  hash?: string;
74
76
  animate?: boolean;
@@ -86,7 +88,7 @@ export function createVisit(
86
88
  {
87
89
  to,
88
90
  from = this.currentPageUrl,
89
- hash: target,
91
+ hash,
90
92
  animate = true,
91
93
  animation: name,
92
94
  el,
@@ -97,7 +99,7 @@ export function createVisit(
97
99
  ): Visit {
98
100
  return {
99
101
  from: { url: from },
100
- to: { url: to },
102
+ to: { url: to, hash },
101
103
  containers: this.options.containers,
102
104
  animation: {
103
105
  animate,
@@ -117,7 +119,7 @@ export function createVisit(
117
119
  },
118
120
  scroll: {
119
121
  reset,
120
- target
122
+ target: undefined
121
123
  }
122
124
  };
123
125
  }
@@ -116,6 +116,23 @@ describe('Cache', () => {
116
116
  expect(cache.has(page2.url)).toBe(true);
117
117
  expect(cache.has(page3.url)).toBe(false);
118
118
  });
119
+
120
+ it('should return a copy from cache.get()', () => {
121
+ cache.set(page1.url, page1);
122
+ const page = cache.get(page1.url);
123
+ page!.html = 'new';
124
+ expect(cache.get(page1.url)?.html).toEqual(page1.html);
125
+ });
126
+
127
+ it('should return a new Map with shallow copies from cache.all', () => {
128
+ cache.set(page1.url, page1);
129
+ cache.set(page2.url, page2);
130
+
131
+ const all = cache.all;
132
+ all.get(page1.url)!.html = 'new';
133
+
134
+ expect(cache.get(page1.url)?.html).toEqual(page1.html);
135
+ });
119
136
  });
120
137
 
121
138
  describe('Types', () => {
@@ -0,0 +1,36 @@
1
+ import { DelegateEvent } from 'delegate-it';
2
+ import { describe, it } from 'vitest';
3
+
4
+ import { delegateEvent } from '../../helpers/delegateEvent.js';
5
+
6
+ describe('delegateEvent', () => {
7
+ it('should return correct types', () => {
8
+ delegateEvent('form', 'submit', (event) => {});
9
+
10
+ // @ts-expect-no-error
11
+ delegateEvent('form', 'submit', (event: SubmitEvent) => {});
12
+ // @ts-expect-error
13
+ delegateEvent('form', 'submit', (event: MouseEvent) => {});
14
+
15
+ // @ts-expect-no-error
16
+ delegateEvent('form', 'submit', (event: DelegateEvent<SubmitEvent>) => {});
17
+ // @ts-expect-error
18
+ delegateEvent('form', 'submit', (event: DelegateEvent<MouseEvent>) => {});
19
+
20
+ // @ts-expect-no-error
21
+ delegateEvent('form', 'submit', (event: DelegateEvent<SubmitEvent, HTMLFormElement>) => {});
22
+ delegateEvent(
23
+ 'form',
24
+ 'submit',
25
+ // @ts-expect-error
26
+ (event: DelegateEvent<MouseEvent, HTMLAnchorElement>) => {}
27
+ );
28
+
29
+ delegateEvent('form', 'submit', (event) => {
30
+ // @ts-expect-no-error
31
+ const el: HTMLFormElement = event.delegateTarget;
32
+ });
33
+ // @ts-expect-error
34
+ delegateEvent('form', 'submit', (event: MouseEvent) => {});
35
+ });
36
+ });
@@ -8,7 +8,7 @@ import { escapeCssIdentifier as escape, query } from '../utils.js';
8
8
  *
9
9
  * @see https://html.spec.whatwg.org/#find-a-potential-indicated-element
10
10
  */
11
- export const getAnchorElement = (hash: string): Element | null => {
11
+ export const getAnchorElement = (hash?: string): Element | null => {
12
12
  if (hash && hash.charAt(0) === '#') {
13
13
  hash = hash.substring(1);
14
14
  }
@@ -5,6 +5,7 @@ import { VisitInitOptions } from './Visit.js';
5
5
 
6
6
  export type HistoryAction = 'push' | 'replace';
7
7
  export type HistoryDirection = 'forwards' | 'backwards';
8
+ export type NavigationToSelfAction = 'scroll' | 'navigate';
8
9
 
9
10
  /** Define how to navigate to a page. */
10
11
  type NavigationOptions = {
@@ -28,6 +29,10 @@ export function navigate(
28
29
  options: NavigationOptions & FetchOptions = {},
29
30
  init: Omit<VisitInitOptions, 'to'> = {}
30
31
  ) {
32
+ if (typeof url !== 'string') {
33
+ throw new Error(`swup.navigate() requires a URL parameter`);
34
+ }
35
+
31
36
  // Check if the visit should be ignored
32
37
  if (this.shouldIgnoreVisit(url, { el: init.el, event: init.event })) {
33
38
  window.location.href = url;
@@ -36,7 +41,7 @@ export function navigate(
36
41
 
37
42
  const { url: to, hash } = Location.fromUrl(url);
38
43
  this.visit = this.createVisit({ ...init, to, hash });
39
- this.performNavigation(to, options);
44
+ this.performNavigation(options);
40
45
  }
41
46
 
42
47
  /**
@@ -52,15 +57,9 @@ export function navigate(
52
57
  */
53
58
  export async function performNavigation(
54
59
  this: Swup,
55
- url: string,
56
60
  options: NavigationOptions & FetchOptions = {}
57
61
  ) {
58
- if (typeof url !== 'string') {
59
- throw new Error(`swup.navigate() requires a URL parameter`);
60
- }
61
-
62
62
  const { el } = this.visit.trigger;
63
- this.visit.to.url = Location.fromUrl(url).url;
64
63
  options.referrer = options.referrer || this.currentPageUrl;
65
64
 
66
65
  if (options.animate === false) {
@@ -95,10 +94,14 @@ export async function performNavigation(
95
94
  return args.page;
96
95
  });
97
96
 
98
- // Create history record if this is not a popstate call (with or without anchor)
97
+ // Create/update history record if this is not a popstate call or leads to the same URL
99
98
  if (!this.visit.history.popstate) {
100
- const newUrl = url + (this.visit.scroll.target || '');
101
- if (this.visit.history.action === 'replace') {
99
+ // Add the hash directly from the trigger element
100
+ const newUrl = this.visit.to.url + this.visit.to.hash;
101
+ if (
102
+ this.visit.history.action === 'replace' ||
103
+ this.visit.to.url === this.currentPageUrl
104
+ ) {
102
105
  updateHistoryRecord(newUrl);
103
106
  } else {
104
107
  const index = this.currentHistoryIndex + 1;
@@ -142,7 +145,7 @@ export async function performNavigation(
142
145
 
143
146
  // Rewrite `skipPopStateHandling` to redirect manually when `history.go` is processed
144
147
  this.options.skipPopStateHandling = () => {
145
- window.location.href = this.visit.to.url as string;
148
+ window.location.href = this.visit.to.url + this.visit.to.hash;
146
149
  return true;
147
150
  };
148
151
 
@@ -7,14 +7,16 @@ import Swup from '../Swup.js';
7
7
  export const scrollToContent = function (this: Swup): boolean {
8
8
  const options: ScrollIntoViewOptions = { behavior: 'auto' };
9
9
  const { target, reset } = this.visit.scroll;
10
+ const scrollTarget = target || this.visit.to.hash;
11
+
10
12
  let scrolled = false;
11
13
 
12
- if (target) {
14
+ if (scrollTarget) {
13
15
  scrolled = this.hooks.callSync(
14
16
  'scroll:anchor',
15
- { hash: target, options },
17
+ { hash: scrollTarget, options },
16
18
  (visit, { hash, options }) => {
17
- const anchor = this.getAnchorElement(hash || '');
19
+ const anchor = this.getAnchorElement(hash);
18
20
  if (anchor) {
19
21
  anchor.scrollIntoView(options);
20
22
  }