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