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