feat: global navigation progress bar
Most data routes run their load with ssr = false, so SvelteKit holds the previous page on screen while the next one's data resolves — with no feedback. NavProgress, mounted once in the root layout and driven by the $navigating store, shows a thin top bar that trickles while a navigation is pending and fades out on completion. Degrades to a static visible bar under prefers-reduced-motion. New --z-nav-progress token sits above the fixed header but below modals/toasts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.111.0"
|
version = "0.112.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.111.0"
|
version = "0.112.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
147
frontend/e2e/nav-progress.spec.ts
Normal file
147
frontend/e2e/nav-progress.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// The global nav progress bar (rendered once in the root layout) is the only
|
||||||
|
// feedback most data routes have while a client-side navigation is pending:
|
||||||
|
// they run `load` with `ssr = false`, so SvelteKit holds the previous page on
|
||||||
|
// screen with no indication anything is happening. This drives a real
|
||||||
|
// home -> detail navigation, holds the detail's `getManga` open, and asserts
|
||||||
|
// the bar activates during the pending load and clears once it settles.
|
||||||
|
|
||||||
|
const mangaId = 'm1111111-1111-1111-1111-111111111111';
|
||||||
|
|
||||||
|
const listItem = {
|
||||||
|
id: mangaId,
|
||||||
|
title: 'Berserk',
|
||||||
|
status: 'ongoing',
|
||||||
|
alt_titles: [],
|
||||||
|
description: null,
|
||||||
|
cover_image_path: null,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
authors: [],
|
||||||
|
genres: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const mangaDetail = {
|
||||||
|
id: mangaId,
|
||||||
|
title: 'Berserk',
|
||||||
|
status: 'ongoing',
|
||||||
|
alt_titles: [],
|
||||||
|
description: null,
|
||||||
|
cover_image_path: null,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
authors: [],
|
||||||
|
genres: [],
|
||||||
|
tags: [],
|
||||||
|
content_warnings: [],
|
||||||
|
chapter_storage_bytes: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
async function mockCommon(page: Page) {
|
||||||
|
await page.route('**/api/v1/auth/config', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/genres*', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
|
||||||
|
);
|
||||||
|
// Catalog list (Pattern B). Registered before the detail routes so the
|
||||||
|
// more-specific handlers below win for the /mangas/:id URLs.
|
||||||
|
await page.route('**/api/v1/mangas*', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ items: [listItem], page: { limit: 50, offset: 0, total: 1 } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Detail load's non-manga calls.
|
||||||
|
await page.route('**/api/v1/me/bookmarks*', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/similar`, (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 404,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'not_found', message: 'no' } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/me/reactions/${mangaId}`, (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 404,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'not_found', message: 'no' } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('shows the nav progress bar during a pending navigation and clears it after', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await mockCommon(page);
|
||||||
|
|
||||||
|
// Hold the detail's getManga open so the navigation stays pending long
|
||||||
|
// enough to observe the bar. Registered last => wins for the exact
|
||||||
|
// /mangas/:id URL over the catalog glob.
|
||||||
|
let releaseDetail: () => void = () => {};
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
releaseDetail = resolve;
|
||||||
|
});
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}`, async (route) => {
|
||||||
|
await gate;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(mangaDetail)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByTestId('manga-list')).toBeVisible();
|
||||||
|
|
||||||
|
const bar = page.getByTestId('nav-progress');
|
||||||
|
// Idle on a settled page.
|
||||||
|
await expect(bar).not.toHaveClass(/active/);
|
||||||
|
|
||||||
|
// Client-side navigate into the (gated) detail page.
|
||||||
|
await page.locator(`a[href="/manga/${mangaId}"]`).first().click();
|
||||||
|
|
||||||
|
// Bar activates while the detail load is in flight.
|
||||||
|
await expect(bar).toHaveClass(/active/);
|
||||||
|
|
||||||
|
// Let the load resolve; the detail renders and the bar goes idle again.
|
||||||
|
releaseDetail();
|
||||||
|
await expect(page.getByTestId('manga-title')).toHaveText('Berserk');
|
||||||
|
await expect(bar).not.toHaveClass(/active/);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.111.0",
|
"version": "0.112.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
58
frontend/src/lib/components/NavProgress.svelte
Normal file
58
frontend/src/lib/components/NavProgress.svelte
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { navigating } from '$app/stores';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin top-of-viewport progress bar that shows during client-side
|
||||||
|
* navigation. Most data routes run their `load` with `ssr = false`, so
|
||||||
|
* SvelteKit keeps the previous page on screen while the next one's data
|
||||||
|
* resolves — with no feedback. This bar is that feedback: it trickles
|
||||||
|
* toward the right edge while a navigation is pending and fades out once
|
||||||
|
* it settles.
|
||||||
|
*
|
||||||
|
* Decorative (`aria-hidden`). Under the global prefers-reduced-motion
|
||||||
|
* override the trickle animation is disabled, so the `.active` rule keeps
|
||||||
|
* a static visible bar during navigation instead of an empty sliver.
|
||||||
|
*/
|
||||||
|
const active = $derived(!!$navigating);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="nav-progress" class:active data-testid="nav-progress" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.nav-progress {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 3px;
|
||||||
|
width: 0;
|
||||||
|
background: var(--primary);
|
||||||
|
z-index: var(--z-nav-progress);
|
||||||
|
opacity: 0;
|
||||||
|
/* Fade out on completion; the trickle (width) is handled by the
|
||||||
|
keyframe below while active. */
|
||||||
|
transition: opacity 200ms ease-out;
|
||||||
|
pointer-events: none;
|
||||||
|
will-change: width, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-progress.active {
|
||||||
|
opacity: 1;
|
||||||
|
/* Base (non-animated) width — this is what reduced-motion users see,
|
||||||
|
and the keyframe's end state, so disabling the animation still
|
||||||
|
leaves a clearly visible bar. */
|
||||||
|
width: 90%;
|
||||||
|
animation: nav-progress-trickle 8s cubic-bezier(0.1, 0.7, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nav-progress-trickle {
|
||||||
|
0% {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
width: 65%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
64
frontend/src/lib/components/NavProgress.svelte.test.ts
Normal file
64
frontend/src/lib/components/NavProgress.svelte.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
|
import { render, screen, cleanup } from '@testing-library/svelte';
|
||||||
|
|
||||||
|
// A controllable stand-in for SvelteKit's `navigating` store: `null` when
|
||||||
|
// idle, a Navigation-like object while a client-side navigation is pending.
|
||||||
|
// Built via vi.hoisted (a minimal writable, so no import is needed before
|
||||||
|
// the hoisted vi.mock factory runs).
|
||||||
|
const { navigating } = vi.hoisted(() => {
|
||||||
|
let value: unknown = null;
|
||||||
|
const subs = new Set<(v: unknown) => void>();
|
||||||
|
return {
|
||||||
|
navigating: {
|
||||||
|
subscribe(fn: (v: unknown) => void) {
|
||||||
|
subs.add(fn);
|
||||||
|
fn(value);
|
||||||
|
return () => subs.delete(fn);
|
||||||
|
},
|
||||||
|
set(v: unknown) {
|
||||||
|
value = v;
|
||||||
|
subs.forEach((fn) => fn(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('$app/stores', () => ({ navigating }));
|
||||||
|
|
||||||
|
import NavProgress from './NavProgress.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
navigating.set(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NavProgress', () => {
|
||||||
|
it('renders a decorative bar carrying the nav-progress testid', () => {
|
||||||
|
render(NavProgress);
|
||||||
|
const bar = screen.getByTestId('nav-progress');
|
||||||
|
expect(bar).toBeTruthy();
|
||||||
|
expect(bar.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is inactive when not navigating', () => {
|
||||||
|
render(NavProgress);
|
||||||
|
expect(screen.getByTestId('nav-progress').classList.contains('active')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('becomes active while a navigation is pending', async () => {
|
||||||
|
render(NavProgress);
|
||||||
|
navigating.set({ from: null, to: { url: new URL('http://x/manga/1') } });
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(screen.getByTestId('nav-progress').classList.contains('active')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns to inactive once navigation settles', async () => {
|
||||||
|
render(NavProgress);
|
||||||
|
navigating.set({ from: null, to: { url: new URL('http://x/manga/1') } });
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(screen.getByTestId('nav-progress').classList.contains('active')).toBe(true);
|
||||||
|
navigating.set(null);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(screen.getByTestId('nav-progress').classList.contains('active')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -95,6 +95,9 @@
|
|||||||
|
|
||||||
--z-dropdown: 10;
|
--z-dropdown: 10;
|
||||||
--z-sticky: 50;
|
--z-sticky: 50;
|
||||||
|
/* Above the fixed header/app-bar (--z-sticky) so the nav progress bar
|
||||||
|
is never occluded, but below modals and toasts. */
|
||||||
|
--z-nav-progress: 60;
|
||||||
--z-modal: 100;
|
--z-modal: 100;
|
||||||
--z-toast: 1000;
|
--z-toast: 1000;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { theme } from '$lib/theme.svelte';
|
import { theme } from '$lib/theme.svelte';
|
||||||
import AppBar from '$lib/components/AppBar.svelte';
|
import AppBar from '$lib/components/AppBar.svelte';
|
||||||
import BottomNav, { type BottomNavTab } from '$lib/components/BottomNav.svelte';
|
import BottomNav, { type BottomNavTab } from '$lib/components/BottomNav.svelte';
|
||||||
|
import NavProgress from '$lib/components/NavProgress.svelte';
|
||||||
import IconButton from '$lib/components/IconButton.svelte';
|
import IconButton from '$lib/components/IconButton.svelte';
|
||||||
import Upload from '@lucide/svelte/icons/upload';
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
import UserCircle from '@lucide/svelte/icons/user-circle';
|
import UserCircle from '@lucide/svelte/icons/user-circle';
|
||||||
@@ -198,6 +199,8 @@
|
|||||||
<title>{layoutTitle}</title>
|
<title>{layoutTitle}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
<NavProgress />
|
||||||
|
|
||||||
{#if showMobileChrome}
|
{#if showMobileChrome}
|
||||||
<div class="mobile-app-bar-wrap" bind:this={appBarEl}>
|
<div class="mobile-app-bar-wrap" bind:this={appBarEl}>
|
||||||
<AppBar title="Mangalord" testid="mobile-app-bar" />
|
<AppBar title="Mangalord" testid="mobile-app-bar" />
|
||||||
|
|||||||
Reference in New Issue
Block a user