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:
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user