// Pages move the way their addresses do. // // Deeper in the address comes in from the right and going back up // comes in from the left: /kontor/ansatt sits to the right of /kontor. // A page at the same depth comes from below, or from above when it // sorts first, so the index is on top. The cards on a page leave as // islands of their own and the next page's cards arrive one after // another. // // The router swaps the page after its data has arrived, and in between // shows nothing at all. So the click is held here, before the router // sees it: the browser takes its picture of the page as it is, the // click is handed on, and the transition waits until the new page has // replaced the old one. Only transforms and opacity are animated, on // layers the browser composites itself. Without View Transitions, or // with reduced motion asked for, nothing here does anything and the // router navigates as it always did. (() => { if (!document.startViewTransition) return; const still = matchMedia("(prefers-reduced-motion: reduce)"); const MAX_CARDS = 8; const WAIT_MS = 2500; const handed = new WeakSet(); const segments = (path) => path.split("/").filter(Boolean); // forward, back, down or up: which way the new page comes from. function direction(from, to) { const a = segments(from); const b = segments(to); if (b.length > a.length) return "forward"; if (b.length < a.length) return "back"; return to < from ? "up" : "down"; } // Only the cards on screen become islands: each is a picture the // browser has to take and hold, and one nobody sees costs the same. function inView(card) { const r = card.getBoundingClientRect(); return r.bottom > 0 && r.top < innerHeight; } function name(cards, prefix) { cards.filter(inView).slice(0, MAX_CARDS).forEach((card, i) => { card.style.viewTransitionName = `${prefix}-${i + 1}`; }); } function unname() { document.querySelectorAll(".alt-card").forEach((card) => { card.style.viewTransitionName = ""; }); } // Resolves once the page that was showing has been replaced by the // one at `path`, or after WAIT_MS whatever happened. function replaced(old, path) { return new Promise((resolve) => { const done = () => { observer.disconnect(); clearTimeout(timer); resolve(); }; const check = () => { if (location.pathname !== path) return; if (document.querySelector("main.loading")) return; if (old && old.isConnected) return; if (!document.querySelector(".alternatives, main.not-found")) return; done(); }; const observer = new MutationObserver(check); observer.observe(document.body, { childList: true, subtree: true }); const timer = setTimeout(done, WAIT_MS); check(); }); } function move(from, to, handOn) { const root = document.documentElement; const old = document.querySelector(".alternatives"); root.dataset.motion = direction(from, to); name([...document.querySelectorAll(".alt-card")], "pm-out"); const transition = document.startViewTransition(async () => { unname(); handOn(); await replaced(old, to); name([...document.querySelectorAll(".alt-card")], "pm-in"); }); transition.finished.finally(() => { unname(); delete root.dataset.motion; }); } document.addEventListener( "click", (ev) => { if (still.matches || handed.has(ev) || ev.defaultPrevented) return; if (ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return; const a = ev.target.closest && ev.target.closest("a[href]"); if (!a || a.target || a.hasAttribute("download")) return; if ((a.getAttribute("rel") || "").split(/\s+/).includes("external")) return; const url = new URL(a.href, location.href); if (url.origin !== location.origin || url.pathname === location.pathname) return; ev.preventDefault(); ev.stopImmediatePropagation(); move(location.pathname, url.pathname, () => { const again = new MouseEvent("click", { bubbles: true, cancelable: true, composed: true, button: 0 }); handed.add(again); a.dispatchEvent(again); }); }, true, ); // Back and forward: the address has already changed when this runs, // so the router is told a moment later, inside the transition. let shown = location.pathname; window.addEventListener( "popstate", (ev) => { const to = location.pathname; const from = shown; shown = to; if (still.matches || handed.has(ev) || from === to) return; ev.stopImmediatePropagation(); move(from, to, () => { const again = new PopStateEvent("popstate", { state: ev.state }); handed.add(again); window.dispatchEvent(again); }); }, true, ); // Keep `shown` right after the router's own pushes. for (const method of ["pushState", "replaceState"]) { const original = history[method].bind(history); history[method] = (...args) => { const result = original(...args); shown = location.pathname; return result; }; } })();