Files
Mangalord/frontend/src/lib/components/AppBar.svelte
MechaCat02 6dcb720a0f 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>
2026-06-06 19:46:16 +02:00

115 lines
3.0 KiB
Svelte

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