Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cebf8b4d79 | ||
|
|
759fbbe3dd | ||
|
|
d390d8dcce | ||
|
|
25edb6fb26 |
@@ -82,7 +82,8 @@ sqlc:
|
||||
$(SQLC) generate
|
||||
|
||||
# Fails on any difference from the committed generated code, including new
|
||||
# or deleted files.
|
||||
# or deleted files. A failing git status is itself a failure: its empty
|
||||
# output would otherwise read as "no drift".
|
||||
sqlc-check: sqlc
|
||||
@drift="$$(git status --porcelain --untracked-files=all -- $(SQLC_OUT))"; \
|
||||
@drift="$$(git status --porcelain --untracked-files=all -- $(SQLC_OUT))" || { echo "git status failed; cannot check internal/db for drift"; exit 1; }; \
|
||||
if [ -n "$$drift" ]; then echo "internal/db is stale; run make sqlc and commit:"; echo "$$drift"; git diff -- $(SQLC_OUT); exit 1; fi
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
# Accessibility pass
|
||||
|
||||
A pass over every route the PWA serves (the routes in [STATES.md](STATES.md))
|
||||
for launch, on 2026-10-10. Each route was checked for:
|
||||
|
||||
- **Keyboard** — every action reachable and operable with the keyboard alone.
|
||||
- **Focus** — a visible focus ring on everything that takes focus.
|
||||
- **Names and roles** — what a screen reader announces: switches, checkboxes,
|
||||
the current nav item, dialogs and their labels.
|
||||
- **Contrast** — text at least 4.5:1 against its ground, for every token pair
|
||||
used for text.
|
||||
- **Hit targets** — every tap target at least 44 × 44 px.
|
||||
- **Reduced motion** — what `prefers-reduced-motion: reduce` turns off.
|
||||
|
||||
The pass read each screen's markup and stylesheets, computed contrast from
|
||||
[tokens.css](../web/src/styles/tokens.css), and checked the fixes' computed
|
||||
styles in Chrome. Severity: **high** blocks a task for keyboard or
|
||||
screen-reader users; **medium** fails a criterion above but leaves a way
|
||||
through; **low** is a gap outside the criteria above, or a polish item. High
|
||||
and medium findings are fixed in this pass with a regression test; low ones are
|
||||
follow-ups.
|
||||
|
||||
## Shared building blocks
|
||||
|
||||
Most of the criteria are met once, in the shared components every screen uses:
|
||||
|
||||
| Concern | Where | Behaviour |
|
||||
| --- | --- | --- |
|
||||
| Focus ring | [base.css](../web/src/styles/base.css) | `:focus-visible` draws a 2 px grape (`--color-accent`) outline 2 px out, on every focusable element. |
|
||||
| Dialogs | [Sheet](../web/src/ui/Sheet.tsx) | `role="dialog"`, `aria-modal="true"`, and a required `aria-label` or `aria-labelledby` (the type will not compile without one). Opening moves focus in, Tab is trapped, Escape and the scrim close, the page behind is `inert`, and focus returns to the opener. Every sheet is labelled by its visible title. |
|
||||
| Toggles | [Toggle](../web/src/ui/Toggle.tsx) | A `<button role="switch" aria-checked>`; Space and Enter flip it; a 44 px hit box around the 40 × 24 track. |
|
||||
| Checkboxes | [Checkbox](../web/src/ui/Checkbox.tsx) | A native `<input type="checkbox">` inside its `<label>`; the label row is the 44 px target. |
|
||||
| Segmented controls | [Segmented](../web/src/ui/Segmented.tsx) | `role="radiogroup"` of `role="radio"` buttons with a roving tab stop; arrows, Home and End move the choice. |
|
||||
| Icon buttons | [IconButton](../web/src/ui/IconButton.tsx) | A required `label` names every icon-only button; the 40 px face sits in a 44 px hit box. |
|
||||
| Primary nav | [BottomBar](../web/src/shell/BottomBar.tsx), [Rail](../web/src/shell/Rail.tsx), [Sidebar](../web/src/shell/Sidebar.tsx) | `<nav aria-label="Primary">` of `NavLink`s; the current item has `aria-current="page"`, nested routes (`/plan/shopping`, `/settings/ai`) keep their section current. The bottom bar's inactive items keep their name in a visually hidden label. |
|
||||
| Status and errors | [ErrorBanner](../web/src/ui/ErrorBanner.tsx), `role="status"` / `role="alert"` lines | Loading regions are `aria-busy` and named; errors are announced. |
|
||||
| Reduced motion | `--motion-scale` in tokens.css; `@media (prefers-reduced-motion: reduce)` in each animated component; `matchMedia` before every Web Animations call | Sheets appear without rising, skeletons stop pulsing, buttons do not scale, toggles and checkboxes do not spring, the nav pill jumps rather than slides, and the Discover deck fades a decided card out with no throw, bump, wiggle or promotion. |
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Keyboard | Focus | Names and roles | Hit targets | Reduced motion |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| App shell (every route) | Nav is links in tab order. | Global ring; **the bottom bar's ring was 1.8:1 on the dark bar — fixed (M2).** | `nav "Primary"`, `aria-current="page"`. | Bottom bar items 62 px tall, ≥ 44 px wide; rail items 56 × 44. | Pill jumps; no label fade. |
|
||||
| `/` Home | All tiles, status rows, Recipe, the bell and the Rotation check are buttons or links. | Global ring; tiles, status rows and "We didn't make it" draw a 3 px ring, inside their cards' padding. | Tonight `section`, Status list; the bell is named "Recent updates" and its sheet is labelled by its title. | Recipe pill and "We didn't make it" kept at 44 px. | — |
|
||||
| `/onboarding` (all steps) | Buttons and form fields; Add Regular submits on Enter. | Global ring. | `h1` per step; progress bar hidden; "Add Regular" and "Add *pick*" named. | Covered by onboarding tests. | — |
|
||||
| `/plan` Draft board (mobile) | **Swap without drag:** each day tile is a button that opens the swap sheet; the sheet's rows are buttons. Previous/Next week, Rebuild week, Push to Calendar are buttons. | Global ring. | Week list "Dinners this week"; swap sheet labelled "Swap *Day*"; Quest log / Regulars radio group. | Tiles ≥ 86 px tall. | Sheet appears without rising. |
|
||||
| `/plan` plan manager (tablet, desktop) | **Swap without drag:** every day has a "Swap *Day*" button opening the swap sheet. Dragging also works by keyboard: Enter or Space picks a dinner up, arrows move between days, Enter drops, Escape cancels; a polite live region narrates each step and focus lands on the day's swap button. | Global ring. | Days named by weekday; the panel is "Quest log and Regulars"; each draggable row is described by the keys hint. | **The tablet-portrait Quest log chips were 38 px tall — fixed (M4).** | — |
|
||||
| `/plan/shopping` Supply run | Native checkboxes; links to the archive. | Global ring. | Groups named by dinner; native checkbox per item; read-only weeks say "have it" in text, not by colour alone. | Checkbox rows 44 px. | — |
|
||||
| `/plan/shopping/archive` | Period radio group; posts are links. | Posts draw a 3 px ring. | Periods named by heading. | — | — |
|
||||
| `/discover` | **← Pass, → Try it** anywhere on the page (not while typing, nor with a sheet open); Pass and Try it are also buttons; the Quest log pill opens its sheet. | Global ring. | Only the top card is exposed, labelled by its name; cards behind are `aria-hidden` and `inert`; Try it / Pass labels are shown under the buttons. | Pass and Try it 62 px; Quest log pill 44 px. | Card fades out; no throw, bump, wiggle, snap-back spring or promotion. |
|
||||
| `/quick` Quick dinners | Tab radio group; add buttons; More link. | Global ring; More draws a 3 px ring. | Rows named by idea; "Added" is a status. | Covered by Quick dinners tests. | — |
|
||||
| `/cook-from-pantry` | Field adds an ingredient on Enter; chips' remove buttons; Find ideas. | The field rings as a whole (`:focus-within`). | Ingredients list "Ingredients on hand"; idea cards named. | Chip remove buttons 44 px. | — |
|
||||
| `/dinner/:id` (phone) | Buttons for Favorite, Add to week, back. | Global ring. | Favorite is `aria-pressed`; Add to week sheet labelled. | Favorite 52 px. | — |
|
||||
| `/dinner/:id` Kitchen reading mode | ← / → move steps (not while typing, nor with a sheet open); Previous and Next step buttons; text size radio group; ingredient checkboxes. | **The text-size pill clipped its focus ring — fixed (M3).** | Keep screen on is a `role="switch"`; ingredients are native checkboxes; the current step is `aria-current="step"`. | Checkboxes 44 px rows. | — |
|
||||
| `/settings` | Rows are links; on tablet, a `tablist` with arrow keys. | Global ring. | Tabs `role="tab"` / `tabpanel`. | Covered by Settings tests. | — |
|
||||
| `/settings/notifications` | Every channel is a switch; reminder time button; webhook form. | Global ring. | Each switch named "*Event*: *Channel*"; the matrix is a named group; the webhook sheet is labelled. | Toggle hit box 44 px. | — |
|
||||
| `/settings/ai` | Selects, slider, Add/Replace key. | Global ring. | Native `select`s and range input with labels; key sheet labelled. | Covered by AI settings tests. | — |
|
||||
| `/settings/household` | Selects; time zone list of buttons. | Global ring. | The chosen time zone is `aria-pressed`. | Zone options 48 px. | — |
|
||||
| `/settings/regulars` | Add, edit and favourite buttons; method selects; recipe search. | Global ring. | Favourite is `aria-pressed`; edit sheet labelled. | Covered by Regulars tests. | — |
|
||||
| `/settings/calendar` | Connect link; calendar select. | Global ring. | Reconnect problems are `role="alert"`. | — | — |
|
||||
|
||||
## Text contrast
|
||||
|
||||
WCAG AA asks 4.5:1 for normal-size text. Every colour a stylesheet gives text,
|
||||
on every ground it is drawn on:
|
||||
|
||||
| Text token | Grounds | Lowest ratio |
|
||||
| --- | --- | --- |
|
||||
| `--color-text` | bg, surface, neutral-100, neutral-200, accent-100, accent-200, accent-2-100, accent-2-200, accent-2-300 | 11.10 (on accent-2-300) |
|
||||
| `--color-neutral-700` | bg, surface, neutral-100, neutral-200, accent-100, accent-200, accent-2-100, accent-2-200 | 5.22 (on accent-200) |
|
||||
| `--color-neutral-800` | the same light grounds | 7.90 |
|
||||
| `--color-neutral-900` | the same light grounds | 11.18 |
|
||||
| `--color-accent` | the same light grounds | 7.16 |
|
||||
| `--color-accent-700` | the same light grounds | 5.76 |
|
||||
| `--color-accent-800` | the same light grounds | 8.69 |
|
||||
| `--color-accent-2-800` | the same light grounds | 7.51 |
|
||||
| `--color-accent-2-900` | the light grounds and accent-2-300 | 9.51 (on accent-2-300) |
|
||||
| `--on-accent` | accent, accent-800, neutral-800 | 8.39 (on accent) |
|
||||
| `--color-bg` | text, neutral-800 | 9.26 (on neutral-800) |
|
||||
| `--color-neutral-400` | text (the bottom bar's inactive items) | 8.28 |
|
||||
|
||||
`--color-neutral-600` (3.5:1 on surface, 4.0:1 on bg) was used for muted text
|
||||
in five places and failed — fixed (M1). [contrast.test.ts](../web/src/contrast.test.ts)
|
||||
checks every pair above from tokens.css and fails if any stylesheet gives text a
|
||||
colour token not in this table.
|
||||
|
||||
Non-text: the teal Try it button and teal "+" buttons draw a light icon on
|
||||
`--color-accent-2-600` at 3.73:1, which meets the 3:1 asked of icons; their
|
||||
names are text elsewhere (the shown label, or `aria-label`).
|
||||
|
||||
## Findings
|
||||
|
||||
### Fixed in this pass
|
||||
|
||||
| # | Severity | Route | Finding | Fix | Regression test |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| M1 | Medium | `/plan`, `/dinner/:id`, `/cook-from-pantry` | Muted text in `--color-neutral-600` at 3.5–4.0:1: the swapping day's dinner and start-by time, Regulars already this week in the swap sheet, ingredients set out and steps done in Kitchen reading mode, and the ingredient field's placeholder. | `--color-neutral-700` (≥ 5.4:1 on bg and surface); still muted against `--color-text`, and set-out ingredients keep their strike-through. | [contrast.test.ts](../web/src/contrast.test.ts); the swapping-tile and done-step colour assertions in [PlanPage.test.tsx](../web/src/plan/PlanPage.test.tsx) and [KitchenReading.test.tsx](../web/src/dinners/KitchenReading.test.tsx). |
|
||||
| M2 | Medium | Every route (phone) | The bottom bar's focus ring was the global grape ring on the near-black bar: 1.8:1, nearly invisible. | The bar's items ring in `--on-accent` (15.4:1 on the bar). | "rings a focused item in on-accent ink" in [BottomBar.test.tsx](../web/src/shell/BottomBar.test.tsx). |
|
||||
| M3 | Medium | `/dinner/:id` Kitchen reading mode | The text-size pill hides overflow, so the focus ring of the chosen size — drawn outside it — was cut off top and bottom, and in grape on the grape fill. | The ring sits 4 px inside the size, in `--on-accent` (8.4:1 on grape). Only the chosen size takes focus (roving tab stop). | "rings the focused size inside the clipped pill" in [KitchenReading.test.tsx](../web/src/dinners/KitchenReading.test.tsx). |
|
||||
| M4 | Medium | `/plan` (tablet portrait, 768–1179 px) | The Quest log strip's draggable chips were 38 px tall (28 px photo, 3 px padding, 2 px border). | The chip button keeps a 44 × 44 px minimum. | The strip test's `expectMinHitArea` in [PlanPage.test.tsx](../web/src/plan/PlanPage.test.tsx). |
|
||||
|
||||
No high findings.
|
||||
|
||||
### Follow-ups (low)
|
||||
|
||||
| # | Route | Finding | Suggested follow-up |
|
||||
| --- | --- | --- | --- |
|
||||
| L1 | Every route with a checkbox | An unchecked checkbox's `--color-neutral-400` border is 1.9:1 on bg — below the 3:1 WCAG asks of a control's boundary. Its label row still names and reveals it. | Darken the border (for example `--color-neutral-600`, 4.0:1) with the design owner. |
|
||||
| L2 | Settings, Kitchen reading mode | An off toggle's `--color-neutral-300` track is 1.4:1 on bg; the knob's position and the switch's name carry the state. | Give the off track a ≥ 3:1 edge with the design owner. |
|
||||
| L3 | `/discover` | The ← / → shortcuts are not announced to screen readers. | Add `aria-keyshortcuts` to Pass and Try it. |
|
||||
| L4 | `/discover` | Deciding the last card replaces Pass and Try it with "All caught up", and focus falls back to the page. | Move focus to "Get more now" when the deck empties. |
|
||||
| L5 | `/discover` | A decision is not announced; the next card's name appears silently. | A polite status ("Added to the Quest log" / "Passed") after each decision. |
|
||||
| L6 | `/plan` (mobile) | A day tile's name is its day and dinner ("MON Beef tacos"); it does not say it opens the swap sheet. | Add "Swap" to the tile's accessible name. |
|
||||
@@ -0,0 +1,86 @@
|
||||
// Text contrast over the design tokens (docs/A11Y.md, "Text contrast"). Every
|
||||
// colour a stylesheet gives text must be a token listed in TEXT_GROUNDS, and
|
||||
// every pair listed there must reach WCAG AA's 4.5:1 for normal-size text.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import tokens from './styles/tokens.css?raw'
|
||||
|
||||
const stylesheets = import.meta.glob<string>(['./**/*.css', '!./styles/tokens.css'], {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
eager: true,
|
||||
})
|
||||
|
||||
// The grounds MealQuest's pages, cards, rows and tinted fills use.
|
||||
const LIGHT_GROUNDS = [
|
||||
'--color-bg',
|
||||
'--color-surface',
|
||||
'--color-neutral-100',
|
||||
'--color-neutral-200',
|
||||
'--color-accent-100',
|
||||
'--color-accent-200',
|
||||
'--color-accent-2-100',
|
||||
'--color-accent-2-200',
|
||||
]
|
||||
|
||||
// Each text colour token, with every ground it is drawn on.
|
||||
const TEXT_GROUNDS: Record<string, string[]> = {
|
||||
'--color-text': [...LIGHT_GROUNDS, '--color-accent-2-300'],
|
||||
'--color-neutral-700': LIGHT_GROUNDS,
|
||||
'--color-neutral-800': LIGHT_GROUNDS,
|
||||
'--color-neutral-900': LIGHT_GROUNDS,
|
||||
'--color-accent': LIGHT_GROUNDS,
|
||||
'--color-accent-700': LIGHT_GROUNDS,
|
||||
'--color-accent-800': LIGHT_GROUNDS,
|
||||
'--color-accent-2-800': LIGHT_GROUNDS,
|
||||
'--color-accent-2-900': [...LIGHT_GROUNDS, '--color-accent-2-300'],
|
||||
// Light ink on the grape fills (buttons, the active nav pill, the chosen
|
||||
// text size) and on the dark ones (the Offline pill).
|
||||
'--on-accent': ['--color-accent', '--color-accent-800', '--color-neutral-800'],
|
||||
// The dark pill button and the chosen settings tab.
|
||||
'--color-bg': ['--color-text', '--color-neutral-800'],
|
||||
// The bottom bar's inactive items, on the dark bar.
|
||||
'--color-neutral-400': ['--color-text'],
|
||||
}
|
||||
|
||||
function hex(name: string): string {
|
||||
const value = new RegExp(`^\\s*${name}:\\s*(#[0-9a-fA-F]{6})\\b`, 'm').exec(tokens)?.[1]
|
||||
if (value === undefined) throw new Error(`${name} is not a hex colour in tokens.css`)
|
||||
return value
|
||||
}
|
||||
|
||||
// Relative luminance, per WCAG 2.
|
||||
function luminance(colour: string): number {
|
||||
const [r, g, b] = [1, 3, 5].map((i) => {
|
||||
const c = Number.parseInt(colour.slice(i, i + 2), 16) / 255
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
||||
})
|
||||
return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!
|
||||
}
|
||||
|
||||
function contrast(a: string, b: string): number {
|
||||
const [light, dark] = [luminance(hex(a)), luminance(hex(b))].sort((x, y) => y - x)
|
||||
return (light! + 0.05) / (dark! + 0.05)
|
||||
}
|
||||
|
||||
describe('text contrast', () => {
|
||||
const pairs = Object.entries(TEXT_GROUNDS).flatMap(([text, grounds]) => grounds.map((ground) => [text, ground] as const))
|
||||
|
||||
it.each(pairs)('%s on %s reaches 4.5:1', (text, ground) => {
|
||||
expect(contrast(text, ground)).toBeGreaterThanOrEqual(4.5)
|
||||
})
|
||||
|
||||
it('gives text only the colour tokens checked above', () => {
|
||||
expect(Object.keys(stylesheets).length).toBeGreaterThan(0)
|
||||
const unchecked = Object.entries(stylesheets).flatMap(([path, css]) =>
|
||||
Array.from(css.matchAll(/(?<![\w-])color:\s*([^;}]+)/g))
|
||||
.map((match) => match[1]!.trim())
|
||||
.filter((value) => value !== 'inherit')
|
||||
.filter((value) => {
|
||||
const token = /^var\((--[\w-]+)\)$/.exec(value)?.[1]
|
||||
return token === undefined || !(token in TEXT_GROUNDS)
|
||||
})
|
||||
.map((value) => `${path}: color: ${value}`),
|
||||
)
|
||||
expect(unchecked).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -341,6 +341,13 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* The pill clips anything outside it, so the focus ring sits inside the
|
||||
size, drawn in on-accent ink: only the chosen size takes focus. */
|
||||
.readingHeader .textSize > :focus-visible {
|
||||
outline-color: var(--on-accent);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
|
||||
.readingHeader .textSize > :nth-child(1) {
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -433,7 +440,7 @@
|
||||
}
|
||||
|
||||
.setOut {
|
||||
color: var(--color-neutral-600);
|
||||
color: var(--color-neutral-700);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
@@ -515,7 +522,7 @@
|
||||
}
|
||||
|
||||
.stepDone {
|
||||
color: var(--color-neutral-600);
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.stepCurrent {
|
||||
|
||||
@@ -325,7 +325,7 @@ describe('Kitchen reading mode', () => {
|
||||
// The step done is muted, its number a check.
|
||||
expect(steps()[0]!.textContent).toBe('Rinse the rice and start it cooking.')
|
||||
expect(steps()[0]!.querySelector('svg')).not.toBeNull()
|
||||
expect(declaredStyle(steps()[0]!).color).toBe('var(--color-neutral-600)')
|
||||
expect(declaredStyle(steps()[0]!).color).toBe('var(--color-neutral-700)')
|
||||
expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(18px * var(--reading-scale))')
|
||||
|
||||
fireEvent.click(next)
|
||||
@@ -381,6 +381,21 @@ describe('Kitchen reading mode', () => {
|
||||
})
|
||||
|
||||
describe('text size', () => {
|
||||
it('rings the focused size inside the clipped pill, in on-accent ink on the grape', async () => {
|
||||
serve()
|
||||
await renderPage()
|
||||
const medium = screen.getByRole('radio', { name: 'Medium text' })
|
||||
// A key press makes the next focus a keyboard one, which
|
||||
// :focus-visible matches.
|
||||
fireEvent.keyDown(document.body, { key: 'Tab' })
|
||||
medium.focus()
|
||||
expect(medium.matches(':focus-visible')).toBe(true)
|
||||
// The pill hides overflow, so a ring outside the size would be cut off.
|
||||
expect(declaredStyle(medium).getPropertyValue('outline-offset')).toBe('-4px')
|
||||
expect(declaredStyle(medium).getPropertyValue('outline-color')).toBe('var(--on-accent)')
|
||||
expect(declaredStyle(medium).background).toBe('var(--color-accent)')
|
||||
})
|
||||
|
||||
it('is remembered on this device', async () => {
|
||||
serve()
|
||||
const view = await renderPage()
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-neutral-600);
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.notice {
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
|
||||
.swapping .dinner,
|
||||
.swapping .startBy {
|
||||
color: var(--color-neutral-600);
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.rebuildCell {
|
||||
@@ -410,6 +410,12 @@
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* An upright tablet's week: four days over three, every column wide enough
|
||||
that nothing scrolls sideways. */
|
||||
.portraitPage .week {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.column {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SupplyRunPage } from '../shopping/SupplyRunPage.tsx'
|
||||
import { cacheFallbackHeader } from '../sw/routes.ts'
|
||||
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { ago, cooking, duration, PlanPage } from './PlanPage.tsx'
|
||||
@@ -1222,7 +1223,7 @@ describe('PlanPage', () => {
|
||||
expect(declaredStyle(friday).background).toBe('var(--color-bg)')
|
||||
expect(declaredStyle(friday).boxShadow).toBe('inset 0 0 0 2px var(--color-accent)')
|
||||
expect(declaredStyle(within(friday).getByText('FRI · SWAPPING…')).color).toBe('var(--color-accent-700)')
|
||||
expect(declaredStyle(within(friday).getByText('Pizza night')).color).toBe('var(--color-neutral-600)')
|
||||
expect(declaredStyle(within(friday).getByText('Pizza night')).color).toBe('var(--color-neutral-700)')
|
||||
|
||||
// "Quest log · N | Regulars · N", on the Quest log first.
|
||||
expect(await within(sheet).findByRole('radio', { name: 'Quest log · 1', checked: true })).toBeInTheDocument()
|
||||
@@ -1644,6 +1645,9 @@ describe.each([
|
||||
const chip = within(within(strip).getByRole('list', { name: 'Quest log dinners' })).getByRole('listitem')
|
||||
expect(chip).toHaveTextContent('Lemon orzo')
|
||||
expect(declaredStyle(within(chip).getByRole('img', { hidden: true, name: 'Lemon orzo' })).width).toBe('28px')
|
||||
// The 28 px photo makes a 38 px chip; the chip is a button, so it
|
||||
// keeps the 44 px tap minimum.
|
||||
expectMinHitArea(within(chip).getByRole('button', { name: /Lemon orzo/ }))
|
||||
// The strip follows the grid.
|
||||
const grid = await screen.findByRole('list', { name: 'Dinners this week' })
|
||||
expect(grid.compareDocumentPosition(strip) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Confirm } from '../settings/LeaveGuard.tsx'
|
||||
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
|
||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun, WEEK_PARAM } from '../shopping/api.ts'
|
||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||
import { useBreakpoint, useOrientation, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
@@ -673,8 +673,10 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
|
||||
// week; the week buttons keep ?week= on the week shown. The board keeps its place in the tree across layouts, so crossing a
|
||||
// breakpoint keeps a draft being followed. From tablet up a dinner is
|
||||
// dragged from the strip or the panel onto a day of the grid to swap it in.
|
||||
// An upright tablet lays the week out four days over three.
|
||||
export function PlanPage() {
|
||||
const layout = useBreakpoint()
|
||||
const orientation = useOrientation()
|
||||
const settings = useHouseholdSettings()
|
||||
const [params, setParams] = useSearchParams()
|
||||
const chosen = params.get(WEEK_PARAM)
|
||||
@@ -699,7 +701,11 @@ export function PlanPage() {
|
||||
}
|
||||
return (
|
||||
<PlanDragProvider>
|
||||
<div className={`${styles.page} ${styles[`${layout}Page`]}`}>
|
||||
<div
|
||||
className={[styles.page, styles[`${layout}Page`], layout === 'tablet' && orientation === 'portrait' && styles.portraitPage]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<div className={styles.board}>
|
||||
{content}
|
||||
{layout === 'tablet' && <QuestLogStrip />}
|
||||
|
||||
@@ -190,7 +190,12 @@
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* The 28 px photo makes a 38 px chip; the chip is a button, so it keeps the
|
||||
44 px tap minimum. */
|
||||
.chip.draggable {
|
||||
box-sizing: border-box;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 3px 12px 3px 3px;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
/* A dinner already this week: shown, muted, and not a button. */
|
||||
.already {
|
||||
color: var(--color-neutral-600);
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.skeletonRow {
|
||||
|
||||
@@ -83,6 +83,12 @@
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* An upright tablet stacks the columns: Notifications above the AI
|
||||
assistant and Recipe search. */
|
||||
.portrait .columns {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.column {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import { useBreakpoint, useOrientation } from '../shell/useBreakpoint.ts'
|
||||
import { Card } from '../ui/Card.tsx'
|
||||
import { AIAssistantCard } from './AISettingsPage.tsx'
|
||||
import { HouseholdSettingsSections } from './HouseholdSettingsPage.tsx'
|
||||
@@ -21,7 +22,10 @@ const step: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1 }
|
||||
// sub-tabs instead of the mobile list. Every tab stays mounted while hidden,
|
||||
// so a draft survives switching tabs, and one leave guard covers them all.
|
||||
// hidden hides the page but keeps it mounted, drafts and leave guard included.
|
||||
// On an upright tablet the two columns stack into one.
|
||||
export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
|
||||
const layout = useBreakpoint()
|
||||
const orientation = useOrientation()
|
||||
const [tab, setTab] = useState<Tab>('notifications-ai')
|
||||
const [householdDirty, setHouseholdDirty] = useState(false)
|
||||
const [aiDirty, setAIDirty] = useState(false)
|
||||
@@ -64,7 +68,10 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page} hidden={hidden}>
|
||||
<div
|
||||
className={[styles.page, layout === 'tablet' && orientation === 'portrait' && styles.portrait].filter(Boolean).join(' ')}
|
||||
hidden={hidden}
|
||||
>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.heading}>
|
||||
<h1 className={styles.title}>Settings</h1>
|
||||
|
||||
@@ -22,3 +22,9 @@
|
||||
.desktop .main {
|
||||
padding: 32px 36px;
|
||||
}
|
||||
|
||||
/* An upright tablet (820 px wide) leaves 724 px beside the rail; slimmer side
|
||||
padding gives the page a little more of it. */
|
||||
.tablet.portrait .main {
|
||||
padding: 32px 28px;
|
||||
}
|
||||
|
||||
@@ -2,15 +2,17 @@ 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 { useBreakpoint, useOrientation } 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.
|
||||
// scrolling main area for the routed page. A portrait viewport is marked so
|
||||
// the narrower upright tablet keeps its content clear of the rail.
|
||||
export function AppShell() {
|
||||
const breakpoint = useBreakpoint()
|
||||
const orientation = useOrientation()
|
||||
return (
|
||||
<div className={`${styles.shell} ${styles[breakpoint]}`}>
|
||||
<div className={[styles.shell, styles[breakpoint], orientation === 'portrait' && styles.portrait].filter(Boolean).join(' ')}>
|
||||
{breakpoint === 'mobile' && <BottomBar />}
|
||||
{breakpoint === 'tablet' && <Rail />}
|
||||
{breakpoint === 'desktop' && <Sidebar />}
|
||||
|
||||
@@ -48,6 +48,12 @@
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
/* The app-wide grape focus ring barely shows on the dark bar (1.8:1); the
|
||||
bar's ring is the light on-accent ink instead. */
|
||||
.item:focus-visible {
|
||||
outline-color: var(--on-accent);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
|
||||
@@ -182,6 +182,18 @@ describe('BottomBar', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('rings a focused item in on-accent ink, which shows on the dark bar', async () => {
|
||||
await renderAt('/')
|
||||
const item = link('Plan')
|
||||
// A key press makes the next focus a keyboard one, which :focus-visible
|
||||
// matches; earlier tests' clicks would leave it a pointer one.
|
||||
fireEvent.keyDown(document.body, { key: 'Tab' })
|
||||
item.focus()
|
||||
expect(item).toHaveFocus()
|
||||
expect(item.matches(':focus-visible')).toBe(true)
|
||||
expect(declaredStyle(item).getPropertyValue('outline-color')).toBe('var(--on-accent)')
|
||||
})
|
||||
|
||||
it('does not animate when the active item stays the same', async () => {
|
||||
await renderAt('/plan')
|
||||
fireEvent.click(link('Plan'))
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AppRoutes } from '../App.tsx'
|
||||
import planStyles from '../plan/PlanPage.module.css'
|
||||
import settingsTabsStyles from '../settings/SettingsTabs.module.css'
|
||||
import {
|
||||
AI_MODELS,
|
||||
AI_SETTINGS,
|
||||
CHAT_WEBHOOK_NONE,
|
||||
GOOGLE_NOT_CONNECTED,
|
||||
HOME,
|
||||
HOUSEHOLD_SETTINGS,
|
||||
NOTIFICATION_SETTINGS,
|
||||
RECIPE_SEARCH_SETTINGS,
|
||||
ApiProviders,
|
||||
} from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { dinnerBody } from '../test/dinners.ts'
|
||||
import { jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import shellStyles from './AppShell.module.css'
|
||||
import railStyles from './Rail.module.css'
|
||||
|
||||
// Every primary route on a tablet held upright (820 × 1180) and on its side
|
||||
// (1180 × 820): both have the 96 px rail and the tablet layout; upright, the
|
||||
// shell is marked portrait, the Plan week reflows four days over three, and
|
||||
// Settings' two columns stack into one.
|
||||
|
||||
const PORTRAIT = { width: 820, height: 1180 }
|
||||
const LANDSCAPE = { width: 1180, height: 820 }
|
||||
|
||||
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
|
||||
|
||||
const PLAN = {
|
||||
id: 'plan-1',
|
||||
week_start: '2026-10-05',
|
||||
week_label: 'Week 41 · Oct 5 – 11',
|
||||
status: 'draft',
|
||||
ai_note: null,
|
||||
calendar_synced_at: null,
|
||||
days: DAYS.map((date, i) => ({
|
||||
date,
|
||||
dinner: { id: `d${i}`, name: `Dinner ${i}`, photo: null, minutes: 30, method: 'stovetop', origin: 'regular' },
|
||||
is_new: false,
|
||||
start_by: null,
|
||||
})),
|
||||
updated_at: '2026-10-04T15:00:00Z',
|
||||
}
|
||||
|
||||
const SUPPLY_RUN = {
|
||||
weekStart: '2026-10-05',
|
||||
label: 'Week 41 · Oct 5 – 11',
|
||||
title: null,
|
||||
status: 'current',
|
||||
sent_to_chat_at: null,
|
||||
chat_send_unconfirmed_at: null,
|
||||
needed_count: 1,
|
||||
total_count: 1,
|
||||
groups: [
|
||||
{
|
||||
dinner: { id: 'd0', name: 'Dinner 0' },
|
||||
date: '2026-10-05',
|
||||
day: 'Monday',
|
||||
items: [{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const DETAIL = {
|
||||
id: 'd1',
|
||||
name: 'Korean beef bowls',
|
||||
status: 'regular',
|
||||
origin: 'regular',
|
||||
method: 'stovetop',
|
||||
minutes_total: 30,
|
||||
difficulty: 'easy',
|
||||
cuisine: 'korean',
|
||||
is_favorite: false,
|
||||
photo_urls: null,
|
||||
ingredients: [{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }],
|
||||
steps: ['Brown the beef.'],
|
||||
source: null,
|
||||
grounding_status: 'manual',
|
||||
grounding_error: null,
|
||||
why_picked: null,
|
||||
}
|
||||
|
||||
// The API every route reads, answered as a settled household's server would.
|
||||
// Any other request stays pending, so a route that reads more just shows its
|
||||
// loading state.
|
||||
const API: Record<string, unknown> = {
|
||||
'/api/v1/me': ME,
|
||||
'/api/v1/home': HOME,
|
||||
'/api/v1/rotation-reviews': { reviews: [] },
|
||||
'/api/v1/settings/household': HOUSEHOLD_SETTINGS,
|
||||
'/api/v1/settings/ai': AI_SETTINGS,
|
||||
'/api/v1/settings/ai/models': AI_MODELS,
|
||||
'/api/v1/settings/recipe-search': RECIPE_SEARCH_SETTINGS,
|
||||
'/api/v1/settings/notifications': NOTIFICATION_SETTINGS,
|
||||
'/api/v1/settings/chat-webhook': CHAT_WEBHOOK_NONE,
|
||||
'/api/v1/integrations/google': GOOGLE_NOT_CONNECTED,
|
||||
'/api/v1/plans/2026-10-05': PLAN,
|
||||
'/api/v1/shopping/2026-10-05': SUPPLY_RUN,
|
||||
'/api/v1/shopping/archive?by=week': { by: 'week', periods: [] },
|
||||
'/api/v1/suggestions/deck': { suggestions: [], active_batch: null, failed_batch: null },
|
||||
'/api/v1/dinners?status=regular': { dinners: [dinnerBody('r1', 'Beef tacos')] },
|
||||
'/api/v1/dinners?status=quest_log': { dinners: [] },
|
||||
'/api/v1/dinners/d1': DETAIL,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
|
||||
const path = (input: Parameters<typeof fetch>[0]) => new URL(String(input), 'https://mealquest.test')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>((input) => {
|
||||
const url = path(input)
|
||||
const key = `${url.pathname}${url.search}`
|
||||
if (!Object.hasOwn(API, key)) return new Promise<Response>(() => {})
|
||||
return Promise.resolve(jsonResponse(200, API[key]))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function renderAt(path: string, { width, height }: { width: number; height: number }) {
|
||||
mockViewport(width, { height })
|
||||
render(
|
||||
<InDataRouter path={path}>
|
||||
<ApiProviders>
|
||||
<AppRoutes />
|
||||
</ApiProviders>
|
||||
</InDataRouter>,
|
||||
)
|
||||
// The routes wait for the household settings (OnboardingGate).
|
||||
await screen.findByRole('navigation', { name: 'Primary' })
|
||||
}
|
||||
|
||||
function shell() {
|
||||
return screen.getByRole('main').parentElement!
|
||||
}
|
||||
|
||||
const ROUTES: [string, string][] = [
|
||||
['Home', '/'],
|
||||
['Plan', '/plan?week=2026-10-05'],
|
||||
['Supply run', '/plan/shopping'],
|
||||
['Supply archive', '/plan/shopping/archive'],
|
||||
['Discover', '/discover'],
|
||||
['Quick dinners', '/quick'],
|
||||
['Cook from what I have', '/cook-from-pantry'],
|
||||
['Dinner detail (Kitchen reading mode)', '/dinner/d1'],
|
||||
['Settings', '/settings'],
|
||||
['Notifications', '/settings/notifications'],
|
||||
['AI assistant', '/settings/ai'],
|
||||
['Household', '/settings/household'],
|
||||
['Regulars', '/settings/regulars'],
|
||||
['Calendar', '/settings/calendar'],
|
||||
]
|
||||
|
||||
describe.each([
|
||||
['portrait', PORTRAIT],
|
||||
['landscape', LANDSCAPE],
|
||||
])('tablet %s', (orientation, viewport) => {
|
||||
const portrait = orientation === 'portrait'
|
||||
|
||||
it.each(ROUTES)(`renders %s with the 96 px rail in the tablet ${orientation} layout`, async (_name, path) => {
|
||||
await renderAt(path, viewport)
|
||||
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toHaveClass(railStyles.rail!)
|
||||
expect(declaredStyle(screen.getByRole('navigation', { name: 'Primary' })).width).toBe('96px')
|
||||
expect(shell()).toHaveClass(shellStyles.tablet!)
|
||||
if (portrait) expect(shell()).toHaveClass(shellStyles.portrait!)
|
||||
else expect(shell()).not.toHaveClass(shellStyles.portrait!)
|
||||
expect(await screen.findByRole('heading', { level: 1 })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it(`lays the Plan week out ${portrait ? 'four days over three' : 'in seven columns'}`, async () => {
|
||||
await renderAt('/plan?week=2026-10-05', viewport)
|
||||
const week = await screen.findByRole('list', { name: 'Dinners this week' })
|
||||
const page = week.closest(`.${planStyles.tabletPage}`)
|
||||
expect(page).not.toBeNull()
|
||||
if (portrait) expect(page).toHaveClass(planStyles.portraitPage!)
|
||||
else expect(page).not.toHaveClass(planStyles.portraitPage!)
|
||||
expect(declaredStyle(week).gridTemplateColumns).toBe(
|
||||
portrait ? 'repeat(4, minmax(0, 1fr))' : 'repeat(7, minmax(112px, 1fr))',
|
||||
)
|
||||
expect(declaredStyle(week).overflowX).toBe('auto')
|
||||
})
|
||||
|
||||
it(`shows Settings (mock 2e) in ${portrait ? 'one column' : 'two columns'}`, async () => {
|
||||
await renderAt('/settings', viewport)
|
||||
const panel = screen.getByRole('tabpanel', { name: 'Notifications & AI' })
|
||||
const page = panel.closest(`.${settingsTabsStyles.page}`)
|
||||
if (portrait) expect(page).toHaveClass(settingsTabsStyles.portrait!)
|
||||
else expect(page).not.toHaveClass(settingsTabsStyles.portrait!)
|
||||
expect(declaredStyle(panel).gridTemplateColumns).toBe(
|
||||
portrait ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) minmax(0, 1.15fr)',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('reflows when the tablet turns', async () => {
|
||||
const resize = mockViewport(PORTRAIT.width, { height: PORTRAIT.height })
|
||||
render(
|
||||
<InDataRouter path="/plan?week=2026-10-05">
|
||||
<ApiProviders>
|
||||
<AppRoutes />
|
||||
</ApiProviders>
|
||||
</InDataRouter>,
|
||||
)
|
||||
const week = await screen.findByRole('list', { name: 'Dinners this week' })
|
||||
expect(shell()).toHaveClass(shellStyles.portrait!)
|
||||
expect(week.closest(`.${planStyles.tabletPage}`)).toHaveClass(planStyles.portraitPage!)
|
||||
resize(LANDSCAPE.width, LANDSCAPE.height)
|
||||
expect(shell()).toHaveClass(shellStyles.tablet!)
|
||||
expect(shell()).not.toHaveClass(shellStyles.portrait!)
|
||||
expect(week.closest(`.${planStyles.tabletPage}`)).not.toHaveClass(planStyles.portraitPage!)
|
||||
})
|
||||
@@ -7,6 +7,12 @@ export type Breakpoint = 'mobile' | 'tablet' | 'desktop'
|
||||
export const TABLET_QUERY = '(min-width: 768px)'
|
||||
export const DESKTOP_QUERY = '(min-width: 1200px)'
|
||||
|
||||
export type Orientation = 'portrait' | 'landscape'
|
||||
|
||||
// A tablet held upright (820 × 1180) has the rail and the tablet layout but
|
||||
// far less width than one on its side, so pages reflow for it.
|
||||
export const PORTRAIT_QUERY = '(orientation: portrait)'
|
||||
|
||||
// The layout the current viewport calls for, updated as the viewport crosses
|
||||
// a breakpoint.
|
||||
export function useBreakpoint(): Breakpoint {
|
||||
@@ -29,3 +35,16 @@ export function useBreakpoint(): Breakpoint {
|
||||
lists.desktop.matches ? 'desktop' : lists.tablet.matches ? 'tablet' : 'mobile',
|
||||
)
|
||||
}
|
||||
|
||||
// The viewport's orientation, updated as the device turns.
|
||||
export function useOrientation(): Orientation {
|
||||
const [list] = useState(() => window.matchMedia(PORTRAIT_QUERY))
|
||||
const subscribe = useCallback(
|
||||
(onChange: () => void) => {
|
||||
list.addEventListener('change', onChange)
|
||||
return () => list.removeEventListener('change', onChange)
|
||||
},
|
||||
[list],
|
||||
)
|
||||
return useSyncExternalStore(subscribe, () => (list.matches ? 'portrait' : 'landscape'))
|
||||
}
|
||||
|
||||
@@ -3,16 +3,22 @@ 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, and prefers-reduced-motion: reduce
|
||||
// with reducedMotion (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, { reducedMotion = false } = {}): (width: number) => void {
|
||||
// queries for a pretend viewport width, orientation: portrait for its height
|
||||
// (a viewport given no height is landscape), and prefers-reduced-motion:
|
||||
// reduce with reducedMotion (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,
|
||||
{ reducedMotion = false, height: initialHeight = 0 } = {},
|
||||
): (width: number, height?: number) => void {
|
||||
let width = initialWidth
|
||||
let height = initialHeight
|
||||
const lists: { list: MediaQueryList; listeners: Set<Listener> }[] = []
|
||||
|
||||
function evaluate(query: string): boolean {
|
||||
if (query === '(prefers-reduced-motion: reduce)') return reducedMotion
|
||||
if (query === '(orientation: portrait)') return height >= width
|
||||
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)}`)
|
||||
@@ -37,9 +43,10 @@ export function mockViewport(initialWidth: number, { reducedMotion = false } = {
|
||||
return list
|
||||
}
|
||||
|
||||
return (next: number) => {
|
||||
return (nextWidth: number, nextHeight = 0) => {
|
||||
const before = lists.map(({ list }) => list.matches)
|
||||
width = next
|
||||
width = nextWidth
|
||||
height = nextHeight
|
||||
act(() => {
|
||||
lists.forEach(({ list, listeners }, i) => {
|
||||
if (list.matches === before[i]) return
|
||||
|
||||
Reference in New Issue
Block a user