// ===================================================================== // Modale — une seule implémentation pour toute l'application. // // Corrige trois défauts qui coûtaient cher à l'usage : // 1. Le contenu long faisait défiler le TITRE et le bouton « Enregistrer » // hors de l'écran → en-tête et pied sont désormais fixes. // 2. Fermer par Échap ou par un clic à côté effaçait une saisie en cours // sans un mot → `dirty` déclenche une demande de confirmation. // 3. Le focus s'échappait derrière la modale → piège à focus + restitution // du focus à l'élément d'origine à la fermeture. // ===================================================================== import { ReactNode, useCallback, useEffect, useRef } from 'react' import { motion } from 'framer-motion' import { Trash2, X } from 'lucide-react' import { useFeedback } from '../lib/feedback' import { Button, IconButton } from './ui' type ModalSize = 'sm' | 'md' | 'lg' const WIDTH: Record = { sm: 'max-w-sm', md: 'max-w-md', lg: 'max-w-2xl' } const FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])' export function Modal({ title, subtitle, children, footer, onClose, size = 'md', /** Y a-t-il une saisie non enregistrée ? Si oui, on confirme la fermeture. */ dirty = false }: { title: string subtitle?: ReactNode children: ReactNode footer?: ReactNode onClose: () => void size?: ModalSize dirty?: boolean }): JSX.Element { const { confirm } = useFeedback() const panelRef = useRef(null) const returnFocus = useRef(null) const requestClose = useCallback(async (): Promise => { if (!dirty) { onClose() return } const ok = await confirm({ title: 'Abandonner les modifications ?', message: "Ce que tu viens de saisir sera perdu.", confirmLabel: 'Abandonner', cancelLabel: 'Continuer l’édition', danger: true }) if (ok) onClose() }, [dirty, onClose, confirm]) // Focus initial + restitution à la fermeture. useEffect(() => { returnFocus.current = document.activeElement as HTMLElement | null const first = panelRef.current?.querySelector( 'input:not([type="hidden"]):not([disabled]), textarea:not([disabled])' ) // Un champ si la modale en contient un, sinon le panneau lui-même. ;(first ?? panelRef.current)?.focus({ preventScroll: true }) return () => returnFocus.current?.focus?.({ preventScroll: true }) }, []) // Échap (sauf si un menu déroulant est ouvert : il ferme en premier) // et piège à focus sur Tab. useEffect(() => { const onKey = (e: globalThis.KeyboardEvent): void => { if (e.key === 'Escape') { if (document.querySelector('[data-select-open]')) return e.preventDefault() void requestClose() return } if (e.key !== 'Tab' || !panelRef.current) return const items = Array.from(panelRef.current.querySelectorAll(FOCUSABLE)).filter( (el) => el.offsetParent !== null ) if (items.length === 0) return const first = items[0] const last = items[items.length - 1] const active = document.activeElement if (e.shiftKey && (active === first || active === panelRef.current)) { e.preventDefault() last.focus() } else if (!e.shiftKey && active === last) { e.preventDefault() first.focus() } } document.addEventListener('keydown', onKey, true) return () => document.removeEventListener('keydown', onKey, true) }, [requestClose]) return ( void requestClose()} > e.stopPropagation()} className={`flex max-h-[88vh] w-full flex-col overflow-hidden rounded-xl border border-border bg-panel shadow-pop outline-none ${WIDTH[size]}`} > {/* En-tête fixe */}

{title}

{subtitle &&
{subtitle}
}
} onClick={() => void requestClose()} />
{/* Corps défilant */}
{children}
{/* Pied fixe : le bouton principal reste TOUJOURS visible */} {footer && (
{footer}
)}
) } /** * Disposition normalisée du pied de modale : action destructrice à gauche, * Annuler + action principale à droite. */ export function ModalActions({ onDelete, deleteLabel = 'Supprimer', onCancel, cancelLabel = 'Annuler', children }: { onDelete?: () => void deleteLabel?: string onCancel?: () => void cancelLabel?: string children: ReactNode }): JSX.Element { return ( <> {onDelete ? ( ) : ( )}
{onCancel && ( )} {children}
) }