trololo

Créer des modales avec React

Après avoir exploré les différences entre une modale et une boîte de dialogue dans notre précédent article, il est temps de plonger dans la pratique. 🐱‍👤

Implémentation d'une modale en React

Exemple de la modale que nous allons développer dans cet article :

Code de la modale, celui-ci sera détaillé par la suite :

//  Modal.tsx
function Modal({children, isOpen, handleClose}) {
    // 👇 L'usage de cet ref va particulièrement nous intéresser 
    const dialogRef = useRef<HTMLDialogElement>(null);

    const close = () => {
        dialogRef.current?.close();
    };

    useEffect(() => {
        const dialog = dialogRef.current;
        if (isOpen && !dialogRef.current?.open) {
            dialog?.showModal(); // 👈 usage propre à l'élément <dialog>
        } else {
            dialog?.close(); // 👈 usage propre à l'élément <dialog>
        }
    }, [isOpen]);

    return (
        <dialog
            ref={dialogRef}
            // 👇 capture l'élément 'close' et mise à jour de l'état du composant
            onClose={handleClose}
        >
            {children}

            <button 
                type="button" 
                onClick={close} 
                title="close modal" 
                aria-label="close modal"
                >
                close
            </button>
        </dialog>
    );
}
// App.tsx

function App() {
    const [isOpen, setIsOpen] = useState(false);
    const handleClose = (() => {
        setIsOpen(false);
    });

    const handleClick = (() => {
        setIsOpen(true);
    });

    return (
        <div>
        
            <Modal 
                isOpen={isOpen} 
                handleClose={handleClose}
            >
                {/* Le contenu de la modale */}
                <header>
                    <h1>Hello world</h1>
                    <small>pour être original...</small>
                </header>
            </Modal>
            
            
            <button
                type="button"
                onClick={handleClick}
            >
                Open modal
            </button>
        
        
            <p>{/* le petit Lorem Ipsum qui va bien 👌 */}</p>
            
            
        </div>
    );
}

Utilisation de l'élément<dialog> et useRef

L'élément <dialog> est une balise HTML permettant de créer des boîtes de dialogue modales avec une facilité déconcertante.

Il faut noter que cette balise bénéficie en outre d'un excellent support par les navigateurs modernes.

Pour créer une modale, il suffit d'encadrer le contenu de la modale avec le tag dialog.

<dialog>
    <p>Contenu de la modale</p>
</dialog>

Par défaut, la modale n'est pas visible, pour modifier son état le navigateur met quelques fonctions à notre disposition :

dialog.show(); // affiche l'élément comme une boîte de dialogue
dialog.showModal(); // affiche l'élément comme une modale
dialog.close(); // masque l'élément quelque soit s a nature

J'utilise alternativement les termes de "modale" et de "boîte de dialogue". Si besoin, cet article vous aidera à comprendre les différences entre boîte de dialogue et modale !

Nous utilisons useRef pour conserver une référence vers l'élément du DOM afin d'invoquer directement ces méthodes.

Contrôle de l'état d'ouverture / fermeture de la modale

La responsabilité relative à l'état d'affichage de la modale est partagée entre le composant Modal et son parent App.

Lorsque la propriété isOpen change, cela déclenche le hook useEffect lequel force l'élément dialog à rentrer dans l'état souhaité en invoquant soit la méthode showModal ou close.

Fausse bonne idée : gérer l'ouverture via css

Contrairement à d'autres solutions, ajouter ou retirer des classes css pour modifier l'affichage de la modale provoque des résultats assez mitigés.

Comme nous le verrons plus loin, la dialog embarque avec elle toute une logique d'accessibilité. Pour que le navigateur la gère au mieux, il est recommandé d'utiliser ces méthodes plutôt que de bricoler quelque chose de son côté 😁.

Synchroniser le DOM avec le contexte React

Si dialog.close() provoque la fermeture de la modale, cela ne concerne que la logique DOM. Pour synchroniser l'état de vos composants React, la solution consiste à écouter l'événement close émis par la dialog lors de sa fermeture.

<dialog
    ref={dialogRef}
    // 👇 capture l'élément 'close' et invoque handleClose, lequel a pour responsabilité de mettre l'état du composant à jour
    onClose={handleClose}
>
    {/*...*/}
</dialog>

Donner du style à sa modale 😎

Deux choses intéressantes à noter :

  • Le pseudo élément ::backdrop : il représente le layer séparant le reste de la page de la modale. Ce pseudo élément présente l'avantage de lutter contre le fléau des DOM trop complexes.
  • La propriété css backdrop-filter : elle permet d'appliquer un filtre... sur le backdrop. 🤯
dialog::backdrop {
    background: rgba(50, 20, 100, 0.2);
    backdrop-filter: blur(2px);
}

Amélioration : Bloquer le scroll en arrière-plan

🛑 Stop ! Pas besoin de JS !

Si votre premier réflexe est de tenter de développer des scripts complexes pour bloquer les événements du navigateur... et bien vous commettriez la même erreur que moi ! 😱

Après des heures à développer un script robuste, j'ai finalement tout supprimé (sans regret !) en découvrant la ligne suivante :

html:has(dialog[open]) {
    overflow: hidden;
}

Tout est là.

Support de la fonctionnalité "click outside"

Implémentation

// Modal.tsx

//...
 function handleClickOutside(event) {
        if (!dialogRef.current) {
            return;
        }
        
        const box = dialogRef.current?.getBoundingClientRect();
        // On calcule si le curseur est à l'extérieur de la boîte englobante de la modale
        if (
            event.pageX < box.left ||
            event.pageX > box.right ||
            event.pageY < (box.top + window.scrollY) ||
            event.pageY > (box.bottom + window.scrollY)
        ) {
            close();
        }
    }
    // ...

    return (
        <dialog 
            ref={dialogRef} 
            onClose={handleClose}
            // 👇 On ajoute le handler "click outside" directement sur l'élément dialog
            onClick={handleClickOutside} 
        >
            // ...
        </dialog>
    );

Brancher l'écouteur "click-outside" sur l'élément dialog

Non, non, ce n'est pas une erreur : le "click outside" est bel et bien déclenché lorsqu'on clique sur la dialog 🙃.

En effet, le pseudo élément ::backdrop recouvre l'intégralité de la page, et comme il n'est pas possible de sélectionner des pseudos élément via l'api DOM, le navigateur estime que l'événement click doit être envoyé à l'élément dialog.

C'est pourquoi on écrit un brin de JS dans handleClickOutside pour vérifier si la souris se trouve réellement sur notre élément ou pas.

Augmenter la réutilisabilité avec un custom hook

Implémenter la logique de mise à jour de l'état en fonction de l'état d'ouverture de la modale peut vite être répétitif et agaçant.

Bonne nouvelle, React étant fait pour être agréable à utiliser, une solution à notre problème existe : les custom hooks.

function useModal() {
    const [isOpen, setIsOpen] = useState(false);
    const closeModal = (() => {
        setIsOpen(false);
    });

    const openModal = (() => {
        setIsOpen(true);
    });

    return {
        isOpen,
        closeModal,
        openModal
    };
}
function App() {
    const { isOpen, openModal, closeModal } = useModal();

    return (
        <div>
            <Modal 
                isOpen={isOpen}
                // 👇 on remplace handleClose par closeModal
                handleClose={closeModal}
            >
                {/* Le contenu de la modale */}
                <header>
                    <h1>Hello world</h1>
                    <small>pour être original...</small>
                </header>
            </Modal>
            
            
            <button
                type="button"
                // 👇 on remplace handleClick par openModal
                onClick={openModal}
            >
                Open modal
            </button>
            // ...
        </div>
    );
}

🛠 Précisions techniques, détails et conseils d'accessibilité

La balise <dialog> permet de créer très facilement aussi bien une boîte de dialogue classique, qu'une modale, le tout en résolvant plusieurs contraintes d'accessibilités pour nos.

Par défaut, elle se voit attribuer le rôle aria (aria-role) dialog.

Les différences concernant l'accessibilité seront surtout marquées par l'emploi de show ou de showModale, c'est-à-dire selon le type de widget souhaité.

Boîte de dialogue classique

  • S'ouvre avec la méthode show.
  • Ne gère pas automatiquement le contenu inerte.
    👉aria-hidden ou inert permettra d'indiquer aux outils d'assistance qu'un contenu n'est plus visible / utilisable.
  • Propriété aria-modal="false" (implicite et par défaut) indique que l'élément n'est pas modal.
  • Ne se ferme pas automatiquement lorsqu'on appuie sur Escape. De plus, si l'on souhaite implémenter ce type de comportement, bien penser à ne fermer que la dernière boîte de dialogue lorsqu'on appuie sur Escape

Les "vraies" modales

  • S'ouvre avec la méthode showModal.
  • Le contenu situé à l'extérieur de la modale est automatiquement considéré comme inerte.
  • Propriété aria-modal="true" ajoutée implicitement à l'élément.
    👉 Combinée aux rôles dialog et alertdialog, elle rend inutile l'utilisation de aria-hidden sur le contenu extérieur de la modale car aria-modal indique aux technologies d'assistance que ce contenu est inerte.
  • Se ferme automatiquement lorsqu'on appuie sur Escape.

Conclusion :

L'élément dialog offre une alternative accessible et légère pour la création de modale. Facilement adaptable, elle constitue à mes yeux une bonne approche contrairement aux grosses librairies qui, en présentant quantité de fonctionnalités, pénalisent finalement la page et l'application d'un code trop complexe et d'un DOM trop large, sans parler des soucis de maintenabilité sur le long terme lorsqu'on a trop de dépendances externes.