batchCooking/apps/web/src/components/ui/Dialog.tsx
Nicolas c29648e293 fix(ci): corrige lint/tests cassés par la migration portions
- 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>
2026-08-19 14:56:50 +02:00

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>
);
}