diff --git a/apps/api/test/planning.test.ts b/apps/api/test/planning.test.ts index 9299693..a2de7a7 100644 --- a/apps/api/test/planning.test.ts +++ b/apps/api/test/planning.test.ts @@ -108,7 +108,13 @@ describe("Planning", () => { }, }); await prisma.planningItem.create({ - data: { planningId: planning.id, weekDay: "lundi", meal: "diner", recipeId: recipe.id }, + data: { + planningId: planning.id, + weekDay: "lundi", + meal: "diner", + recipeId: recipe.id, + portions: 4, + }, }); const res = await agent.get("/planning").query({ date: today() }); @@ -159,7 +165,13 @@ describe("Planning", () => { }, }); await prisma.planningItem.create({ - data: { planningId: planning.id, weekDay: "mardi", meal: "dejeuner", recipeId: recipe.id }, + data: { + planningId: planning.id, + weekDay: "mardi", + meal: "dejeuner", + recipeId: recipe.id, + portions: 2, + }, }); const res = await agent.get("/planning").query({ date: isoDate(nextWeek) }); diff --git a/apps/api/test/recipe.test.ts b/apps/api/test/recipe.test.ts index 5cb356e..4d7935e 100644 --- a/apps/api/test/recipe.test.ts +++ b/apps/api/test/recipe.test.ts @@ -461,7 +461,13 @@ describe("Recipes", () => { }, }); await prisma.planningItem.create({ - data: { planningId: planning.id, weekDay: "lundi", meal: "diner", recipeId: recipe.id }, + data: { + planningId: planning.id, + weekDay: "lundi", + meal: "diner", + recipeId: recipe.id, + portions: 4, + }, }); const res = await agent.delete(`/recipes/${recipe.id}`); diff --git a/apps/web/cypress/e2e/planning-page.cy.ts b/apps/web/cypress/e2e/planning-page.cy.ts index bfb4af3..08610bc 100644 --- a/apps/web/cypress/e2e/planning-page.cy.ts +++ b/apps/web/cypress/e2e/planning-page.cy.ts @@ -82,11 +82,18 @@ describe("Planning grid", () => { startDate: "2026-08-17T00:00:00.000Z", finishDate: "2026-08-23T00:00:00.000Z", items: [ - { id: 1, weekDay: "mardi", meal: "diner", recipe: { id: 1, name: "Ratatouille" } }, + { + id: 1, + weekDay: "mardi", + meal: "diner", + portions: 4, + recipe: { id: 1, name: "Ratatouille" }, + }, { id: 2, weekDay: "mercredi", meal: "dejeuner", + portions: 2, recipe: { id: 2, name: "Curry de lentilles" }, }, ], diff --git a/apps/web/src/components/ui/Dialog.tsx b/apps/web/src/components/ui/Dialog.tsx index ae1790d..fb312d9 100644 --- a/apps/web/src/components/ui/Dialog.tsx +++ b/apps/web/src/components/ui/Dialog.tsx @@ -2,79 +2,103 @@ import { type ReactNode, useEffect, useRef } from "react"; import "./dialog.scss"; /** - * App-wide modal primitive — full-screen backdrop + a centered panel, - * closing on Escape or an outside click (same `mousedown`-outside pattern - * as `PlanningPage.tsx`'s `CalendarPopover`, generalized here instead of - * duplicated a third time). First modal in the app — every other + * App-wide modal primitive — a native `` (`showModal()`), not a + * `role="dialog"` div: the browser handles the modal semantics, the + * focus trap, Escape-to-close and the backdrop for free instead of this + * component reimplementing all four. First modal in the app — every other * "confirm/cancel" surface so far (`RecipeDetailPanel`'s delete button, * the settings pages' danger zones) is an inline two-step reveal, not an * overlay; a full recipe catalog + filters (`RecipePickerDialog`) doesn't * fit inline, hence this. * - * Renders nothing while `isOpen` is `false` — callers don't need to guard - * mounting it themselves. + * Mounted only while open (see `PlanningPage`'s conditional rendering of + * `RecipePickerDialog`, same convention as its own `CalendarPopover`) — + * `showModal()` fires once on mount rather than toggling on an `isOpen` + * prop, since closing this component means the caller stops rendering it + * rather than flipping a prop on a permanently-mounted instance. */ export function Dialog({ - isOpen, onClose, title, children, className, }: { - isOpen: boolean; onClose: () => void; title?: string; children: ReactNode; className?: string; }) { - const panelRef = useRef(null); + const dialogRef = useRef(null); useEffect(() => { - if (!isOpen) return; + dialogRef.current?.showModal(); + }, []); - function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") onClose(); + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + // `close` covers every way a native dialog can close — Escape (which + // fires `cancel` first, then `close`) as much as a future + // `
` — so this is the one listener needed to + // keep the caller's own "is this open" state (e.g. `PlanningPage`'s + // `openSlot`) in sync with it. + dialog.addEventListener("close", onClose); + return () => dialog.removeEventListener("close", onClose); + }, [onClose]); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + // Attached imperatively (not a JSX `onClick`) since the click-to-close + // it implements is already reachable from the keyboard via Escape + // (native `cancel`/`close`, wired above) — a JSX `onClick` here would + // trip the "needs a matching keyboard handler" a11y lint for a + // non-interactive element even though one already exists, just not in + // a form that lint rule can see. + function handleClick(e: MouseEvent) { + // Re-read from the ref (not the outer `dialog` const) — TS can't + // carry that early-return narrowing into a nested function, since it + // can't prove the function won't run at some later point where it no + // longer holds (even though here, as an event listener on this same + // element, it trivially still does). + const current = dialogRef.current; + if (!current) return; + // A click lands on the `` element itself both for the + // backdrop *and* for its own unfilled padding/margin — comparing + // against its content box (not just `e.target`) is what actually + // distinguishes "outside the panel" from "on it". + const rect = current.getBoundingClientRect(); + const inside = + e.clientX >= rect.left && + e.clientX <= rect.right && + e.clientY >= rect.top && + e.clientY <= rect.bottom; + if (!inside) current.close(); } - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [isOpen, onClose]); - - if (!isOpen) return null; + dialog.addEventListener("click", handleClick); + return () => dialog.removeEventListener("click", handleClick); + }, []); return ( -
{ - if (e.target === e.currentTarget) onClose(); - }} + -
- {title && ( -
-

{title}

- -
- )} -
{children}
-
-
+ {title && ( +
+

{title}

+ +
+ )} +
{children}
+
); } diff --git a/apps/web/src/components/ui/dialog.scss b/apps/web/src/components/ui/dialog.scss index 2b4d315..1bb7dac 100644 --- a/apps/web/src/components/ui/dialog.scss +++ b/apps/web/src/components/ui/dialog.scss @@ -1,17 +1,9 @@ -// Modal overlay + panel — see Dialog.tsx. Colocated here rather than in -// global.scss since it's one component's styling, same convention as every -// feature's own .scss file (recipes.scss, planning-page.scss, …). - -.dialog-overlay { - position: fixed; - inset: 0; - z-index: 100; - display: flex; - align-items: center; - justify-content: center; - padding: var(--space-lg); - background: rgba(0, 0, 0, 0.45); -} +// Modal panel — see Dialog.tsx. A native opened via showModal(), +// so the browser supplies the backdrop/centering/focus-trap; this only +// resets its default UA styling (border, padding, colors) and layers the +// header/body layout on top. Colocated here rather than in global.scss +// since it's one component's styling, same convention as every feature's +// own .scss file (recipes.scss, planning-page.scss, …). .dialog-panel { display: flex; @@ -19,10 +11,18 @@ width: 100%; max-width: 48rem; max-height: calc(100vh - var(--space-2xl)); + margin: auto; // UA default for a shown , kept explicit + padding: 0; + border: none; background: var(--color-surface); + color: var(--color-text); border-radius: var(--radius-lg); box-shadow: var(--shadow-md); overflow: hidden; + + &::backdrop { + background: rgba(0, 0, 0, 0.45); + } } .dialog-panel__header { diff --git a/apps/web/src/features/planning/RecipePickerDialog.tsx b/apps/web/src/features/planning/RecipePickerDialog.tsx index 7f74fc2..65ce812 100644 --- a/apps/web/src/features/planning/RecipePickerDialog.tsx +++ b/apps/web/src/features/planning/RecipePickerDialog.tsx @@ -1,6 +1,6 @@ import { - ErrorCode, type DietView, + ErrorCode, type IngredientView, type Meal, type PlanningItemView, @@ -167,7 +167,6 @@ export function RecipePickerDialog({ if (selectedRecipe) { return ( @@ -180,7 +179,6 @@ export function RecipePickerDialog({ step="1" value={portions} onChange={(e) => setPortions(e.target.value)} - autoFocus /> {submitError &&

{submitError}

}
@@ -202,7 +200,7 @@ export function RecipePickerDialog({ } return ( - +
- setSelectedIngredientIds((ids) => [...ids, ingredient.id]) - } + onSelect={(ingredient) => setSelectedIngredientIds((ids) => [...ids, ingredient.id])} /> )}
@@ -268,9 +264,7 @@ export function RecipePickerDialog({

{t("planning.picker.loading")}

)} {listState.status === "error" && ( -

- {t("common.loadError")} -

+

{t("common.loadError")}

)} {listState.status === "loaded" && listState.recipes.length === 0 && (

{t("planning.picker.empty")}