- Dialog.tsx : remplace le div role="dialog" par un <dialog> natif (showModal) — corrige lint/a11y/useSemanticElements, récupère gratuitement le piège de focus et l'Échap natifs. Le clic extérieur est rebranché en imperative addEventListener pour éviter lint/a11y/useKeyWithClickEvents sur un élément non interactif. - RecipePickerDialog.tsx : retire l'autoFocus (lint/a11y/noAutofocus), ordre des imports/formatage corrigés par `biome check --write`. - apps/api/test/planning.test.ts, apps/api/test/recipe.test.ts : les fixtures qui créent un `PlanningItem` directement via Prisma n'avaient pas le nouveau champ `portions` requis. - apps/web/cypress/e2e/planning-page.cy.ts : ajoute `portions` aux items mockés pour rester fidèle au contrat `PlanningItemView`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
104 lines
3.9 KiB
TypeScript
104 lines
3.9 KiB
TypeScript
import { type ReactNode, useEffect, useRef } from "react";
|
|
import "./dialog.scss";
|
|
|
|
/**
|
|
* App-wide modal primitive — a native `<dialog>` (`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.
|
|
*
|
|
* 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({
|
|
onClose,
|
|
title,
|
|
children,
|
|
className,
|
|
}: {
|
|
onClose: () => void;
|
|
title?: string;
|
|
children: ReactNode;
|
|
className?: string;
|
|
}) {
|
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
|
|
useEffect(() => {
|
|
dialogRef.current?.showModal();
|
|
}, []);
|
|
|
|
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
|
|
// `<form method="dialog">` — 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 `<dialog>` 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();
|
|
}
|
|
dialog.addEventListener("click", handleClick);
|
|
return () => dialog.removeEventListener("click", handleClick);
|
|
}, []);
|
|
|
|
return (
|
|
<dialog
|
|
ref={dialogRef}
|
|
className={["dialog-panel", className].filter(Boolean).join(" ")}
|
|
aria-label={title}
|
|
>
|
|
{title && (
|
|
<div className="dialog-panel__header">
|
|
<h2>{title}</h2>
|
|
<button
|
|
type="button"
|
|
className="dialog-panel__close"
|
|
onClick={() => dialogRef.current?.close()}
|
|
aria-label="Fermer"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div className="dialog-panel__body">{children}</div>
|
|
</dialog>
|
|
);
|
|
}
|