Add responsive app shell, routes and navigation
Replace the catch-all placeholder with an AppShell that renders one primary nav per breakpoint (chosen with matchMedia via useBreakpoint): a dark pill bottom bar below 768 px, a 96 px rail with the "M" mark at 768-1199 px, and a 208 px sidebar with the MealQuest wordmark and the "Our kitchen / Shared household" card at 1200 px and up. Nav order is Home, Plan, Discover, Quick, Settings; NavLink marks the active item aria-current="page", with nested Plan and Settings paths keeping their parent current. The mobile scroll area reserves 100 px plus the safe area beneath its content. Register every handoff route plus /settings/household, /settings/regulars and /settings/calendar, each with a placeholder heading. Remove the default body margin so the shell fills the viewport. Tests force each breakpoint with a matchMedia mock and assert the routes, nav order, each layout's structure, aria-current and the mobile bottom padding from the stylesheet. Maestro task: 42a4695a-6bd0-49a1-925e-387858dbee48 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+44
-10
@@ -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(
|
||||
<MemoryRouter>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(screen.getByRole('heading', { name: 'MealQuest' })).toBeInTheDocument()
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
})
|
||||
|
||||
function renderAt(path: string) {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
+25
-9
@@ -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 (
|
||||
<main>
|
||||
<h1>MealQuest</h1>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
// 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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="*" element={<Shell />} />
|
||||
<Route element={<AppShell />}>
|
||||
{routes.map(({ path, title }) => (
|
||||
<Route key={path} path={path} element={<PlaceholderPage title={title} />} />
|
||||
))}
|
||||
</Route>
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
@@ -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 <h1>{title}</h1>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
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 } 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(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
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(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(declaredStyle(nav()).background).toBe('var(--color-surface)')
|
||||
const mark = within(nav()).getByText('M')
|
||||
expect(declaredStyle(mark).width).toBe('48px')
|
||||
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(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')
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<div className={`${styles.shell} ${styles[breakpoint]}`}>
|
||||
{breakpoint === 'mobile' && <BottomBar />}
|
||||
{breakpoint === 'tablet' && <Rail />}
|
||||
{breakpoint === 'desktop' && <Sidebar />}
|
||||
<main className={styles.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
.bar {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
height: 62px;
|
||||
padding: 0 6px;
|
||||
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;
|
||||
}
|
||||
@@ -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 (
|
||||
<nav aria-label="Primary" className={styles.bar}>
|
||||
{navItems.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} end={item.end} className={styles.item}>
|
||||
{({ isActive }) => (
|
||||
<span className={isActive ? `${styles.pill} ${styles.active}` : styles.pill}>
|
||||
<Icon icon={item.icon} size="nav" />
|
||||
<span className={isActive ? styles.label : styles.hiddenLabel}>{item.label}</span>
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
.rail {
|
||||
width: 96px;
|
||||
padding: 24px 0;
|
||||
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);
|
||||
}
|
||||
@@ -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 (
|
||||
<nav aria-label="Primary" className={styles.rail}>
|
||||
<div className={styles.mark} aria-hidden="true">
|
||||
M
|
||||
</div>
|
||||
{navItems.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} end={item.end} className={styles.item}>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span className={isActive ? `${styles.pill} ${styles.active}` : styles.pill}>
|
||||
<Icon icon={item.icon} size="nav" />
|
||||
</span>
|
||||
{item.label}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
.sidebar {
|
||||
width: 208px;
|
||||
padding: 28px 16px;
|
||||
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);
|
||||
}
|
||||
@@ -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 (
|
||||
<aside className={styles.sidebar}>
|
||||
<p className={styles.wordmark}>MealQuest</p>
|
||||
<nav aria-label="Primary" className={styles.nav}>
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) => (isActive ? `${styles.item} ${styles.active}` : styles.item)}
|
||||
>
|
||||
<Icon icon={item.icon} size="nav" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className={styles.household}>
|
||||
<span className={styles.avatar} aria-hidden="true">
|
||||
K
|
||||
</span>
|
||||
<p className={styles.householdText}>
|
||||
<strong>Our kitchen</strong>
|
||||
<span className={styles.householdNote}>Shared household</span>
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
]
|
||||
@@ -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',
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Listener> }[] = []
|
||||
|
||||
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<Listener>()
|
||||
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))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user