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:
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-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-toast: 1000;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { theme } from '$lib/theme.svelte';
|
||||
import AppBar from '$lib/components/AppBar.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 Upload from '@lucide/svelte/icons/upload';
|
||||
import UserCircle from '@lucide/svelte/icons/user-circle';
|
||||
@@ -198,6 +199,8 @@
|
||||
<title>{layoutTitle}</title>
|
||||
</svelte:head>
|
||||
|
||||
<NavProgress />
|
||||
|
||||
{#if showMobileChrome}
|
||||
<div class="mobile-app-bar-wrap" bind:this={appBarEl}>
|
||||
<AppBar title="Mangalord" testid="mobile-app-bar" />
|
||||
|
||||
Reference in New Issue
Block a user