feat(web): add navigation progress bar #115

Merged
eleith merged 1 commits from feat/navigation-progress into main 2026-11-02 13:50:17 +00:00
4 changed files with 340 additions and 0 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
'@when/web': patch
---
Show a themed navigation progress bar using native SvelteKit state and Svelte motion. Preserve a short completion cue across full-page transitions with session storage, handle back-to-back navigation safely, and respect reduced-motion preferences.
+211
View File
@@ -0,0 +1,211 @@
import { expect, test, type Page, type Locator } from './support/test.ts';
import { CHAT_MEETING } from './support/seed.ts';
const STORAGE_KEY = 'when.navigation-pending';
const progressBar = (page: Page) => page.getByRole('progressbar', { name: 'Loading page' });
const themeButton = (page: Page) =>
page.getByRole('button', {
name: /^(Switch to dark theme|Switch to light theme|Use system theme)$/
});
type ProgressProbe = { incoming: string | null; seen: boolean };
type ObservedWindow = typeof window & { progressProbe: ProgressProbe };
async function openWithPausedClock(page: Page) {
const noon = new Date();
noon.setUTCHours(12, 0, 0, 0);
await page.clock.install({ time: noon });
await page.goto('/');
await expect(themeButton(page)).toBeEnabled();
await page.clock.pauseAt(new Date(noon.getTime() + 60_000));
await expect(progressBar(page)).toHaveCount(0);
}
async function followLink(page: Page, link: Locator) {
await link.focus();
await page.keyboard.press('Enter');
// Kit defers link navigation through RAF/timers; input alone cannot advance a paused clock.
await page.clock.runFor(100);
}
async function holdPageLoad(page: Page, path: string) {
const requested = Promise.withResolvers<void>();
const release = Promise.withResolvers<void>();
const dataPath = path === '/' ? '/__data.json' : `${path}/__data.json`;
await page.route(
(url) => url.pathname === dataPath,
async (route) => {
requested.resolve();
await release.promise;
await route.continue();
},
{ times: 1 }
);
return { requested: requested.promise, release: release.resolve };
}
async function fillScale(bar: Locator) {
return bar
.locator('.fill')
.evaluate(
(fill) =>
fill.getBoundingClientRect().width / fill.parentElement!.getBoundingClientRect().width
);
}
test('a new navigation survives the previous navigation finishing', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'no-preference' });
await openWithPausedClock(page);
const bar = progressBar(page);
const first = await holdPageLoad(page, '/schedule/chat');
try {
const link = page.getByRole('link', { name: CHAT_MEETING.title });
await followLink(page, link);
await first.requested;
await expect(bar).toBeVisible();
await expect(bar).not.toHaveAttribute('aria-valuenow');
await expect(bar).toHaveCSS('pointer-events', 'none');
await page.clock.runFor(300);
const pendingScale = await fillScale(bar);
expect(pendingScale).toBeGreaterThan(0.2);
expect(pendingScale).toBeLessThan(0.9);
first.release();
await expect(page).toHaveURL('/schedule/chat');
await page.clock.runFor(32);
expect(await fillScale(bar)).toBeGreaterThan(pendingScale + 0.05);
const second = await holdPageLoad(page, '/');
try {
const back = page.getByRole('link', { name: 'Back', exact: true });
await followLink(page, back);
await second.requested;
await expect(bar).toBeVisible();
// Virtual frames cross the old completion deadline while the next response is held.
await page.clock.runFor(500);
await expect(bar).toBeVisible();
expect(await fillScale(bar)).toBeGreaterThan(0.2);
expect(await fillScale(bar)).toBeLessThan(0.9);
second.release();
await expect(page).toHaveURL('/');
await page.clock.runFor(450);
await expect(bar).toHaveCount(0);
expect(await page.evaluate((key) => sessionStorage.getItem(key), STORAGE_KEY)).toBeNull();
} finally {
second.release();
}
} finally {
first.release();
}
});
test('document transitions consume a fresh handoff and reject stale or invalid markers', async ({
page
}) => {
await openWithPausedClock(page);
await page.addInitScript((key) => {
const probe: ProgressProbe = { incoming: sessionStorage.getItem(key), seen: false };
(window as ObservedWindow).progressProbe = probe;
new MutationObserver(() => {
if (document.querySelector('[role="progressbar"][aria-label="Loading page"]'))
probe.seen = true;
}).observe(document, { childList: true, subtree: true });
}, STORAGE_KEY);
await page.reload();
await expect(themeButton(page)).toBeEnabled();
const probe = await page.evaluate(() => (window as ObservedWindow).progressProbe);
expect(Number.isFinite(Number(probe.incoming))).toBe(true);
expect(probe.incoming).not.toBeNull();
await expect
.poll(() => page.evaluate(() => (window as ObservedWindow).progressProbe.seen))
.toBe(true);
expect(await page.evaluate((key) => sessionStorage.getItem(key), STORAGE_KEY)).toBeNull();
await expect(progressBar(page)).toBeVisible();
expect(await fillScale(progressBar(page))).toBeCloseTo(0.9, 2);
await page.clock.runFor(250);
await expect(progressBar(page)).toHaveCount(0);
// Exercise the persisted-page consumer without depending on browser BFCache eligibility.
await page.evaluate((key) => {
sessionStorage.setItem(key, String(Date.now() - 5000));
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }));
}, STORAGE_KEY);
await expect(progressBar(page)).toBeVisible();
expect(await page.evaluate((key) => sessionStorage.getItem(key), STORAGE_KEY)).toBeNull();
await page.clock.runFor(250);
await expect(progressBar(page)).toHaveCount(0);
for (const marker of ['invalid', 'stale', 'future']) {
await page.evaluate(
({ key, marker }) => {
(window as ObservedWindow).progressProbe.seen = false;
let value = 'not-a-time';
if (marker === 'stale') value = String(Date.now() - 5001);
else if (marker === 'future') value = String(Date.now() + 1);
sessionStorage.setItem(key, value);
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }));
},
{ key: STORAGE_KEY, marker }
);
await page.clock.runFor(32);
await expect(progressBar(page)).toHaveCount(0);
expect(await page.evaluate(() => (window as ObservedWindow).progressProbe.seen)).toBe(false);
expect(await page.evaluate((key) => sessionStorage.getItem(key), STORAGE_KEY)).toBeNull();
}
});
test('blocked handoff storage leaves reduced-motion navigation usable', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.setViewportSize({ width: 390, height: 844 });
await page.addInitScript((blockedKey) => {
const getItem = Storage.prototype.getItem;
const setItem = Storage.prototype.setItem;
const removeItem = Storage.prototype.removeItem;
Storage.prototype.getItem = function (key) {
if (key === blockedKey) throw new DOMException('Storage blocked', 'SecurityError');
return getItem.call(this, key);
};
Storage.prototype.setItem = function (key, value) {
if (key === blockedKey) throw new DOMException('Storage blocked', 'SecurityError');
return setItem.call(this, key, value);
};
Storage.prototype.removeItem = function (key) {
if (key === blockedKey) throw new DOMException('Storage blocked', 'SecurityError');
return removeItem.call(this, key);
};
}, STORAGE_KEY);
await openWithPausedClock(page);
const held = await holdPageLoad(page, '/schedule/chat');
try {
const link = page.getByRole('link', { name: CHAT_MEETING.title });
await followLink(page, link);
await held.requested;
const bar = progressBar(page);
await expect(bar).toBeVisible();
await expect(bar).not.toHaveAttribute('aria-valuenow');
const scale = await fillScale(bar);
expect(scale).toBeCloseTo(0.9, 2);
await page.clock.runFor(1000);
expect(await fillScale(bar)).toBeCloseTo(scale, 5);
const bounds = await bar.boundingBox();
expect(bounds!.x).toBe(0);
expect(bounds!.y).toBe(0);
expect(bounds!.width).toBe(390);
held.release();
await expect(page).toHaveURL('/schedule/chat');
await page.clock.runFor(450);
await expect(bar).toHaveCount(0);
} finally {
held.release();
}
await page.reload();
await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible();
const back = page.getByRole('link', { name: 'Back', exact: true });
await followLink(page, back);
await expect(page).toHaveURL('/');
await expect(themeButton(page)).toBeEnabled();
await page.clock.runFor(450);
await expect(progressBar(page)).toHaveCount(0);
expect(errors).toEqual([]);
});
@@ -0,0 +1,121 @@
<script lang="ts">
import { navigating } from '$app/state';
import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { prefersReducedMotion, Tween } from 'svelte/motion';
const MIN_DISPLAY_MS = 200;
const FINISH_MS = 200;
const FRESH_MS = 5000;
const STORAGE_KEY = 'when.navigation-pending';
let visible = $state(false);
let startedAt = 0;
let completionTimer: ReturnType<typeof setTimeout> | undefined;
let resumeFrame: number | undefined;
const progress = new Tween(0, { easing: cubicOut });
function cancelCompletion() {
clearTimeout(completionTimer);
completionTimer = undefined;
if (resumeFrame !== undefined) {
cancelAnimationFrame(resumeFrame);
resumeFrame = undefined;
}
progress.set(progress.current, { duration: 0 });
}
function finish(reducedMotion: boolean) {
clearTimeout(completionTimer);
progress.set(100, { duration: reducedMotion ? 0 : FINISH_MS });
completionTimer = setTimeout(() => {
visible = false;
startedAt = 0;
}, FINISH_MS);
}
$effect(() => {
const reducedMotion = prefersReducedMotion.current;
if (navigating.to) {
visible = true;
startedAt = Date.now();
progress.set(20, { duration: 0 });
progress.set(90, { duration: reducedMotion ? 0 : 10000 });
} else if (startedAt > 0) {
const remaining = Math.max(0, MIN_DISPLAY_MS - (Date.now() - startedAt));
completionTimer = setTimeout(() => finish(reducedMotion), remaining);
}
return cancelCompletion;
});
onMount(() => {
function restorePendingNavigation() {
let pending: string | null;
try {
pending = sessionStorage.getItem(STORAGE_KEY);
sessionStorage.removeItem(STORAGE_KEY);
} catch {
return;
}
if (!pending) return;
const age = Date.now() - Number(pending);
if (!Number.isFinite(age) || age < 0 || age > FRESH_MS || navigating.to) return;
cancelCompletion();
visible = true;
startedAt = Date.now();
progress.set(90, { duration: 0 });
resumeFrame = requestAnimationFrame(() => {
resumeFrame = undefined;
finish(prefersReducedMotion.current);
});
}
function markPendingNavigation() {
try {
sessionStorage.setItem(STORAGE_KEY, String(Date.now()));
} catch {
// Navigation feedback still works when storage is unavailable.
}
}
function restoreFromHistory(event: PageTransitionEvent) {
if (event.persisted) restorePendingNavigation();
}
restorePendingNavigation();
window.addEventListener('pagehide', markPendingNavigation);
window.addEventListener('pageshow', restoreFromHistory);
return () => {
window.removeEventListener('pagehide', markPendingNavigation);
window.removeEventListener('pageshow', restoreFromHistory);
cancelCompletion();
};
});
</script>
{#if visible}
<div class="navigation-progress" role="progressbar" aria-label="Loading page">
<div class="fill" style:transform={`scaleX(${progress.current / 100})`}></div>
</div>
{/if}
<style>
.navigation-progress {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
height: var(--space-1);
pointer-events: none;
overflow: hidden;
}
.fill {
width: 100%;
height: 100%;
background: var(--when-color-primary);
transform-origin: left;
}
</style>
+3
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import NavigationProgressBar from '#lib/components/NavigationProgressBar.svelte';
import '#lib/styles/reset.css';
import '#lib/styles/theme.css';
import '#lib/styles/calendar.css';
@@ -40,6 +41,8 @@
<meta name="twitter:image" content={ogImage} />
</svelte:head>
<NavigationProgressBar />
<div class="app-root">
{@render children()}
</div>