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:
@@ -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