batchCooking/apps/web/src/components/ui/Dialog.tsx
Nicolas 85fd9bae7d feat(planning): le picker prévisualise avant de confirmer, formulaire de revue intégré
Trois ajustements successifs sur le dialogue de sélection de recette
(RecipePickerDialog), demandés en continu après le premier correctif
de débordement :

1. Dialogue élargi et à hauteur fixe (95vw plafonné à 85rem, 80vh) au
   lieu de dépendre du contenu, avec la répartition liste/détail
   redéfinie en fractions du dialogue lui-même (3fr/2fr) plutôt qu'en
   vw — cette dernière suivait la largeur du viewport, sans rapport
   avec la largeur désormais fixe du dialogue.

2. Le formulaire de revue d'import (ex-ImportRecipePage) est extrait
   dans un composant partagé, RecipeImportForm — toujours monté en
   page autonome (route directe/rechargement), mais désormais aussi
   intégré comme une étape du dialogue lui-même quand un item de
   source a besoin d'une résolution manuelle, au lieu de naviguer et
   perdre le contexte du picker (recherche, filtres, créneau).

3. Cliquer sur une recette dans le dialogue ne fait plus que la
   sélectionner/prévisualiser (RecipeDetailPanel, comme /recettes) —
   plus de saut automatique vers l'étape suivante. Un nouveau pied de
   dialogue (Dialog.tsx gagne une prop ) porte Confirmer/
   Fermer : Confirmer agit sur la sélection en cours (recette réelle
   → étape portions existante ; item de source pas encore importé →
   import transparent ou formulaire intégré, point 2). Les onglets
   réguliers gagnent leur propre paire maître-détail (RecipeTable +
   RecipeDetailPanel, showActions=false) sur ce même modèle ; les
   onglets source prévisualisent désormais aussi les items déjà
   importés en interne (RecipeSourcesPanel), plus de saut direct.

Cypress (planning.feature/planning.ts) mis à jour en conséquence :
sélectionner puis confirmer sont deux étapes distinctes, le clic sur
la ligne ne déclenche plus rien tout seul.

Bug pré-existant trouvé en testant en direct (sans rapport avec ce qui
précède) : l'import d'une recette source plante avec une contrainte
d'unicité Prisma dès que deux lignes d'ingrédient se résolvent au même
ingrédient catalogue — signalé séparément (tâche en arrière-plan), pas
corrigé ici.

Vérifié en direct (navigateur, comptes de test) : sélection sans saut
d'écran, pied de dialogue activé/désactivé correctement, Confirmer sur
un item de source non résolu bascule vers le formulaire intégré,
Fermer ferme bien le dialogue.

pnpm exec tsc -b --force (web) — propre.
pnpm exec biome check — propre.
pnpm --filter web build — propre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 00:05:17 +02:00

108 lines
4.2 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,
footer,
}: {
onClose: () => void;
title?: string;
children: ReactNode;
className?: string;
/** Optional action bar pinned below the scrollable body (`.dialog-panel__footer`) — outside `.dialog-panel__body`'s own scroll, same idea as `title`'s header. Omit for a plain dialog with no persistent footer actions. */
footer?: ReactNode;
}) {
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>
{footer && <div className="dialog-panel__footer">{footer}</div>}
</dialog>
);
}