UNPKG

swup

Version:

Versatile and extensible page transition library for server-rendered websites

240 lines (205 loc) 7.41 kB
import type Swup from '../Swup.js'; import { FetchError, type FetchOptions, type PageData } from './fetchPage.js'; import { type VisitInitOptions, type Visit, VisitState } from './Visit.js'; import { createHistoryRecord, updateHistoryRecord, Location, classify } from '../helpers.js'; import { getContextualAttr } from '../utils.js'; export type HistoryAction = 'push' | 'replace'; export type HistoryDirection = 'forwards' | 'backwards'; export type NavigationToSelfAction = 'scroll' | 'navigate'; export type CacheControl = Partial<{ read: boolean; write: boolean }>; /** Define how to navigate to a page. */ type NavigationOptions = { /** Whether this visit is animated. Default: `true` */ animate?: boolean; /** Name of a custom animation to run. */ animation?: string; /** History action to perform: `push` for creating a new history entry, `replace` for replacing the current entry. Default: `push` */ history?: HistoryAction; /** Whether this visit should read from or write to the cache. */ cache?: CacheControl; /** Custom metadata associated with this visit. */ meta?: Record<string, unknown>; }; /** * Navigate to a new URL. * @param url The URL to navigate to. * @param options Options for how to perform this visit. * @returns Promise<void> */ export function navigate( this: Swup, url: string, options: NavigationOptions & FetchOptions = {}, init: Omit<VisitInitOptions, 'to'> = {} ) { if (typeof url !== 'string') { throw new Error(`swup.navigate() requires a URL parameter`); } // Check if the visit should be ignored if (this.shouldIgnoreVisit(url, { el: init.el, event: init.event })) { window.location.assign(url); return; } const { url: to, hash } = Location.fromUrl(url); const visit = this.createVisit({ ...init, to, hash }); this.performNavigation(visit, options); } /** * Start a visit to a new URL. * * Internal method that assumes the visit context has already been created. * * As a user, you should call `swup.navigate(url)` instead. * * @param url The URL to navigate to. * @param options Options for how to perform this visit. * @returns Promise<void> */ export async function performNavigation( this: Swup, visit: Visit, options: NavigationOptions & FetchOptions = {} ): Promise<void> { if (this.navigating) { if (this.visit.state >= VisitState.ENTERING) { // Currently navigating and content already loaded? Finish and queue visit.state = VisitState.QUEUED; this.onVisitEnd = () => this.performNavigation(visit, options); return; } else { // Currently navigating and content not loaded? Abort running visit await this.hooks.call('visit:abort', this.visit, undefined); delete this.visit.to.document; this.visit.state = VisitState.ABORTED; } } this.navigating = true; this.visit = visit; const { el } = visit.trigger; options.referrer = options.referrer || this.location.url; if (options.animate === false) { visit.animation.animate = false; } // Clean up old animation classes if (!visit.animation.animate) { this.classes.clear(); } // Get history action from option or attribute on trigger element const history = options.history || getContextualAttr(el, 'data-swup-history'); if (typeof history === 'string' && ['push', 'replace'].includes(history)) { visit.history.action = history as HistoryAction; } // Get custom animation name from option or attribute on trigger element const animation = options.animation || getContextualAttr(el, 'data-swup-animation'); if (typeof animation === 'string') { visit.animation.name = animation; } // Get custom metadata from option visit.meta = options.meta || {}; // Sanitize cache option if (typeof options.cache === 'object') { visit.cache.read = options.cache.read ?? visit.cache.read; visit.cache.write = options.cache.write ?? visit.cache.write; } else if (options.cache !== undefined) { visit.cache = { read: !!options.cache, write: !!options.cache }; } // Delete this so that window.fetch doesn't mis-interpret it delete options.cache; try { await this.hooks.call('visit:start', visit, undefined); visit.state = VisitState.STARTED; // Begin loading page const page = this.hooks.call('page:load', visit, { options }, async (visit, args) => { // Read from cache let cachedPage: PageData | undefined; if (visit.cache.read) { cachedPage = this.cache.get(visit.to.url); } args.page = cachedPage || (await this.fetchPage(visit.to.url, args.options)); args.cache = !!cachedPage; return args.page; }); /** * When the page is loaded: mark the visit as loaded and save * the raw html and a parsed document of the received page in the visit object */ page.then(({ html }) => { visit.advance(VisitState.LOADED); visit.to.html = html; visit.to.document = new DOMParser().parseFromString(html, 'text/html'); }); // Create/update history record if this is not a popstate call or leads to the same URL const newUrl = visit.to.url + visit.to.hash; if (!visit.history.popstate) { if (visit.history.action === 'replace' || visit.to.url === this.location.url) { updateHistoryRecord(newUrl); } else { this.currentHistoryIndex++; createHistoryRecord(newUrl, { index: this.currentHistoryIndex }); } } this.location = Location.fromUrl(newUrl); // Mark visit type with classes if (visit.history.popstate) { this.classes.add('is-popstate'); } if (visit.animation.name) { this.classes.add(`to-${classify(visit.animation.name)}`); } // Wait for page before starting to animate out? if (visit.animation.wait) { await page; } // Check if failed/aborted in the meantime if (visit.done) return; // Perform the actual transition: animate and replace content await this.hooks.call('visit:transition', visit, undefined, async () => { // No animation? Just await page and render if (!visit.animation.animate) { await this.hooks.call('animation:skip', undefined); await this.renderPage(visit, await page); return; } // Animate page out, render page, animate page in visit.advance(VisitState.LEAVING); await this.animatePageOut(visit); if (visit.animation.native && document.startViewTransition) { await document.startViewTransition( async () => await this.renderPage(visit, await page) ).finished; } else { await this.renderPage(visit, await page); } await this.animatePageIn(visit); }); // Check if failed/aborted in the meantime if (visit.done) return; // Finalize visit await this.hooks.call('visit:end', visit, undefined, () => this.classes.clear()); visit.state = VisitState.COMPLETED; this.navigating = false; /** Run eventually queued function */ if (this.onVisitEnd) { this.onVisitEnd(); this.onVisitEnd = undefined; } } catch (error) { // Return early if error is undefined or signals an aborted request if (!error || (error as FetchError)?.aborted) { visit.state = VisitState.ABORTED; return; } visit.state = VisitState.FAILED; // Log to console console.error(error); // Remove current history entry, then load requested url in browser this.options.skipPopStateHandling = () => { window.location.assign(visit.to.url + visit.to.hash); return true; }; // Go back to the actual page we're still at window.history.back(); } finally { delete visit.to.document; } }