feat(frontend): mobile primitives + bottom-nav chrome (0.56.0)
Phase 1 of the mobile redesign: add the reusable primitives every later phase plugs into, and switch the layout to a 4-tab bottom nav + compact AppBar on phone viewports while leaving the desktop header untouched above 640px. - New primitives: BottomNav, Sheet, AppBar, SegmentedControl, all with vitest unit tests. - Layout swaps the desktop header for AppBar + BottomNav under the existing 640px breakpoint. Login / register / reader routes opt out of the mobile chrome. - Library tab currently points at /bookmarks; the curated /library wrapper lands in Phase 5. - Independent ResizeObservers publish --app-header-h, --mobile-app-bar-h, --app-bottom-nav-h, with a zero-height skip so hidden bars don't clobber the visible one's value. - viewport-fit=cover + env(safe-area-inset-*) tokens for notched devices and the iOS home indicator. - Playwright spec covers visibility at 390px / 1280px viewports and tab navigation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.55.0"
|
||||
version = "0.56.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.55.0"
|
||||
version = "0.56.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
84
frontend/e2e/mobile-chrome.spec.ts
Normal file
84
frontend/e2e/mobile-chrome.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
// Mobile chrome contract: the AppBar + BottomNav are visible on phone
|
||||
// viewports and the desktop header is hidden. On desktop, the reverse.
|
||||
// On the reader route, both mobile bars are hidden so the reader's own
|
||||
// chrome owns the screen. The 640px cutover is the project's single
|
||||
// existing breakpoint and is also used here.
|
||||
|
||||
const MOBILE = { width: 390, height: 844 } as const;
|
||||
const DESKTOP = { width: 1280, height: 720 } as const;
|
||||
|
||||
async function mockAnonymous(page: Page) {
|
||||
await page.route('**/api/v1/auth/config', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/auth/me', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } })
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/mangas*', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('mobile chrome', () => {
|
||||
test('phone viewport on /: bottom nav visible, desktop header hidden', async ({ page }) => {
|
||||
await mockAnonymous(page);
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByTestId('bottom-nav')).toBeVisible();
|
||||
await expect(page.getByTestId('mobile-app-bar')).toBeVisible();
|
||||
await expect(page.locator('header.desktop-header')).toBeHidden();
|
||||
});
|
||||
|
||||
test('desktop viewport on /: desktop header visible, mobile chrome hidden', async ({ page }) => {
|
||||
await mockAnonymous(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.locator('header.desktop-header')).toBeVisible();
|
||||
await expect(page.getByTestId('bottom-nav')).toBeHidden();
|
||||
await expect(page.getByTestId('mobile-app-bar')).toBeHidden();
|
||||
});
|
||||
|
||||
test('phone viewport: Home tab carries aria-current on /', async ({ page }) => {
|
||||
await mockAnonymous(page);
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.goto('/');
|
||||
|
||||
const home = page.getByTestId('bottom-nav-home');
|
||||
await expect(home).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
test('phone viewport: tapping Library navigates to /bookmarks and marks itself active', async ({ page }) => {
|
||||
await mockAnonymous(page);
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByTestId('bottom-nav-library').click();
|
||||
await expect(page).toHaveURL(/\/bookmarks$/);
|
||||
await expect(page.getByTestId('bottom-nav-library')).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
test('phone viewport: login route hides the mobile chrome (auth gateway pattern)', async ({ page }) => {
|
||||
await mockAnonymous(page);
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.goto('/login');
|
||||
|
||||
await expect(page.getByTestId('bottom-nav')).toBeHidden();
|
||||
await expect(page.getByTestId('mobile-app-bar')).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.55.0",
|
||||
"version": "0.56.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
||||
/>
|
||||
<title>Mangalord</title>
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
114
frontend/src/lib/components/AppBar.svelte
Normal file
114
frontend/src/lib/components/AppBar.svelte
Normal file
@@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
|
||||
let {
|
||||
title,
|
||||
backHref,
|
||||
onBack,
|
||||
trailing,
|
||||
testid
|
||||
}: {
|
||||
title: string;
|
||||
/**
|
||||
* If set, the leading slot renders a back chevron linking here.
|
||||
* Mutually exclusive with onBack (href wins). Omit both for a
|
||||
* brand-only bar (no leading control).
|
||||
*/
|
||||
backHref?: string;
|
||||
onBack?: () => void;
|
||||
trailing?: Snippet;
|
||||
testid?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<header class="app-bar" data-testid={testid}>
|
||||
<div class="leading">
|
||||
{#if backHref}
|
||||
<a
|
||||
href={backHref}
|
||||
class="back"
|
||||
aria-label="Back"
|
||||
data-testid={testid ? `${testid}-back` : undefined}
|
||||
>
|
||||
<ChevronLeft size={22} aria-hidden="true" />
|
||||
</a>
|
||||
{:else if onBack}
|
||||
<button
|
||||
type="button"
|
||||
class="back"
|
||||
onclick={onBack}
|
||||
aria-label="Back"
|
||||
data-testid={testid ? `${testid}-back` : undefined}
|
||||
>
|
||||
<ChevronLeft size={22} aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<h1 class="title">{title}</h1>
|
||||
<div class="trailing">
|
||||
{#if trailing}{@render trailing()}{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.app-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
padding-top: calc(var(--space-2) + var(--safe-top));
|
||||
padding-left: calc(var(--space-3) + var(--safe-left));
|
||||
padding-right: calc(var(--space-3) + var(--safe-right));
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.leading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
background: var(--surface-elevated);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--weight-semibold);
|
||||
line-height: var(--leading-tight);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
/* Long titles ellipsize rather than wrap — the bar must stay
|
||||
a single row to keep its measured height predictable. */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trailing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-1);
|
||||
min-width: 44px;
|
||||
}
|
||||
</style>
|
||||
40
frontend/src/lib/components/AppBar.svelte.test.ts
Normal file
40
frontend/src/lib/components/AppBar.svelte.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup } from '@testing-library/svelte';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
import AppBar from './AppBar.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('AppBar', () => {
|
||||
it('renders the title as the h1', () => {
|
||||
render(AppBar, { props: { title: 'Mangalord' } });
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Mangalord' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('omits the back control when neither backHref nor onBack is provided', () => {
|
||||
render(AppBar, { props: { title: 'Home' } });
|
||||
expect(screen.queryByRole('link', { name: 'Back' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Back' })).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a back link when backHref is provided', () => {
|
||||
render(AppBar, { props: { title: 'Detail', backHref: '/library' } });
|
||||
const back = screen.getByRole('link', { name: 'Back' });
|
||||
expect((back as HTMLAnchorElement).getAttribute('href')).toBe('/library');
|
||||
});
|
||||
|
||||
it('renders a back button calling onBack when onBack is provided (and no href)', () => {
|
||||
const onBack = vi.fn();
|
||||
render(AppBar, { props: { title: 'Detail', onBack } });
|
||||
screen.getByRole('button', { name: 'Back' }).click();
|
||||
expect(onBack).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders the trailing snippet when provided', () => {
|
||||
const trailing = createRawSnippet(() => ({
|
||||
render: () => `<button type="button" aria-label="More">…</button>`
|
||||
}));
|
||||
render(AppBar, { props: { title: 'Detail', trailing } });
|
||||
expect(screen.getByRole('button', { name: 'More' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
123
frontend/src/lib/components/BottomNav.svelte
Normal file
123
frontend/src/lib/components/BottomNav.svelte
Normal file
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
export interface BottomNavTab {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: Component<{ size?: number; 'aria-hidden'?: boolean | 'true' | 'false' }>;
|
||||
/**
|
||||
* URL pathname prefixes that mark this tab as active. Longest
|
||||
* matching prefix wins across tabs, so order matters less than
|
||||
* specificity (e.g. '/profile/account' beats '/profile'). The
|
||||
* tab's own `href` is implicitly included.
|
||||
*/
|
||||
match: string[];
|
||||
}
|
||||
|
||||
let {
|
||||
tabs,
|
||||
currentPath,
|
||||
testid = 'bottom-nav'
|
||||
}: {
|
||||
tabs: BottomNavTab[];
|
||||
currentPath: string;
|
||||
testid?: string;
|
||||
} = $props();
|
||||
|
||||
/**
|
||||
* Pick the tab with the longest `match` prefix that matches the
|
||||
* current path. Ties (no match) fall back to no active tab. Each
|
||||
* tab's own href is treated as a match candidate too, so a Home
|
||||
* tab pointing at '/' is active on '/' without an explicit entry.
|
||||
*/
|
||||
const activeHref = $derived.by(() => {
|
||||
let best: { href: string; len: number } | null = null;
|
||||
for (const tab of tabs) {
|
||||
const candidates = [tab.href, ...tab.match];
|
||||
for (const prefix of candidates) {
|
||||
if (!prefixMatches(currentPath, prefix)) continue;
|
||||
if (!best || prefix.length > best.len) {
|
||||
best = { href: tab.href, len: prefix.length };
|
||||
}
|
||||
}
|
||||
}
|
||||
return best?.href ?? null;
|
||||
});
|
||||
|
||||
function prefixMatches(path: string, prefix: string): boolean {
|
||||
if (prefix === '/') return path === '/';
|
||||
return path === prefix || path.startsWith(prefix + '/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="bottom-nav" aria-label="Primary" data-testid={testid}>
|
||||
{#each tabs as tab (tab.href + tab.label)}
|
||||
{@const isActive = activeHref === tab.href}
|
||||
<a
|
||||
href={tab.href}
|
||||
class="tab"
|
||||
class:active={isActive}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
data-testid={`${testid}-${tab.label.toLowerCase()}`}
|
||||
>
|
||||
<tab.icon size={22} aria-hidden="true" />
|
||||
<span class="label">{tab.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: var(--z-sticky);
|
||||
display: grid;
|
||||
grid-auto-columns: 1fr;
|
||||
grid-auto-flow: column;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
/* Safe-area inset lifts the bar above the iOS home indicator
|
||||
without collapsing the layout on non-notch devices (env()
|
||||
resolves to 0 there). */
|
||||
padding-bottom: var(--safe-bottom);
|
||||
padding-left: var(--safe-left);
|
||||
padding-right: var(--safe-right);
|
||||
transition: transform 220ms ease-out;
|
||||
}
|
||||
|
||||
/* Reader fullscreen mode slides the bar off-screen, mirroring the
|
||||
top-header pattern in +layout.svelte. */
|
||||
:global(html[data-reader-fullscreen='true']) .bottom-nav {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: var(--space-2) var(--space-1);
|
||||
min-height: 56px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
72
frontend/src/lib/components/BottomNav.svelte.test.ts
Normal file
72
frontend/src/lib/components/BottomNav.svelte.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup } from '@testing-library/svelte';
|
||||
import Home from '@lucide/svelte/icons/house';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import BookOpen from '@lucide/svelte/icons/book-open';
|
||||
import User from '@lucide/svelte/icons/user';
|
||||
import BottomNav, { type BottomNavTab } from './BottomNav.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const tabs: BottomNavTab[] = [
|
||||
{ label: 'Home', href: '/', icon: Home, match: [] },
|
||||
{ label: 'Browse', href: '/browse', icon: Search, match: [] },
|
||||
{
|
||||
label: 'Library',
|
||||
href: '/library',
|
||||
icon: BookOpen,
|
||||
match: ['/bookmarks', '/collections', '/profile/bookmarks', '/profile/collections', '/profile/history']
|
||||
},
|
||||
{
|
||||
label: 'Account',
|
||||
href: '/profile/account',
|
||||
icon: User,
|
||||
match: ['/profile', '/profile/preferences']
|
||||
}
|
||||
];
|
||||
|
||||
describe('BottomNav', () => {
|
||||
it('renders all tabs as links with correct hrefs', () => {
|
||||
render(BottomNav, { props: { tabs, currentPath: '/' } });
|
||||
for (const tab of tabs) {
|
||||
const link = screen.getByRole('link', { name: new RegExp(tab.label) });
|
||||
expect((link as HTMLAnchorElement).getAttribute('href')).toBe(tab.href);
|
||||
}
|
||||
});
|
||||
|
||||
it('marks Home as aria-current on the root path', () => {
|
||||
render(BottomNav, { props: { tabs, currentPath: '/' } });
|
||||
const home = screen.getByRole('link', { name: /Home/ });
|
||||
expect(home.getAttribute('aria-current')).toBe('page');
|
||||
const library = screen.getByRole('link', { name: /Library/ });
|
||||
expect(library.getAttribute('aria-current')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks Library as active when on /bookmarks', () => {
|
||||
render(BottomNav, { props: { tabs, currentPath: '/bookmarks' } });
|
||||
const library = screen.getByRole('link', { name: /Library/ });
|
||||
expect(library.getAttribute('aria-current')).toBe('page');
|
||||
});
|
||||
|
||||
it('marks Library as active on nested /profile/collections', () => {
|
||||
render(BottomNav, { props: { tabs, currentPath: '/profile/collections' } });
|
||||
const library = screen.getByRole('link', { name: /Library/ });
|
||||
expect(library.getAttribute('aria-current')).toBe('page');
|
||||
});
|
||||
|
||||
it('prefers the longer prefix when matches overlap (Account beats Library on /profile/account)', () => {
|
||||
render(BottomNav, { props: { tabs, currentPath: '/profile/account' } });
|
||||
const account = screen.getByRole('link', { name: /Account/ });
|
||||
const library = screen.getByRole('link', { name: /Library/ });
|
||||
expect(account.getAttribute('aria-current')).toBe('page');
|
||||
expect(library.getAttribute('aria-current')).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves all tabs inactive on an unknown route', () => {
|
||||
render(BottomNav, { props: { tabs, currentPath: '/unknown' } });
|
||||
for (const tab of tabs) {
|
||||
const link = screen.getByRole('link', { name: new RegExp(tab.label) });
|
||||
expect(link.getAttribute('aria-current')).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
74
frontend/src/lib/components/SegmentedControl.svelte
Normal file
74
frontend/src/lib/components/SegmentedControl.svelte
Normal file
@@ -0,0 +1,74 @@
|
||||
<script lang="ts" generics="T extends string">
|
||||
interface Option {
|
||||
label: string;
|
||||
value: T;
|
||||
}
|
||||
|
||||
let {
|
||||
options,
|
||||
value,
|
||||
onchange,
|
||||
ariaLabel,
|
||||
testid
|
||||
}: {
|
||||
options: Option[];
|
||||
value: T;
|
||||
onchange: (next: T) => void;
|
||||
ariaLabel: string;
|
||||
testid?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="segmented" role="radiogroup" aria-label={ariaLabel} data-testid={testid}>
|
||||
{#each options as opt (opt.value)}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={opt.value === value}
|
||||
class="seg"
|
||||
class:active={opt.value === value}
|
||||
onclick={() => {
|
||||
if (opt.value !== value) onchange(opt.value);
|
||||
}}
|
||||
data-testid={testid ? `${testid}-${opt.value}` : undefined}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.segmented {
|
||||
display: inline-flex;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.seg {
|
||||
height: 32px;
|
||||
padding: 0 var(--space-3);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition),
|
||||
color var(--transition);
|
||||
}
|
||||
|
||||
.seg:hover:not(.active) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.seg.active {
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
</style>
|
||||
53
frontend/src/lib/components/SegmentedControl.svelte.test.ts
Normal file
53
frontend/src/lib/components/SegmentedControl.svelte.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup } from '@testing-library/svelte';
|
||||
import SegmentedControl from './SegmentedControl.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const opts = [
|
||||
{ label: 'Bookmarks', value: 'bookmarks' as const },
|
||||
{ label: 'Collections', value: 'collections' as const },
|
||||
{ label: 'History', value: 'history' as const }
|
||||
];
|
||||
|
||||
describe('SegmentedControl', () => {
|
||||
it('marks the active option with aria-checked=true and a single .active class', () => {
|
||||
render(SegmentedControl, {
|
||||
props: {
|
||||
options: opts,
|
||||
value: 'collections',
|
||||
onchange: () => {},
|
||||
ariaLabel: 'Library tab'
|
||||
}
|
||||
});
|
||||
const active = screen.getByRole('radio', { name: 'Collections' });
|
||||
expect(active.getAttribute('aria-checked')).toBe('true');
|
||||
const inactive = screen.getByRole('radio', { name: 'Bookmarks' });
|
||||
expect(inactive.getAttribute('aria-checked')).toBe('false');
|
||||
});
|
||||
|
||||
it('fires onchange with the new value when an inactive option is clicked', () => {
|
||||
const onchange = vi.fn();
|
||||
render(SegmentedControl, {
|
||||
props: { options: opts, value: 'bookmarks', onchange, ariaLabel: 'Library tab' }
|
||||
});
|
||||
screen.getByRole('radio', { name: 'History' }).click();
|
||||
expect(onchange).toHaveBeenCalledWith('history');
|
||||
});
|
||||
|
||||
it('does not fire onchange when the currently active option is clicked again', () => {
|
||||
const onchange = vi.fn();
|
||||
render(SegmentedControl, {
|
||||
props: { options: opts, value: 'bookmarks', onchange, ariaLabel: 'Library tab' }
|
||||
});
|
||||
screen.getByRole('radio', { name: 'Bookmarks' }).click();
|
||||
expect(onchange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exposes the group with the provided aria-label', () => {
|
||||
render(SegmentedControl, {
|
||||
props: { options: opts, value: 'bookmarks', onchange: () => {}, ariaLabel: 'Library tab' }
|
||||
});
|
||||
expect(screen.getByRole('radiogroup', { name: 'Library tab' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
227
frontend/src/lib/components/Sheet.svelte
Normal file
227
frontend/src/lib/components/Sheet.svelte
Normal file
@@ -0,0 +1,227 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
|
||||
let {
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
testid
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: Snippet;
|
||||
footer?: Snippet;
|
||||
testid?: string;
|
||||
} = $props();
|
||||
|
||||
let panel: HTMLDivElement | undefined = $state();
|
||||
let previouslyFocused: HTMLElement | null = null;
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
queueMicrotask(() => panel?.focus());
|
||||
} else if (previouslyFocused) {
|
||||
previouslyFocused.focus();
|
||||
previouslyFocused = null;
|
||||
}
|
||||
});
|
||||
|
||||
function focusable(): HTMLElement[] {
|
||||
if (!panel) return [];
|
||||
const selector = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled]):not([type="hidden"])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])'
|
||||
].join(',');
|
||||
return Array.from(panel.querySelectorAll<HTMLElement>(selector));
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (!open) return;
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab') {
|
||||
const items = focusable();
|
||||
if (items.length === 0) {
|
||||
e.preventDefault();
|
||||
panel?.focus();
|
||||
return;
|
||||
}
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (e.shiftKey) {
|
||||
if (active === first || !panel?.contains(active)) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else if (active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
return () => document.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
|
||||
function onScrimClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="scrim"
|
||||
onclick={onScrimClick}
|
||||
role="presentation"
|
||||
data-testid={testid ? `${testid}-scrim` : undefined}
|
||||
>
|
||||
<div
|
||||
class="panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="sheet-title"
|
||||
tabindex="-1"
|
||||
bind:this={panel}
|
||||
data-testid={testid}
|
||||
>
|
||||
<div class="handle" aria-hidden="true"></div>
|
||||
<header class="header">
|
||||
<h2 id="sheet-title">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="close"
|
||||
onclick={onClose}
|
||||
aria-label="Close"
|
||||
title="Close"
|
||||
data-testid={testid ? `${testid}-close` : undefined}
|
||||
>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="body">{@render children()}</div>
|
||||
{#if footer}
|
||||
<footer class="footer">{@render footer()}</footer>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
animation: scrim-in 180ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes scrim-in {
|
||||
from {
|
||||
background: rgba(0, 0, 0, 0);
|
||||
}
|
||||
to {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-top-left-radius: var(--radius-lg);
|
||||
border-top-right-radius: var(--radius-lg);
|
||||
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.2);
|
||||
width: 100%;
|
||||
max-width: 32rem;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
outline: none;
|
||||
/* Honor home-indicator safe area so the footer / body don't
|
||||
sit under the iOS gesture area. */
|
||||
padding-bottom: var(--safe-bottom);
|
||||
animation: panel-in 220ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes panel-in {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.handle {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
margin: var(--space-2) auto var(--space-1);
|
||||
background: var(--border-strong);
|
||||
border-radius: var(--radius-pill);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-4) var(--space-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-lg);
|
||||
}
|
||||
|
||||
.close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: var(--space-4);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
84
frontend/src/lib/components/Sheet.svelte.test.ts
Normal file
84
frontend/src/lib/components/Sheet.svelte.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent } from '@testing-library/svelte';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
import Sheet from './Sheet.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
function bodySnippet(text: string) {
|
||||
return createRawSnippet(() => ({
|
||||
render: () => `<p>${text}</p>`
|
||||
}));
|
||||
}
|
||||
|
||||
describe('Sheet', () => {
|
||||
it('renders nothing when open=false', () => {
|
||||
render(Sheet, {
|
||||
props: {
|
||||
open: false,
|
||||
title: 'Filter',
|
||||
onClose: () => {},
|
||||
children: bodySnippet('hello')
|
||||
}
|
||||
});
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the dialog with title and body when open=true', () => {
|
||||
render(Sheet, {
|
||||
props: {
|
||||
open: true,
|
||||
title: 'Filter',
|
||||
onClose: () => {},
|
||||
children: bodySnippet('body content')
|
||||
}
|
||||
});
|
||||
const dialog = screen.getByRole('dialog', { name: 'Filter' });
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(screen.getByText('body content')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls onClose when the X close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(Sheet, {
|
||||
props: {
|
||||
open: true,
|
||||
title: 'Filter',
|
||||
onClose,
|
||||
children: bodySnippet('x')
|
||||
}
|
||||
});
|
||||
screen.getByRole('button', { name: 'Close' }).click();
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onClose when the scrim is clicked (sheet variant always dismisses on scrim tap)', () => {
|
||||
const onClose = vi.fn();
|
||||
const { container } = render(Sheet, {
|
||||
props: {
|
||||
open: true,
|
||||
title: 'Filter',
|
||||
onClose,
|
||||
children: bodySnippet('x'),
|
||||
testid: 'filter-sheet'
|
||||
}
|
||||
});
|
||||
const scrim = container.querySelector('[data-testid="filter-sheet-scrim"]') as HTMLElement;
|
||||
scrim.click();
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onClose when Escape is pressed', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(Sheet, {
|
||||
props: {
|
||||
open: true,
|
||||
title: 'Filter',
|
||||
onClose,
|
||||
children: bodySnippet('x')
|
||||
}
|
||||
});
|
||||
await fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -66,9 +66,27 @@
|
||||
in +layout.svelte and the reader, so they reflect rendered
|
||||
size and survive font / zoom / wrap changes. */
|
||||
--app-header-h: 60px;
|
||||
--mobile-app-bar-h: 48px;
|
||||
--app-bottom-nav-h: 56px;
|
||||
--reader-nav-h: 56px;
|
||||
--reader-bar-h: 56px;
|
||||
|
||||
/* Mobile breakpoint tokens. CSS @media queries can't read custom
|
||||
properties, so these are documented anchors — keep the pixel
|
||||
values in @media (max-width: 640px) etc. in lockstep. */
|
||||
--bp-sm: 640px;
|
||||
--bp-md: 768px;
|
||||
--bp-lg: 1024px;
|
||||
|
||||
/* Safe-area helpers for notched / home-indicator devices. Resolve
|
||||
to 0 on devices without insets, so they're safe everywhere as
|
||||
long as <meta name="viewport" content="…, viewport-fit=cover">
|
||||
is set (see app.html). */
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-left: env(safe-area-inset-left, 0px);
|
||||
--safe-right: env(safe-area-inset-right, 0px);
|
||||
|
||||
--z-dropdown: 10;
|
||||
--z-sticky: 50;
|
||||
--z-modal: 100;
|
||||
|
||||
@@ -7,17 +7,25 @@
|
||||
import { preferences } from '$lib/preferences.svelte';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import { theme } from '$lib/theme.svelte';
|
||||
import AppBar from '$lib/components/AppBar.svelte';
|
||||
import BottomNav, { type BottomNavTab } from '$lib/components/BottomNav.svelte';
|
||||
import Upload from '@lucide/svelte/icons/upload';
|
||||
import UserCircle from '@lucide/svelte/icons/user-circle';
|
||||
import Bookmark from '@lucide/svelte/icons/bookmark';
|
||||
import FolderOpen from '@lucide/svelte/icons/folder-open';
|
||||
import LogOut from '@lucide/svelte/icons/log-out';
|
||||
import Shield from '@lucide/svelte/icons/shield';
|
||||
import House from '@lucide/svelte/icons/house';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import BookOpen from '@lucide/svelte/icons/book-open';
|
||||
import User from '@lucide/svelte/icons/user';
|
||||
import '$lib/styles/tokens.css';
|
||||
|
||||
let { children, data } = $props();
|
||||
let loggingOut = $state(false);
|
||||
let headerEl: HTMLElement | undefined = $state();
|
||||
let appBarEl: HTMLElement | undefined = $state();
|
||||
let bottomNavEl: HTMLElement | undefined = $state();
|
||||
|
||||
// Static-route title map. Dynamic pages (manga / author / collection /
|
||||
// chapter) override this via their own <svelte:head><title>, since the
|
||||
@@ -45,6 +53,46 @@
|
||||
|
||||
const layoutTitle = $derived(STATIC_TITLES[$page.route?.id ?? ''] ?? 'Mangalord');
|
||||
|
||||
// Mobile bottom-nav tabs. Library consolidates Bookmarks / Collections /
|
||||
// History — it currently links to /bookmarks as the closest existing
|
||||
// route; Phase 5 introduces /library with internal segmented sub-tabs
|
||||
// and this href flips at that point. Browse temporarily shares Home's
|
||||
// destination until the curated-feed split lands.
|
||||
const MOBILE_TABS: BottomNavTab[] = [
|
||||
{ label: 'Home', href: '/', icon: House, match: [] },
|
||||
{ label: 'Browse', href: '/', icon: Search, match: [] },
|
||||
{
|
||||
label: 'Library',
|
||||
href: '/bookmarks',
|
||||
icon: BookOpen,
|
||||
match: [
|
||||
'/bookmarks',
|
||||
'/collections',
|
||||
'/profile/bookmarks',
|
||||
'/profile/collections',
|
||||
'/profile/history'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Account',
|
||||
href: '/profile/account',
|
||||
icon: User,
|
||||
match: ['/profile', '/profile/preferences']
|
||||
}
|
||||
];
|
||||
|
||||
// Auth-gate and reader routes hide the mobile chrome entirely — the
|
||||
// bottom nav would be visual clutter on a login form and would fight
|
||||
// the reader's own chrome for screen real-estate. The desktop header
|
||||
// is unaffected; it continues to follow the reader fullscreen slide
|
||||
// transform.
|
||||
const HIDE_MOBILE_CHROME_PATHS = new Set(['/login', '/register']);
|
||||
const showMobileChrome = $derived.by(() => {
|
||||
if (HIDE_MOBILE_CHROME_PATHS.has($page.url.pathname)) return false;
|
||||
if ($page.route?.id === '/manga/[id]/chapter/[chapter_id]') return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Seed authConfig from the universal layout load. $effect keeps
|
||||
// the store in sync if `data` is replaced by a subsequent layout
|
||||
// load (client-side nav). The first run also covers initial
|
||||
@@ -58,25 +106,55 @@
|
||||
theme.init();
|
||||
preferences.init();
|
||||
if (!session.loaded) session.refresh();
|
||||
});
|
||||
|
||||
// Publish the header's measured height as a CSS custom
|
||||
// property so sticky descendants (e.g. the reader nav) can
|
||||
// pin themselves directly below it without guessing. A
|
||||
// ResizeObserver keeps it in sync as the viewport reflows
|
||||
// (the nav `flex-wrap: wrap`s on narrow widths), the user
|
||||
// zooms, or fonts swap. Hard-coded pixel offsets in tokens
|
||||
// are wrong in principle — actual height varies with all
|
||||
// of the above.
|
||||
// Three runtime height publishers — each is an independent $effect
|
||||
// keyed by its element ref, so they handle mount, unmount, and
|
||||
// SPA navigation (e.g. entering / leaving the reader, which
|
||||
// toggles `showMobileChrome`) without leaking observers.
|
||||
//
|
||||
// Skipping publish when offsetHeight === 0 is what lets the
|
||||
// desktop header and the mobile AppBar coexist in the DOM: only
|
||||
// the one visible at the current viewport publishes a non-zero
|
||||
// height; the other (display: none) stays at its CSS fallback.
|
||||
$effect(() => {
|
||||
if (!headerEl) return;
|
||||
const el = headerEl;
|
||||
const publish = () => {
|
||||
document.documentElement.style.setProperty(
|
||||
'--app-header-h',
|
||||
`${headerEl!.offsetHeight}px`
|
||||
);
|
||||
const h = el.offsetHeight;
|
||||
if (h > 0) document.documentElement.style.setProperty('--app-header-h', `${h}px`);
|
||||
};
|
||||
publish();
|
||||
const ro = new ResizeObserver(publish);
|
||||
ro.observe(headerEl);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!appBarEl) return;
|
||||
const el = appBarEl;
|
||||
const publish = () => {
|
||||
const h = el.offsetHeight;
|
||||
if (h > 0)
|
||||
document.documentElement.style.setProperty('--mobile-app-bar-h', `${h}px`);
|
||||
};
|
||||
publish();
|
||||
const ro = new ResizeObserver(publish);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!bottomNavEl) return;
|
||||
const el = bottomNavEl;
|
||||
const publish = () => {
|
||||
const h = el.offsetHeight;
|
||||
if (h > 0)
|
||||
document.documentElement.style.setProperty('--app-bottom-nav-h', `${h}px`);
|
||||
};
|
||||
publish();
|
||||
const ro = new ResizeObserver(publish);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
|
||||
@@ -109,7 +187,13 @@
|
||||
<title>{layoutTitle}</title>
|
||||
</svelte:head>
|
||||
|
||||
<header bind:this={headerEl}>
|
||||
{#if showMobileChrome}
|
||||
<div class="mobile-app-bar-wrap" bind:this={appBarEl}>
|
||||
<AppBar title="Mangalord" testid="mobile-app-bar" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<header bind:this={headerEl} class="desktop-header">
|
||||
<nav aria-label="primary">
|
||||
<a class="brand" href="/">Mangalord</a>
|
||||
<a class="nav-link" href="/upload">
|
||||
@@ -167,6 +251,12 @@
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
{#if showMobileChrome}
|
||||
<div class="mobile-bottom-nav-wrap" bind:this={bottomNavEl}>
|
||||
<BottomNav tabs={MOBILE_TABS} currentPath={$page.url.pathname} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
header {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
@@ -290,4 +380,49 @@
|
||||
edge-to-edge once the header has slid off. */
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* Mobile chrome (AppBar + BottomNav) is rendered always when the
|
||||
route opts in, but CSS-hides above the breakpoint so the desktop
|
||||
header is the only visible top-chrome. The ResizeObserver in the
|
||||
script ignores zero offsetHeight so the hidden element doesn't
|
||||
clobber the visible one's published var. */
|
||||
.mobile-app-bar-wrap {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: var(--z-sticky);
|
||||
transform: translateY(0);
|
||||
transition: transform 220ms ease-out;
|
||||
}
|
||||
|
||||
:global(html[data-reader-fullscreen='true']) .mobile-app-bar-wrap {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.mobile-app-bar-wrap,
|
||||
.mobile-bottom-nav-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.desktop-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-app-bar-wrap,
|
||||
.mobile-bottom-nav-wrap {
|
||||
display: block;
|
||||
}
|
||||
|
||||
main {
|
||||
/* On mobile, swap the desktop-header reservation for the
|
||||
AppBar's. BottomNav reservation goes on the bottom so
|
||||
the last bit of content isn't trapped under the bar. */
|
||||
padding-top: calc(var(--mobile-app-bar-h) + var(--space-3));
|
||||
padding-bottom: calc(var(--app-bottom-nav-h) + var(--space-4));
|
||||
padding-left: var(--space-3);
|
||||
padding-right: var(--space-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user