Reflow tablet portrait layouts: Plan week 4 + 3, Settings one column
At 768-1199 px held upright the shell marks itself portrait (new useOrientation hook over the existing tablet breakpoint) and trims its side padding; the Plan week lays out four days over three instead of scrolling sideways, and Settings (mock 2e) stacks its two columns into one. Vitest covers every primary route at 820x1180 and 1180x820. Task: 529c743e-4464-479b-b750-7ec41a0c9772 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,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 />}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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