195 lines
6.3 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|