feat(web): add navigation progress bar #115
No files matched your search
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user