swup
Version:
Versatile and extensible page transition library for server-rendered websites
383 lines (324 loc) • 12.7 kB
text/typescript
import { type DelegateEvent } from 'delegate-it';
import version from './config/version.js';
import { delegateEvent, getCurrentUrl, Location, updateHistoryRecord } from './helpers.js';
import { type DelegateEventUnsubscribe } from './helpers/delegateEvent.js';
import { Cache } from './modules/Cache.js';
import { Classes } from './modules/Classes.js';
import { type Visit, createVisit } from './modules/Visit.js';
import { Hooks, type HookName, type HookInitOptions } from './modules/Hooks.js';
import { getAnchorElement } from './modules/getAnchorElement.js';
import { awaitAnimations } from './modules/awaitAnimations.js';
import { navigate, performNavigation, type NavigationToSelfAction } from './modules/navigate.js';
import { fetchPage } from './modules/fetchPage.js';
import { animatePageOut } from './modules/animatePageOut.js';
import { replaceContent } from './modules/replaceContent.js';
import { scrollToContent } from './modules/scrollToContent.js';
import { animatePageIn } from './modules/animatePageIn.js';
import { renderPage } from './modules/renderPage.js';
import { use, unuse, findPlugin, type Plugin } from './modules/plugins.js';
import { isSameResolvedUrl, resolveUrl } from './modules/resolveUrl.js';
import { nextTick } from './utils.js';
import { type HistoryState } from './helpers/history.js';
/** Options for customizing swup's behavior. */
export type Options = {
/** Whether history visits are animated. Default: `false` */
animateHistoryBrowsing: boolean;
/** Selector for detecting animation timing. Default: `[class*="transition-"]` */
animationSelector: string | false;
/** Elements on which to add animation classes. Default: `html` element */
animationScope: 'html' | 'containers';
/** Enable in-memory page cache. Default: `true` */
cache: boolean;
/** Content containers to be replaced on page visits. Default: `['#swup']` */
containers: string[];
/** Callback for ignoring visits. Receives the element and event that triggered the visit. */
ignoreVisit: (url: string, { el, event }: { el?: Element; event?: Event }) => boolean;
/** Selector for links that trigger visits. Default: `'a[href]'` */
linkSelector: string;
/** How swup handles links to the same page. Default: `scroll` */
linkToSelf: NavigationToSelfAction;
/** Enable native animations using the View Transitions API. */
native: boolean;
/** Hook handlers to register. */
hooks: Partial<HookInitOptions>;
/** Plugins to register on startup. */
plugins: Plugin[];
/** Custom headers sent along with fetch requests. */
requestHeaders: Record<string, string>;
/** Rewrite URLs before loading them. */
resolveUrl: (url: string) => string;
/** Callback for telling swup to ignore certain popstate events. */
skipPopStateHandling: (event: PopStateEvent) => boolean;
/** Request timeout in milliseconds. */
timeout: number;
};
const defaults: Options = {
animateHistoryBrowsing: false,
animationSelector: '[class*="transition-"]',
animationScope: 'html',
cache: true,
containers: ['#swup'],
hooks: {},
ignoreVisit: (url, { el } = {}) => !!el?.closest('[data-no-swup]'),
linkSelector: 'a[href]',
linkToSelf: 'scroll',
native: false,
plugins: [],
resolveUrl: (url) => url,
requestHeaders: {
'X-Requested-With': 'swup',
'Accept': 'text/html, application/xhtml+xml'
},
skipPopStateHandling: (event) => (event.state as HistoryState)?.source !== 'swup',
timeout: 0
};
/** Swup page transition library. */
export default class Swup {
/** Library version */
readonly version: string = version;
/** Options passed into the instance */
options: Options;
/** Default options before merging user options */
readonly defaults: Options = defaults;
/** Registered plugin instances */
plugins: Plugin[] = [];
/** Data about the current visit */
visit: Visit;
/** Cache instance */
readonly cache: Cache;
/** Hook registry */
readonly hooks: Hooks;
/** Animation class manager */
readonly classes: Classes;
/** Location of the currently visible page */
location: Location = Location.fromUrl(window.location.href);
/** URL of the currently visible page @deprecated Use swup.location.url instead */
get currentPageUrl(): string {
return this.location.url;
}
/** Index of the current history entry */
protected currentHistoryIndex: number;
/** Delegated event subscription handle */
protected clickDelegate?: DelegateEventUnsubscribe;
/** Navigation status */
protected navigating: boolean = false;
/** Run anytime a visit ends */
protected onVisitEnd?: () => Promise<unknown>;
/** Install a plugin */
use = use;
/** Uninstall a plugin */
unuse = unuse;
/** Find a plugin by name or instance */
findPlugin = findPlugin;
/** Log a message. Has no effect unless debug plugin is installed */
log: (message: string, context?: unknown) => void = () => {};
/** Navigate to a new URL */
navigate = navigate;
/** Actually perform a navigation */
protected performNavigation = performNavigation;
/** Create a new context for this visit */
protected createVisit = createVisit;
/** Register a delegated event listener */
delegateEvent = delegateEvent;
/** Fetch a page from the server */
fetchPage = fetchPage;
/** Resolve when animations on the page finish */
awaitAnimations = awaitAnimations;
protected renderPage = renderPage;
/** Replace the content after page load */
replaceContent = replaceContent;
protected animatePageIn = animatePageIn;
protected animatePageOut = animatePageOut;
protected scrollToContent = scrollToContent;
/** Find the anchor element for a given hash */
getAnchorElement = getAnchorElement;
/** Get the current page URL */
getCurrentUrl = getCurrentUrl;
/** Resolve a URL to its final location */
resolveUrl = resolveUrl;
/** Check if two URLs resolve to the same location */
protected isSameResolvedUrl = isSameResolvedUrl;
constructor(options: Partial<Options> = {}) {
// Merge defaults and options
this.options = { ...this.defaults, ...options };
this.handleLinkClick = this.handleLinkClick.bind(this);
this.handlePopState = this.handlePopState.bind(this);
this.cache = new Cache(this);
this.classes = new Classes(this);
this.hooks = new Hooks(this);
this.visit = this.createVisit({ to: '' });
this.currentHistoryIndex = (window.history.state as HistoryState)?.index ?? 1;
this.enable();
}
/** Enable this instance, adding listeners and classnames. */
async enable() {
// Add event listener
const { linkSelector } = this.options;
this.clickDelegate = this.delegateEvent(linkSelector, 'click', this.handleLinkClick);
window.addEventListener('popstate', this.handlePopState);
// Set scroll restoration to manual if animating history visits
if (this.options.animateHistoryBrowsing) {
window.history.scrollRestoration = 'manual';
}
// Initial save to cache
if (this.options.cache) {
// Disabled to avoid caching modified dom state: logic moved to preload plugin
// https://github.com/swup/swup/issues/475
}
// Sanitize/check native option
this.options.native = this.options.native && !!document.startViewTransition;
// Mount plugins
this.options.plugins.forEach((plugin) => this.use(plugin));
// Install user hooks
for (const [key, handler] of Object.entries(this.options.hooks)) {
// Build hook options from modifier suffix: 'content:replace.before' => { before: true }
const [hook, modifiers] = this.hooks.parseName(key as HookName);
// @ts-expect-error: object.entries() does not preserve key/value types
this.hooks.on(hook, handler, modifiers);
}
// Create initial history record
if ((window.history.state as HistoryState)?.source !== 'swup') {
updateHistoryRecord(null, { index: this.currentHistoryIndex });
}
// Give consumers a chance to hook into enable
await nextTick();
// Trigger enable hook
await this.hooks.call('enable', undefined, undefined, () => {
const html = document.documentElement;
html.classList.add('swup-enabled');
html.classList.toggle('swup-native', this.options.native);
});
}
/** Disable this instance, removing listeners and classnames. */
async destroy() {
// remove delegated listener
this.clickDelegate!.destroy();
// remove popstate listener
window.removeEventListener('popstate', this.handlePopState);
// empty cache
this.cache.clear();
// unmount plugins
this.plugins.forEach((plugin) => this.unuse(plugin));
// trigger disable hook
await this.hooks.call('disable', undefined, undefined, () => {
const html = document.documentElement;
html.classList.remove('swup-enabled');
html.classList.remove('swup-native');
});
// remove handlers
this.hooks.clear();
}
/** Determine if a visit should be ignored by swup, based on URL or trigger element. */
shouldIgnoreVisit(href: string, { el, event }: { el?: Element; event?: Event } = {}) {
const { origin, url, hash } = Location.fromUrl(href);
// Ignore if the new origin doesn't match the current one
if (origin !== window.location.origin) {
return true;
}
// Ignore if the link/form would open a new window (or none at all)
if (el && this.triggerWillOpenNewWindow(el)) {
return true;
}
// Ignore if the visit should be ignored as per user options
if (this.options.ignoreVisit(url + hash, { el, event })) {
return true;
}
// Finally, allow the visit
return false;
}
protected handleLinkClick(event: DelegateEvent<MouseEvent>) {
const el = event.delegateTarget as HTMLAnchorElement;
const { href, url, hash } = Location.fromElement(el);
// Exit early if the link should be ignored
if (this.shouldIgnoreVisit(href, { el, event })) {
return;
}
// Ignore if swup is currently navigating towards the link's URL
if (this.navigating && url === this.visit.to.url) {
event.preventDefault();
return;
}
const visit = this.createVisit({ to: url, hash, el, event });
// Exit early if control key pressed
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
this.hooks.callSync('link:newtab', visit, { href });
return;
}
// Exit early if other than left mouse button
if (event.button !== 0) {
return;
}
this.hooks.callSync('link:click', visit, { el, event }, () => {
const from = visit.from.url ?? '';
event.preventDefault();
// Handle links to the same page
if (!url || url === from) {
if (hash) {
// With hash: scroll to anchor
this.hooks.callSync('link:anchor', visit, { hash }, () => {
updateHistoryRecord(url + hash);
this.scrollToContent(visit);
});
} else {
// Without hash: scroll to top or load/reload page
this.hooks.callSync('link:self', visit, undefined, () => {
if (this.options.linkToSelf === 'navigate') {
this.performNavigation(visit);
} else {
updateHistoryRecord(url);
this.scrollToContent(visit);
}
});
}
return;
}
// Exit early if the resolved path hasn't changed
if (this.isSameResolvedUrl(url, from)) {
return;
}
// Finally, proceed with loading the page
this.performNavigation(visit);
});
}
protected handlePopState(event: PopStateEvent) {
const href: string = (event.state as HistoryState)?.url ?? window.location.href;
// Exit early if this event should be ignored
if (this.options.skipPopStateHandling(event)) {
return;
}
// Exit early if the resolved path hasn't changed
if (this.isSameResolvedUrl(getCurrentUrl(), this.location.url)) {
return;
}
const { url, hash } = Location.fromUrl(href);
const visit = this.createVisit({ to: url, hash, event });
// Mark as history visit
visit.history.popstate = true;
// Determine direction of history visit
const index = (event.state as HistoryState)?.index ?? 0;
if (index && index !== this.currentHistoryIndex) {
const direction = index - this.currentHistoryIndex > 0 ? 'forwards' : 'backwards';
visit.history.direction = direction;
this.currentHistoryIndex = index;
}
// Disable animation & scrolling for history visits
visit.animation.animate = false;
visit.scroll.reset = false;
visit.scroll.target = false;
// Animated history visit: re-enable animation & scroll reset
if (this.options.animateHistoryBrowsing) {
visit.animation.animate = true;
visit.scroll.reset = true;
}
this.hooks.callSync('history:popstate', visit, { event }, () => {
this.performNavigation(visit);
});
}
/** Determine whether an element will open a new tab when clicking/activating. */
protected triggerWillOpenNewWindow(triggerEl: Element) {
if (triggerEl.matches('[download], [target="_blank"]')) {
return true;
}
return false;
}
}