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>
50 lines
1.9 KiB
TypeScript
50 lines
1.9 KiB
TypeScript
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))
|
|
})
|
|
})
|
|
}
|
|
}
|