Files
gestion_projet/src/components/Modal.tsx
T
2026-08-10 22:06:49 +02:00

195 lines
6.3 KiB
TypeScript

// =====================================================================
// 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<ModalSize, string> = {
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<HTMLDivElement>(null)
const returnFocus = useRef<HTMLElement | null>(null)
const requestClose = useCallback(async (): Promise<void> => {
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<HTMLElement>(
'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<HTMLElement>(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 (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.12 }}
data-overlay
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-[2px]"
onMouseDown={() => void requestClose()}
>
<motion.div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
initial={{ opacity: 0, scale: 0.97, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 340, damping: 28 }}
onMouseDown={(e) => 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 */}
<header className="flex shrink-0 items-start justify-between gap-4 border-b border-border px-5 py-3.5">
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-ink">{title}</h2>
{subtitle && <div className="mt-0.5 text-xs text-muted">{subtitle}</div>}
</div>
<IconButton label="Fermer" size="sm" icon={<X size={18} />} onClick={() => void requestClose()} />
</header>
{/* Corps défilant */}
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">{children}</div>
{/* Pied fixe : le bouton principal reste TOUJOURS visible */}
{footer && (
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-border bg-panel px-5 py-3">
{footer}
</footer>
)}
</motion.div>
</motion.div>
)
}
/**
* 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 ? (
<Button
variant="quiet"
icon={<Trash2 size={16} />}
onClick={onDelete}
className="text-rose-600 hover:bg-rose-500/10 hover:text-rose-600"
>
{deleteLabel}
</Button>
) : (
<span />
)}
<div className="flex items-center gap-2">
{onCancel && (
<Button variant="quiet" onClick={onCancel}>
{cancelLabel}
</Button>
)}
{children}
</div>
</>
)
}