Fenêtre centrée au-dessus de la page, construite sur l’élément natif dialog ; base de toutes les fenêtres de Pathou (sources, partage, confirmation).
stableÀ valider
// Titre : Fenêtre ouverte
import { useState } from 'react';
import Modal from '@pathou/components/Modal';
export default function Exemple() {
const [open, setOpen] = useState(true);
return (
<div style={{ minHeight: 360 }}>
<button type="button" onClick={() => setOpen(true)}>
Ouvrir la fenêtre
</button>
{open && (
<Modal title="Comment Pathou décide" onClose={() => setOpen(false)}>
<p className="modal-lead">Le verdict combine la pente, le revêtement et la technicité de chaque portion, pour ta pratique et ton niveau.</p>
</Modal>
)}
</div>
);
}
Installation
Copie le fichier dans ton projet, ainsi que les tokens qu’il utilise.
import { useEffect, useRef, type ReactNode } from 'react';
/**
* Fenêtre centrée, basée sur <dialog> : focus piégé, Échap et fond assombri gérés par le navigateur.
* Fermée par ✕, Échap ou un clic sur le fond. Entrée et sortie animées par les attributs
* data-starting-style / data-ending-style (voir .modal dans styles.css).
* `children` peut être une fonction qui reçoit `close` : pour fermer depuis un bouton du contenu avec la même sortie animée.
* `small` : fenêtre étroite, pour une simple question.
*/
export default function Modal({ title, onClose, small, children }: { title: string; onClose: () => void; small?: boolean; children: ReactNode | ((close: () => void) => ReactNode) }) {
const ref = useRef<HTMLDialogElement>(null);
const closing = useRef(false);
// (pas de close() au démontage : il déclencherait onClose, et le retrait du DOM suffit)
useEffect(() => {
const d = ref.current!;
if (d.open) return;
d.setAttribute('data-starting-style', '');
d.showModal();
// calcule le style de départ (transparent, réduit) avant de le retirer, sinon la transition ne part pas
void d.offsetWidth;
d.removeAttribute('data-starting-style');
}, []);
/** Joue la sortie puis ferme : close() déclenche onClose, qui démonte la fenêtre. */
const close = () => {
const d = ref.current;
if (!d || closing.current) return;
closing.current = true;
d.setAttribute('data-ending-style', '');
void Promise.allSettled(d.getAnimations().map((a) => a.finished)).then(() => d.close());
};
const titleId = `modal-${title.replace(/\W+/g, '-')}`;
return (
<dialog
ref={ref}
className={small ? 'modal modal-small' : 'modal'}
aria-labelledby={titleId}
onClose={onClose}
onCancel={(e) => {
e.preventDefault();
close();
}}
onKeyDown={(e) => e.stopPropagation()}
onClick={(e) => e.target === ref.current && close()}
>
<div className="modal-body">
<div className="map-menu-head">
<h2 id={titleId}>{title}</h2>
<button className="map-menu-close" onClick={close} aria-label="Fermer">
<svg viewBox="0 0 20 20" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true">
<path d="M5 5l10 10M15 5L5 15" />
</svg>
</button>
</div>
{typeof children === 'function' ? children(close) : children}
</div>
</dialog>
);
}
Tokens : --panel --text --chip --ease-out
Utilisation
import Modal from './components/Modal';
{open && (
<Modal title="Comment Pathou décide" onClose={() => setOpen(false)}>
<p className="modal-lead">…</p>
</Modal>
)}Exemples
Petite fenêtre, fermée depuis le contenu
// Titre : Petite fenêtre, fermée depuis le contenu
import { useState } from 'react';
import Modal from '@pathou/components/Modal';
export default function Exemple() {
const [open, setOpen] = useState(true);
return (
<div style={{ minHeight: 300 }}>
<button type="button" onClick={() => setOpen(true)}>
Ouvrir la fenêtre
</button>
{open && (
<Modal title="Effacer les réglages ?" onClose={() => setOpen(false)} small>
{(close) => (
<>
<p className="modal-lead">La pratique et le niveau reviendront à leurs valeurs par défaut.</p>
<div className="modal-actions">
<button type="button" className="primary" onClick={close}>
Compris
</button>
</div>
</>
)}
</Modal>
)}
</div>
);
}
Anatomie
| # | Élément | Rôle |
|---|---|---|
| 1 | Fenêtre | L’élément dialog, ouvert en modal (showModal) ; titré par son h2 via aria-labelledby. |
| 2 | Titre | Le h2 de l’en-tête, repris comme nom accessible de la fenêtre. |
| 3 | Bouton Fermer | La croix, nommée « Fermer » ; joue l’animation de sortie avant de fermer. |
| 4 | Contenu | Les enfants, ou une fonction qui reçoit close pour fermer depuis un bouton du contenu avec la même sortie animée. |
Guidelines
- MUSTDonner un
titlecourt qui dit ce que contient la fenêtre ; c’est son nom pour les lecteurs d’écran. - SHOULDRéserver la variante
smallaux questions et confirmations ; un contenu à lire (sources, méthode) prend la taille normale. - SHOULDFermer depuis un bouton du contenu avec la fonction
closereçue en enfant, pour garder l’animation de sortie. - MUST NOTOuvrir une fenêtre par-dessus une autre.
Clavier
| Touche | Effet |
|---|---|
| Échap | Ferme la fenêtre, avec l’animation de sortie. |
| Tab | Reste dans la fenêtre tant qu’elle est ouverte (focus piégé par le navigateur). |
Référence de l’API
| Prop | Type | Défaut |
|---|---|---|
title | string | requis |
onClose | () => void | requis |
small | boolean | — |
children | ReactNode | ((close: () => void) => ReactNode) | requis |