From 998658b42c858abe65b1fec8fed4f5d09ab5fc63 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 18:40:34 -0400 Subject: [PATCH] Responsive app shell, routes and navigation (#38) Co-authored-by: dev_agent Co-committed-by: dev_agent --- web/src/App.test.tsx | 54 ++++++-- web/src/App.tsx | 34 +++-- web/src/pages/PlaceholderPage.tsx | 9 ++ web/src/shell/AppShell.module.css | 24 ++++ web/src/shell/AppShell.test.tsx | 207 +++++++++++++++++++++++++++++ web/src/shell/AppShell.tsx | 22 +++ web/src/shell/BottomBar.module.css | 55 ++++++++ web/src/shell/BottomBar.tsx | 23 ++++ web/src/shell/Rail.module.css | 51 +++++++ web/src/shell/Rail.tsx | 27 ++++ web/src/shell/Sidebar.module.css | 78 +++++++++++ web/src/shell/Sidebar.tsx | 36 +++++ web/src/shell/navItems.ts | 20 +++ web/src/shell/useBreakpoint.ts | 31 +++++ web/src/styles/base.css | 1 + web/src/test/cssRules.ts | 25 ++++ web/src/test/matchMedia.ts | 49 +++++++ 17 files changed, 727 insertions(+), 19 deletions(-) create mode 100644 web/src/pages/PlaceholderPage.tsx create mode 100644 web/src/shell/AppShell.module.css create mode 100644 web/src/shell/AppShell.test.tsx create mode 100644 web/src/shell/AppShell.tsx create mode 100644 web/src/shell/BottomBar.module.css create mode 100644 web/src/shell/BottomBar.tsx create mode 100644 web/src/shell/Rail.module.css create mode 100644 web/src/shell/Rail.tsx create mode 100644 web/src/shell/Sidebar.module.css create mode 100644 web/src/shell/Sidebar.tsx create mode 100644 web/src/shell/navItems.ts create mode 100644 web/src/shell/useBreakpoint.ts create mode 100644 web/src/test/matchMedia.ts diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index f50d2b2..b05e13c 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,15 +1,49 @@ import { render, screen } from '@testing-library/react' import { MemoryRouter } from 'react-router' -import { describe, expect, it } from 'vitest' -import App from './App.tsx' +import { afterEach, describe, expect, it } from 'vitest' +import { mockViewport } from './test/matchMedia.ts' +import App, { routes } from './App.tsx' -describe('App', () => { - it('renders the MealQuest shell', () => { - render( - - - , - ) - expect(screen.getByRole('heading', { name: 'MealQuest' })).toBeInTheDocument() +afterEach(() => { + Reflect.deleteProperty(window, 'matchMedia') +}) + +function renderAt(path: string) { + render( + + + , + ) +} + +describe('App routes', () => { + it('registers every handoff route plus the extra settings pages', () => { + expect(routes.map((route) => route.path)).toEqual([ + '/', + '/onboarding', + '/plan', + '/plan/shopping', + '/plan/shopping/archive', + '/discover', + '/quick', + '/cook-from-pantry', + '/dinner/:id', + '/settings', + '/settings/notifications', + '/settings/ai', + '/settings/household', + '/settings/regulars', + '/settings/calendar', + ]) }) + + it.each(routes.map(({ path, title }) => [path.replace(':id', 'abc123'), title]))( + '%s renders its placeholder page inside the shell', + (path, title) => { + mockViewport(390) + renderAt(path) + expect(screen.getByRole('main')).toContainElement(screen.getByRole('heading', { level: 1, name: title })) + expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument() + }, + ) }) diff --git a/web/src/App.tsx b/web/src/App.tsx index 9415bc4..5e820e5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,15 +1,27 @@ import { useState } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { Route, Routes } from 'react-router' +import { PlaceholderPage } from './pages/PlaceholderPage.tsx' +import { AppShell } from './shell/AppShell.tsx' -// Placeholder shell only: no product UI, auth or API calls yet. -function Shell() { - return ( -
-

MealQuest

-
- ) -} +// Every route the app serves, with the heading its placeholder page shows. +export const routes = [ + { path: '/', title: 'Home' }, + { path: '/onboarding', title: 'Onboarding' }, + { path: '/plan', title: 'Plan' }, + { path: '/plan/shopping', title: 'Supply run' }, + { path: '/plan/shopping/archive', title: 'Supply archive' }, + { path: '/discover', title: 'Discover' }, + { path: '/quick', title: 'Quick dinners' }, + { path: '/cook-from-pantry', title: 'Cook from what I have' }, + { path: '/dinner/:id', title: 'Dinner' }, + { path: '/settings', title: 'Settings' }, + { path: '/settings/notifications', title: 'Notifications' }, + { path: '/settings/ai', title: 'AI assistant' }, + { path: '/settings/household', title: 'Household' }, + { path: '/settings/regulars', title: 'Regulars' }, + { path: '/settings/calendar', title: 'Calendar' }, +] as const // App expects to be rendered inside a router (BrowserRouter in main.tsx, // MemoryRouter in tests). @@ -18,7 +30,11 @@ export default function App() { return ( - } /> + }> + {routes.map(({ path, title }) => ( + } /> + ))} + ) diff --git a/web/src/pages/PlaceholderPage.tsx b/web/src/pages/PlaceholderPage.tsx new file mode 100644 index 0000000..a8d7124 --- /dev/null +++ b/web/src/pages/PlaceholderPage.tsx @@ -0,0 +1,9 @@ +export type PlaceholderPageProps = { + // The route's screen name, shown as the page heading. + title: string +} + +// Stands in for a screen until its feature is built. +export function PlaceholderPage({ title }: PlaceholderPageProps) { + return

{title}

+} diff --git a/web/src/shell/AppShell.module.css b/web/src/shell/AppShell.module.css new file mode 100644 index 0000000..e1eff44 --- /dev/null +++ b/web/src/shell/AppShell.module.css @@ -0,0 +1,24 @@ +.shell { + position: relative; + height: 100dvh; + display: flex; + background: var(--color-bg); +} + +.main { + flex: 1; + min-width: 0; + overflow-y: auto; +} + +/* The bottom bar overlays the last 94 px of the screen (plus the safe area), + so the scroll area reserves at least 100 px beneath its content. */ +.mobile .main { + padding: 0 20px; + padding-bottom: calc(100px + env(safe-area-inset-bottom)); +} + +.tablet .main, +.desktop .main { + padding: 32px 36px; +} diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx new file mode 100644 index 0000000..a7ba038 --- /dev/null +++ b/web/src/shell/AppShell.test.tsx @@ -0,0 +1,207 @@ +import { render, screen, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, describe, expect, it } from 'vitest' +import App from '../App.tsx' +import { declaredStyle, outerSize } from '../test/cssRules.ts' +import { expectMinHitArea } from '../test/hitArea.ts' +import { mockViewport } from '../test/matchMedia.ts' +import bottomBarStyles from './BottomBar.module.css' +import railStyles from './Rail.module.css' +import sidebarStyles from './Sidebar.module.css' + +const ORDER = ['Home', 'Plan', 'Discover', 'Quick', 'Settings'] +const MOBILE = 390 +const TABLET = 1180 +const DESKTOP = 1440 + +afterEach(() => { + Reflect.deleteProperty(window, 'matchMedia') +}) + +function renderAt(path: string, width: number) { + const resize = mockViewport(width) + render( + + + , + ) + return resize +} + +function nav() { + return screen.getByRole('navigation', { name: 'Primary' }) +} + +function navLinks() { + return within(nav()).getAllByRole('link') +} + +function current() { + return navLinks() + .filter((link) => link.getAttribute('aria-current') === 'page') + .map((link) => link.textContent) +} + +describe('AppShell', () => { + it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', (width) => { + renderAt('/', width) + expect(navLinks().map((link) => link.textContent)).toEqual(ORDER) + expect(navLinks().map((link) => link.getAttribute('href'))).toEqual([ + '/', + '/plan', + '/discover', + '/quick', + '/settings', + ]) + }) + + it('shows only the mobile bottom bar below 768 px', () => { + renderAt('/', 767) + expect(screen.getAllByRole('navigation')).toHaveLength(1) + expect(nav()).toHaveClass(bottomBarStyles.bar!) + expect(screen.queryByText('M')).not.toBeInTheDocument() + expect(screen.queryByText('MealQuest')).not.toBeInTheDocument() + expect(screen.queryByText('Our kitchen')).not.toBeInTheDocument() + + const bar = declaredStyle(nav()) + expect(bar.position).toBe('absolute') + expect(bar.left).toBe('12px') + expect(bar.right).toBe('12px') + expect(bar.bottom).toBe('calc(16px + env(safe-area-inset-bottom))') + expect(bar.height).toBe('62px') + expect(outerSize(nav(), 'height')).toBe(62) + expect(bar.background).toBe('var(--color-text)') + expect(bar.borderRadius).toBe('var(--radius-pill)') + }) + + it('shows the active mobile item as icon and label on a grape pill, inactive items as neutral icons', () => { + renderAt('/discover', MOBILE) + const active = screen.getByRole('link', { name: 'Discover' }) + const pill = active.firstElementChild! + expect(pill).toHaveClass(bottomBarStyles.active!) + expect(declaredStyle(pill).height).toBe('42px') + expect(declaredStyle(pill).background).toBe('var(--color-accent)') + expect(declaredStyle(pill).color).toBe('var(--on-accent)') + expect(within(active).getByText('Discover')).toHaveClass(bottomBarStyles.label!) + expect(declaredStyle(within(active).getByText('Discover')).fontSize).toBe('13px') + + const inactive = screen.getByRole('link', { name: 'Plan' }) + expect(declaredStyle(inactive).color).toBe('var(--color-neutral-400)') + expect(inactive.firstElementChild).not.toHaveClass(bottomBarStyles.active!) + expect(within(inactive).getByText('Plan')).toHaveClass(bottomBarStyles.hiddenLabel!) + }) + + it('makes the full 62 px bar height the mobile tap target', () => { + renderAt('/', MOBILE) + for (const link of navLinks()) { + expect(declaredStyle(link).height).toBe('62px') + expectMinHitArea(link) + } + }) + + it('reserves at least 100 px below the mobile scroll area', () => { + renderAt('/', MOBILE) + const padding = declaredStyle(screen.getByRole('main')).paddingBottom + const reserved = /^calc\((\d+)px \+ env\(safe-area-inset-bottom\)\)$/.exec(padding) + expect(reserved, `padding-bottom ${JSON.stringify(padding)}`).not.toBeNull() + expect(Number(reserved![1])).toBeGreaterThanOrEqual(100) + }) + + it.each([768, TABLET, 1199])('shows only the tablet rail with the "M" mark at %i px', (width) => { + renderAt('/plan', width) + expect(screen.getAllByRole('navigation')).toHaveLength(1) + expect(nav()).toHaveClass(railStyles.rail!) + expect(declaredStyle(nav()).width).toBe('96px') + expect(outerSize(nav(), 'width')).toBe(96) + expect(declaredStyle(nav()).background).toBe('var(--color-surface)') + const mark = within(nav()).getByText('M') + expect(declaredStyle(mark).width).toBe('48px') + expect(outerSize(mark, 'width')).toBe(48) + expect(outerSize(mark, 'height')).toBe(48) + expect(declaredStyle(mark).background).toBe('var(--color-accent)') + expect(screen.queryByText('MealQuest')).not.toBeInTheDocument() + expect(screen.queryByText('Our kitchen')).not.toBeInTheDocument() + + for (const link of navLinks()) { + const pill = link.firstElementChild! + expect(declaredStyle(pill).width).toBe('56px') + expect(declaredStyle(pill).height).toBe('36px') + expect(outerSize(pill, 'width')).toBe(56) + expect(outerSize(pill, 'height')).toBe(36) + expect(declaredStyle(link).fontSize).toBe('11px') + expect(declaredStyle(link).fontWeight).toBe('700') + expectMinHitArea(link) + } + expect(screen.getByRole('link', { name: 'Plan' }).firstElementChild).toHaveClass(railStyles.active!) + expect(screen.getByRole('link', { name: 'Home' }).firstElementChild).not.toHaveClass(railStyles.active!) + }) + + it.each([1200, DESKTOP])('shows only the desktop sidebar with wordmark and household card at %i px', (width) => { + renderAt('/quick', width) + expect(screen.getAllByRole('navigation')).toHaveLength(1) + const sidebar = nav().closest('aside')! + expect(sidebar).toHaveClass(sidebarStyles.sidebar!) + expect(declaredStyle(sidebar).width).toBe('208px') + expect(declaredStyle(sidebar).background).toBe('var(--color-surface)') + expect(declaredStyle(sidebar).padding).toBe('28px 16px') + // The 16 px side padding sits inside the 208 px, not outside it. + expect(outerSize(sidebar, 'width')).toBe(208) + + const wordmark = within(sidebar).getByText('MealQuest') + expect(declaredStyle(wordmark).fontFamily).toBe('var(--font-heading)') + expect(declaredStyle(wordmark).fontSize).toBe('28px') + expect(declaredStyle(wordmark).color).toBe('var(--color-accent)') + + expect(within(sidebar).getByText('Our kitchen')).toBeInTheDocument() + expect(within(sidebar).getByText('Shared household')).toBeInTheDocument() + expect(screen.queryByText('M')).not.toBeInTheDocument() + + const active = screen.getByRole('link', { name: 'Quick' }) + expect(active).toHaveClass(sidebarStyles.active!) + expect(declaredStyle(active).background).toBe('var(--color-accent)') + expect(declaredStyle(active).color).toBe('var(--on-accent)') + expect(declaredStyle(active).fontSize).toBe('15px') + expect(screen.getByRole('link', { name: 'Home' })).not.toHaveClass(sidebarStyles.active!) + // Rows stretch across the sidebar, so only their height needs declaring. + for (const link of navLinks()) expect(declaredStyle(link).minHeight).toBe('44px') + }) + + it('switches layout when the viewport crosses a breakpoint', () => { + const resize = renderAt('/', MOBILE) + expect(nav()).toHaveClass(bottomBarStyles.bar!) + resize(TABLET) + expect(nav()).toHaveClass(railStyles.rail!) + resize(DESKTOP) + expect(nav().closest('aside')).toHaveClass(sidebarStyles.sidebar!) + resize(MOBILE) + expect(nav()).toHaveClass(bottomBarStyles.bar!) + expect(screen.getAllByRole('navigation')).toHaveLength(1) + }) + + describe.each([ + ['mobile', MOBILE], + ['tablet', TABLET], + ['desktop', DESKTOP], + ])('aria-current (%s)', (_layout, width) => { + it.each([ + ['/', ['Home']], + ['/plan', ['Plan']], + ['/plan/shopping', ['Plan']], + ['/plan/shopping/archive', ['Plan']], + ['/discover', ['Discover']], + ['/quick', ['Quick']], + ['/settings', ['Settings']], + ['/settings/notifications', ['Settings']], + ['/settings/ai', ['Settings']], + ['/settings/household', ['Settings']], + ['/settings/regulars', ['Settings']], + ['/settings/calendar', ['Settings']], + ['/onboarding', []], + ['/cook-from-pantry', []], + ['/dinner/abc123', []], + ])('%s marks %j current', (path, expected) => { + renderAt(path, width) + expect(current()).toEqual(expected) + }) + }) +}) diff --git a/web/src/shell/AppShell.tsx b/web/src/shell/AppShell.tsx new file mode 100644 index 0000000..7baf72b --- /dev/null +++ b/web/src/shell/AppShell.tsx @@ -0,0 +1,22 @@ +import { Outlet } from 'react-router' +import { BottomBar } from './BottomBar.tsx' +import { Rail } from './Rail.tsx' +import { Sidebar } from './Sidebar.tsx' +import { useBreakpoint } from './useBreakpoint.ts' +import styles from './AppShell.module.css' + +// The responsive frame around every page: one nav layout per breakpoint and a +// scrolling main area for the routed page. +export function AppShell() { + const breakpoint = useBreakpoint() + return ( +
+ {breakpoint === 'mobile' && } + {breakpoint === 'tablet' && } + {breakpoint === 'desktop' && } +
+ +
+
+ ) +} diff --git a/web/src/shell/BottomBar.module.css b/web/src/shell/BottomBar.module.css new file mode 100644 index 0000000..6d4e044 --- /dev/null +++ b/web/src/shell/BottomBar.module.css @@ -0,0 +1,55 @@ +.bar { + position: absolute; + left: 12px; + right: 12px; + bottom: calc(16px + env(safe-area-inset-bottom)); + height: 62px; + padding: 0 6px; + box-sizing: border-box; + display: flex; + justify-content: space-around; + align-items: center; + border-radius: var(--radius-pill); + background: var(--color-text); +} + +/* The whole bar height is the tap target; the 42 px pill sits inside it. */ +.item { + display: flex; + align-items: center; + justify-content: center; + height: 62px; + min-width: 44px; + color: var(--color-neutral-400); + text-decoration: none; +} + +.pill { + display: flex; + align-items: center; + gap: 6px; + height: 42px; + padding: 0 13px; + border-radius: var(--radius-pill); +} + +.active { + background: var(--color-accent); + color: var(--on-accent); +} + +.label { + font-family: var(--font-body); + font-size: 13px; + font-weight: 700; +} + +/* Inactive items are icon-only on screen but keep their name for assistive tech. */ +.hiddenLabel { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/web/src/shell/BottomBar.tsx b/web/src/shell/BottomBar.tsx new file mode 100644 index 0000000..14843d4 --- /dev/null +++ b/web/src/shell/BottomBar.tsx @@ -0,0 +1,23 @@ +import { NavLink } from 'react-router' +import { Icon } from '../ui/Icon.tsx' +import { navItems } from './navItems.ts' +import styles from './BottomBar.module.css' + +// Mobile nav: a dark pill bar floating over the bottom of the screen. Inactive +// items show only their icon; the active item adds its label on a grape pill. +export function BottomBar() { + return ( + + ) +} diff --git a/web/src/shell/Rail.module.css b/web/src/shell/Rail.module.css new file mode 100644 index 0000000..f9da188 --- /dev/null +++ b/web/src/shell/Rail.module.css @@ -0,0 +1,51 @@ +.rail { + width: 96px; + padding: 24px 0; + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + gap: 18px; + background: var(--color-surface); +} + +.mark { + width: 48px; + height: 48px; + margin-bottom: 12px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--color-accent); + color: var(--on-accent); + font-family: var(--font-heading); + font-size: 22px; +} + +.item { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + min-width: 56px; + min-height: 44px; + color: var(--color-neutral-700); + font-size: 11px; + font-weight: 700; + text-decoration: none; +} + +.pill { + width: 56px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-pill); +} + +.active { + background: var(--color-accent); + color: var(--on-accent); +} diff --git a/web/src/shell/Rail.tsx b/web/src/shell/Rail.tsx new file mode 100644 index 0000000..221bef4 --- /dev/null +++ b/web/src/shell/Rail.tsx @@ -0,0 +1,27 @@ +import { NavLink } from 'react-router' +import { Icon } from '../ui/Icon.tsx' +import { navItems } from './navItems.ts' +import styles from './Rail.module.css' + +// Tablet nav: a narrow icon rail with the "M" mark on top. +export function Rail() { + return ( + + ) +} diff --git a/web/src/shell/Sidebar.module.css b/web/src/shell/Sidebar.module.css new file mode 100644 index 0000000..4aa11ce --- /dev/null +++ b/web/src/shell/Sidebar.module.css @@ -0,0 +1,78 @@ +.sidebar { + width: 208px; + padding: 28px 16px; + box-sizing: border-box; + display: flex; + flex-direction: column; + background: var(--color-surface); +} + +.wordmark { + margin: 0; + padding: 0 12px 22px; + font-family: var(--font-heading); + font-size: 28px; + font-weight: 400; + color: var(--color-accent); +} + +.nav { + display: flex; + flex-direction: column; + gap: 6px; +} + +.item { + display: flex; + align-items: center; + gap: 12px; + min-height: 44px; + padding: 12px 16px; + box-sizing: border-box; + border-radius: var(--radius-pill); + color: var(--color-neutral-800); + font-size: 15px; + font-weight: 700; + text-decoration: none; +} + +.active { + background: var(--color-accent); + color: var(--on-accent); +} + +.household { + margin-top: auto; + display: flex; + align-items: center; + gap: 10px; + padding: 12px; + border-radius: 22px; + background: var(--color-bg); +} + +.avatar { + width: 36px; + height: 36px; + flex: none; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--color-accent-2-300); + color: var(--color-accent-2-900); + font-family: var(--font-heading); + font-size: 15px; +} + +.householdText { + margin: 0; + display: flex; + flex-direction: column; + font-size: 13px; + line-height: 1.3; +} + +.householdNote { + color: var(--color-neutral-700); +} diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx new file mode 100644 index 0000000..9374af2 --- /dev/null +++ b/web/src/shell/Sidebar.tsx @@ -0,0 +1,36 @@ +import { NavLink } from 'react-router' +import { Icon } from '../ui/Icon.tsx' +import { navItems } from './navItems.ts' +import styles from './Sidebar.module.css' + +// Desktop nav: the MealQuest wordmark, labelled pill rows and the shared +// household card at the foot. +export function Sidebar() { + return ( + + ) +} diff --git a/web/src/shell/navItems.ts b/web/src/shell/navItems.ts new file mode 100644 index 0000000..8b22f63 --- /dev/null +++ b/web/src/shell/navItems.ts @@ -0,0 +1,20 @@ +import type { LucideIcon } from 'lucide-react' +import { Calendar, Compass, House, SlidersHorizontal, Zap } from 'lucide-react' + +export type NavItem = { + to: string + label: string + icon: LucideIcon + // Match the path exactly; otherwise nested paths (/plan/shopping, + // /settings/ai) also mark the item current. + end: boolean +} + +// The primary nav, in the same order in every layout. +export const navItems: readonly NavItem[] = [ + { to: '/', label: 'Home', icon: House, end: true }, + { to: '/plan', label: 'Plan', icon: Calendar, end: false }, + { to: '/discover', label: 'Discover', icon: Compass, end: false }, + { to: '/quick', label: 'Quick', icon: Zap, end: false }, + { to: '/settings', label: 'Settings', icon: SlidersHorizontal, end: false }, +] diff --git a/web/src/shell/useBreakpoint.ts b/web/src/shell/useBreakpoint.ts new file mode 100644 index 0000000..28b9f48 --- /dev/null +++ b/web/src/shell/useBreakpoint.ts @@ -0,0 +1,31 @@ +import { useCallback, useState, useSyncExternalStore } from 'react' + +export type Breakpoint = 'mobile' | 'tablet' | 'desktop' + +// The handoff's three layouts: mobile < 768 px, tablet 768–1199 px, +// desktop ≥ 1200 px. +export const TABLET_QUERY = '(min-width: 768px)' +export const DESKTOP_QUERY = '(min-width: 1200px)' + +// The layout the current viewport calls for, updated as the viewport crosses +// a breakpoint. +export function useBreakpoint(): Breakpoint { + const [lists] = useState(() => ({ + tablet: window.matchMedia(TABLET_QUERY), + desktop: window.matchMedia(DESKTOP_QUERY), + })) + const subscribe = useCallback( + (onChange: () => void) => { + lists.tablet.addEventListener('change', onChange) + lists.desktop.addEventListener('change', onChange) + return () => { + lists.tablet.removeEventListener('change', onChange) + lists.desktop.removeEventListener('change', onChange) + } + }, + [lists], + ) + return useSyncExternalStore(subscribe, () => + lists.desktop.matches ? 'desktop' : lists.tablet.matches ? 'tablet' : 'mobile', + ) +} diff --git a/web/src/styles/base.css b/web/src/styles/base.css index ac545c1..dc83c17 100644 --- a/web/src/styles/base.css +++ b/web/src/styles/base.css @@ -1,5 +1,6 @@ /* App-wide base styles. Values come only from tokens.css. */ body { + margin: 0; background: var(--color-bg); color: var(--color-text); font-family: var(--font-body); diff --git a/web/src/test/cssRules.ts b/web/src/test/cssRules.ts index e8413aa..3260596 100644 --- a/web/src/test/cssRules.ts +++ b/web/src/test/cssRules.ts @@ -48,3 +48,28 @@ export function animationKeyframes(element: Element): CSSKeyframesRule { if (!keyframes) throw new Error(`no @keyframes named ${JSON.stringify(name)}`) return keyframes } + +function px(value: string, what: string): number { + if (value === '') return 0 + const match = /^(\d+(?:\.\d+)?)px$/.exec(value) + if (!match) throw new Error(`${what} ${JSON.stringify(value)} is not a px length`) + return Number(match[1]) +} + +// The border-box size the element's declared rules give it on one axis: +// width or height, plus padding and border unless box-sizing is border-box. +// jsdom does no layout (getBoundingClientRect is all zeros), so this is the +// way to catch padding that would push a fixed box past its specified size. +export function outerSize(element: Element, axis: 'width' | 'height'): number { + const style = declaredStyle(element) + const size = px(style.getPropertyValue(axis), axis) + if (style.boxSizing === 'border-box') return size + const sides = axis === 'width' ? ['left', 'right'] : ['top', 'bottom'] + return sides.reduce( + (total, side) => + total + + px(style.getPropertyValue(`padding-${side}`), `padding-${side}`) + + px(style.getPropertyValue(`border-${side}-width`), `border-${side}-width`), + size, + ) +} diff --git a/web/src/test/matchMedia.ts b/web/src/test/matchMedia.ts new file mode 100644 index 0000000..0bb1a0b --- /dev/null +++ b/web/src/test/matchMedia.ts @@ -0,0 +1,49 @@ +import { act } from '@testing-library/react' + +type Listener = (event: MediaQueryListEvent) => void + +// Replaces window.matchMedia with one that answers min-width / max-width +// queries for a pretend viewport width (jsdom has no matchMedia and no +// layout). Returns a function that resizes the pretend viewport and notifies +// every query whose answer changed. +export function mockViewport(initialWidth: number): (width: number) => void { + let width = initialWidth + const lists: { list: MediaQueryList; listeners: Set }[] = [] + + function evaluate(query: string): boolean { + const min = /\(min-width:\s*(\d+)px\)/.exec(query) + const max = /\(max-width:\s*(\d+)px\)/.exec(query) + if (!min && !max) throw new Error(`mockViewport cannot evaluate ${JSON.stringify(query)}`) + return (!min || width >= Number(min[1])) && (!max || width <= Number(max[1])) + } + + window.matchMedia = (query: string) => { + const listeners = new Set() + const list = { + media: query, + get matches() { + return evaluate(query) + }, + onchange: null, + addEventListener: (_type: string, listener: Listener) => listeners.add(listener), + removeEventListener: (_type: string, listener: Listener) => listeners.delete(listener), + addListener: (listener: Listener) => listeners.add(listener), + removeListener: (listener: Listener) => listeners.delete(listener), + dispatchEvent: () => true, + } as unknown as MediaQueryList + lists.push({ list, listeners }) + return list + } + + return (next: number) => { + const before = lists.map(({ list }) => list.matches) + width = next + act(() => { + lists.forEach(({ list, listeners }, i) => { + if (list.matches === before[i]) return + const event = { matches: list.matches, media: list.media } as MediaQueryListEvent + listeners.forEach((listener) => listener(event)) + }) + }) + } +}