Responsive app shell, routes and navigation #38

17 changed files with 727 additions and 19 deletions
+44 -10
View File
@@ -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
View File
@@ -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>
)
+9
View File
@@ -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>
}
+24
View File
@@ -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;
}
+207
View File
@@ -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(
<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(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)
})
})
})
+22
View File
@@ -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>
)
}
+55
View File
@@ -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;
}
+23
View File
@@ -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>
)
}
+51
View File
@@ -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);
}
+27
View File
@@ -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>
)
}
+78
View File
@@ -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);
}
+36
View File
@@ -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>
)
}
+20
View File
@@ -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 },
]
+31
View File
@@ -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
View File
@@ -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);
+25
View File
@@ -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,
)
}
+49
View File
@@ -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))
})
})
}
}