Modal

←→

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.

src/components/Modal.tsx
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émentRôle
1FenêtreL’élément dialog, ouvert en modal (showModal) ; titré par son h2 via aria-labelledby.
2TitreLe h2 de l’en-tête, repris comme nom accessible de la fenêtre.
3Bouton FermerLa croix, nommée « Fermer » ; joue l’animation de sortie avant de fermer.
4ContenuLes enfants, ou une fonction qui reçoit close pour fermer depuis un bouton du contenu avec la même sortie animée.

Guidelines

  • MUST
    Donner un title court qui dit ce que contient la fenêtre ; c’est son nom pour les lecteurs d’écran.
  • SHOULD
    Réserver la variante small aux questions et confirmations ; un contenu à lire (sources, méthode) prend la taille normale.
  • SHOULD
    Fermer depuis un bouton du contenu avec la fonction close reçue en enfant, pour garder l’animation de sortie.
  • MUST NOT
    Ouvrir une fenêtre par-dessus une autre.

Clavier

ToucheEffet
ÉchapFerme la fenêtre, avec l’animation de sortie.
TabReste dans la fenêtre tant qu’elle est ouverte (focus piégé par le navigateur).

Référence de l’API

PropTypeDéfaut
titlestringrequis
onClose() => voidrequis
smallboolean—
childrenReactNode | ((close: () => void) => ReactNode)requis