HTML : details

J'ai développé en 2007, au boulot, un composant javascript appelé « InfoBox » qui fait ce qui est aujourd'hui proposé par l'élément details dans les spécifications HTML 5 (et même un peu plus). Cet élément n'est encore implémenté que par très peu de navigateurs web. Voici quelques pistes pour l'utiliser / le simuler.

Présentation

Les spécifications

Voyons d'abord ce que dit le brouillon des recommandations HTML 5 à propos de l'élément details :

  • Il s'agit d'un élément de divulgation (disclosure widget) qui permet de fournir une information complémentaire en se dépliant.
  • Il peut contenir des éléments summary dont seul le premier est affiché.
  • S'il ne possède pas de summary, les navigateurs web doivent en afficher / générer un automatiquement.
  • Le plier / déplier du contenu est marqué par la propriété booléenne open ; le navigateur web doit fournir à l'utilisateur un moyen d'effectuer cette opération.

Voilà pour la théorie. Pour la pratique, prenons un exemple simple qui nous servira de base de travail par la suite :

<details>
    <summary>Plus de détails</summary>
    <p>Ce paragraphe fournit un détail supplémentaire.</p>
    <p>Ce second paragraphe fournit autre détail.</p>
</details>
Exemple simple d'utilisation de l'élément details.

Avec ce code, nous devrions voir affiché dans le document web uniquement le texte du summary, le reste étant masqué. Par une interaction quelconque avec le document (clic sur l'élément par exemple), l'utilisateur pourrait afficher / masquer les deux paragraphes. Et dans les faits ?

Plus de détails

Ce paragraphe fournit un détail supplémentaire.

Ce second paragraphe fournit un autre détail.

Rendu original d'un élément details.

Si vous visualisez cette page avec Firefox <= 19, Opera <= 12.12 ou Internet Explorer <= 10, vous verrez en fait l'intégralité du contenu de l'élément affiché. Seul Chromium, à partir de la version 14, semble appliquer un rendu en accord avec les recommandations W3C.

Pourquoi cet élément ?

Il n'est pas question ici de discuter du bien fondé de l'élément details : les composants de ce type sont assez courant dans les librairies graphiques pour application de bureau, d'où leur simulation dans les applications web à l'aide de CSS et de javascript. Comme le HTML 5 est très orienté « application web », et qu'il est toujours préférable - pour des questions de performances principalement - d'utiliser une fonctionnalité native...

Le comportement plier / déplier pourrait aussi être utile ailleurs ; comme sur un élément section par exemple, avec son header qui aurait le même comportement que le summary quand on clique dessus : afficher / masquer le reste du bloc ; ou bien un comportement plier / déplier pour des listes hiérarchisées : cliquer sur un item plie / déplie ses sous-items.

D'aucuns diraient que le web, c'est avant tout du contenu avant d'être de l'applicatif ; pas faux. Toujours est-il que l'on peut envisager de nombreux usages pour l'élément details dans un cadre « classique », comme par exemple dans des critiques de films / romans : on voit souvent l'auteur de la critique avertir le lecteur que le texte qui va suivre dévoile une partie de l'intrigue ; on pourrait dés lors imaginer structurer le texte avec des éléments details de telle sorte que ces parties soient masquées par défaut.

Encore faut-il que l'élément soit fontionnel, ce qui n'est aujourd'hui pas le cas pour la plupart des navigateurs web. Nous allons voir comment corriger cela.

Implémentation javascript

Il ne serait pas judicieux de publier ici le code du composant InfoBox développé au boulot il y a quelques années, non pas que cela me soit interdit (il est sous licence libre), mais c'est un code (1) qui ne se base pas sur details, (2) qui est un peu trop spécifique à la libraire javascript/java sur laquelle il repose et (3) qui possède plus de fonctionnalités que l'élément HTML5. On peut cependant et très simplement penser un code javascript qui obéirait à quelques règles de bases :

  • Afficher / masquer le contenu de details quand on clique sur son premier enfant.
  • Appliquer notre implémentation uniquement si l'élément details n'est pas géré nativement par le navigateur web.
  • Laisser l'ensemble de l'élément visible lorsque le javascript n'est pas actif.

Nous pouvons écrire cette logique d'affichage / masquage du contenu de l'élément dans un objet dédié afin de pouvoir éventuellement l'utiliser ailleurs.


const RE_HEADINGS = /^h\d$/;
    
const RE_PARENT = /^(article|section|div)$/;
    
const CLASS = 'expander';
    
const CLASS_TARGET = 'expander-target';
    
/**
 *  Flag to know if 'details' elements are supported by the browser.
 *
 *  @private
 *  @type Boolean
 */
const detailsSupported = ('open' in document.createElement('details'));

// /**
//  *  Listen to mutation events.
//  *
//  * @protected
//  * @param {MutationRecord[]} mutations Liste de mutations
//  * @returns {void}
//  */
// const onPropertyMutation = mutations => render(mutations[0].target);

const onHashChange = () => {
    const id = globalThis.location.hash ? globalThis.location.hash.substring(1) : null;
    const el = id ? globalThis.document.getElementById(id) : null;
    if (el) {
        if (el.classList.contains(CLASS)) {
            el.open = true;
        } else if (el.parentElement && el.parentElement.classList.contains(CLASS)) {
            el.parentElement.open = true;
        }
    }
};

/**
 *  Listen click events on element to open / close.
 *
 *  @protected
 *  @param {Event} e Object DOM Event
 */
const onTargetClick = function (e) {
    let target = /** @type {HTMLElement} */ (e.target),
        targetName = target.nodeName.toLowerCase();
    if (target.htmlFor || targetName === 'input' || targetName === 'button') {
        // Ne pas agir sur un label avec un for, un input, un button
        return;
    }
    if (targetName === 'a' && target !== e.currentTarget) {
        // Ne pas agir si c'est un lien qui n'est pas l'élément cible.
        return;
    }
    e.preventDefault();
    toggle(e.currentTarget.parentNode);
};

// /**
//  *  Mutation observer.
//  *
//  *  @private
//  *  @type MutationObserver
//  */
// const observer = new MutationObserver(onPropertyMutation);

// /**
//  *  Configuration de l'observateur de mutations.
//  *
//  *  @private
//  *  @type MutationObserverInit
//  */
// const observerConfig = {
//     attributes: true,
//     attributeFilter: ['open'],
// };

/**
 *  Descripteur de la propriété 'open' pour les details.
 *
 *  @private
 *  @type Object
 */
const openProperty = {
    enumerable: true,
    configurable: false,
    get: function () {
        return this.hasAttribute('open') ? true : undefined;
    },
    set: function (newValue) {
        if (newValue === this.open) {
            return;
        }
        if (newValue) {
            this.setAttribute('open', 'open');
            this.setAttribute('aria-expanded', 'true');
        } else {
            this.removeAttribute('open');
            this.setAttribute('aria-expanded', 'false');
        }
        render(this);
    },
};

/**
 *  Méthode de switch de l'état ouvert / fermé d'un élément.
 * 
 * @private
 * @param {HTMLElement} el Elément
 * @returns {boolean}
 */
const toggle = el => el.open = !el.open;

/**
 * Rendu graphique de l'état ouvert / fermé d'un élément.
 *
 * @private
 * @param {HTMLElement} el Élément
 */
const render = function (el) {
    // el.style.height = el.getAttribute('data-height-' + (el.open ? 'open' : 'close')) + 'px';
    el.style.height = el.open ? '' : `${el.dataset.heightClose}px`;
};

/**
 * @param {HTMLElement} el 
 * @param {HTMLElement} target 
 * @returns {void}
 */
const init = function (el, target) {
    
    const nodeName = el.nodeName.toLowerCase();
        
    if ((nodeName === 'details' && detailsSupported) || el.classList.contains('nojs') 
            || el.getAttribute('data-expander-initialized') === 'true') {
        return;
    }
        
    // Manage target
    if (!target) {
        // first target with offset
        let n = el.children.length,
            i = 0;
        for (i = 0; i < n; i += 1) {
            if (el.children[i].offsetHeight) {
                target = el.children[i];
                break;
            }
        }
    }
    if (!target) {
        console.warn('[rnb/ui/expander] No target for click'); // eslint-disable-line no-console
        console.log(el); // eslint-disable-line no-console
        return;
    }
        
    // Create sumlamry for details if it does not exist
    if (nodeName === 'details' && target.nodeName.toLowerCase() !== 'summary') {
        target = document.createElement('summary');
        target.appendChild(document.createTextNode('Details'));
        el.insertBefore(target, el.firstChild);
    }
    target.classList.add(CLASS_TARGET);

    // Find heights
    const styles = globalThis.getComputedStyle(el, null);
    const pTop = styles.paddingTop;
    const pBottom = styles.paddingBottom;
    let hOpen = el.clientHeight;
    if (pTop) {
        hOpen -= parseInt(pTop, 10);
    }
    if (pBottom) {
        hOpen -= parseInt(pBottom, 10);
    }
        
    if (!el.hasAttribute('data-height-close')) {
        el.setAttribute('data-height-close', target.offsetHeight);
    }
    el.setAttribute('data-height-open', hOpen);
    el.setAttribute('data-expander-initialized', 'true');
    el.classList.add(CLASS);
        
    // Définir la propriété 'open' mappée sur l'attribut 'open'
    Object.defineProperty(el, 'open', openProperty);
        
    let state = el.getAttribute('aria-expanded') === 'true';
    el.open = state;

    // observer.observe(el, observerConfig);
        
    target.addEventListener('click', onTargetClick, false);
};

const expanders = {
    /**
     * Manage a specific element
     *
     * @param {HTMLElement} el
     * @param {HTMLElement} [target]
     */
    initElement (el, target) {
        init(el, target);
    },

    /**
     * Initialize all available components in the current document.
     */
    initDocument () {
        let listen = false;
        document.querySelectorAll('detail, .expander').forEach(element => {
            listen = true;
            /** @type {HTMLElement} */
            let target = null;
            // Cas particulier si class portée par un titre et parent est un
            // article, section, div, alors expander est ce parent
            if (element.nodeName.toLowerCase().search(RE_HEADINGS) > -1
                    && element.parentNode.nodeName.toLowerCase().search(RE_PARENT) > -1) {
                target = element;
                target.classList.remove('expander');
                element = element.parentNode;
                element.classList.add('expander');
            }
            init(element, target);
        });
        if (listen && globalThis instanceof EventTarget) {
            globalThis.removeEventListener('hashchange', onHashChange);
            globalThis.addEventListener('hashchange', onHashChange);
            onHashChange();
        }
    },
};

export default expanders;
Gestion d'affichage / masquage d'un élément.

Ensuite, il ne reste plus qu'à gérer les éléments details au chargement de la page.

Implémentation CSS

/*

expander
======================================

Gérer un comportement plier / déplier.

example:
    
    <!-- Avec l'attribut 'aria-expanded' à 'true', 
    tous les enfants de l'enfant sont affichés. -->
    
    <div class="expander" aria-expanded="true">
        <p>First child</p>
        <p>Content paragraph One.</p>
        <p>Content paragraph two.</p>
    </div>
    
    <!-- Avec l'attribut 'aria-expanded' à 'false',
    les enfants suivant le premier enfant seront masqué. -->
    
    <div class="expander" aria-expanded="false">
        <p>First child</p>
        <p>Content paragraph One.</p>
        <p>Content paragraph two.</p>
    </div>
*/

.expander {
    transition: height 0.2s;
    overflow: hidden;
}
.expander[data-expander-initialized="true"] {
    
}

/* Icône d'état plier/ déplier pour le premier enfant  */
.expander > .expander-target:before {
    content: "\25b6";
    font-family: var(--ff-sans-serif);
    font-size: 1rem;
    display: inline-block;
    width: 1rem;
    margin: 0 0.25rem 0 -1.25rem;
    text-align: center;
    vertical-align: text-top;
}

/* Type d'icône du premier enfant pour un expander ouvert. */
.expander[open] > .expander-target:before,
.expander[aria-expanded="true"] > .expander-target:before {
    content: "\25bc";
}

/* Taille limitée pour un expander fermé. */
details.expander,
.expander[aria-expanded="false"] {
    height: 1.5em;
}

/* Enfants invisibles pour un expander fermé. */
details.expander > *,
.expander[aria-expanded="false"] > * {
    visibility: hidden;
    transition: visibility 0.2s;
}
/* Enfants visible pour un expander avec attribut open */
.expander[open] > * {
    visibility: visible;
}

/* Forcer la visibilité pour le premier enfant. */
.expander > .expander-target {
    cursor: pointer;
    visibility: visible;
    padding-left: 1em;
    margin-top: 0;
}
.expander > dd {
    padding: 0;
    margin: 0;
}
Code CSS associé à la gestion des details en javascript.

Résultat et discussions

Plus de détails

Ce paragraphe fournit un détail supplémentaire.

Ce second paragraphe fournit un autre détail.

Afficher le contenu de details en cliquant dessus.
  • Le code javascript ci-dessus est très basique et très simplifiée ; il nécessiterait quelques adaptations pour se retrouver en production et être utilisable avec tous les navigateurs web. L'exemple est cependant fonctionnel sous Firefox >= 5, Opera >= 11.11 et Internet Explorer >= 9.
  • On pourrait penser à augmenter les fonctionnalités de l'élément comme gérer l'événement focus et permettre par exemple d'utiliser la touche enter ou space pour ouvrir / fermer l'élément.
  • On pourrait enfin émettre des évenements « open » et « close » lors des actions sur l'élément.

Ressources et références

chapter
4.11.1 The details element
Titre
Styling <details>
Auteurs
  • Lachlan Hunt
Éditeur
Public mailing list for the WHAT working group
Date
Commentaire

Pistes de reflexion sur l'implémentation de details dans Opera.

Titre
HTML5 details element, built-in and bolt-on accessibility
Auteurs
  • Bruce Lawson
Éditeur
brucelawson.co.uk
Date

°°ref°° art.dom-attributes-properties

°°ref°° art.obj-js-vs-dom

Historique

2013-10-05
  • add: Fonctionnalités supplémentaires possibles (événements).
  • upd: Seconde implémentation avec couple propriété / attribut.
2013-08-23
  • upd: Extraction de la logique 'expander' dans un objet extérieur.
2013-07-02
  • fix: Suppression des dépendances à rnb-js.
2013-01-05
  • fix: Suppression des expérimentations CSS.
  • upd: Lien vers le code original utilisé dans rnb-js.
2012-04-09
  • upd: Evolution: Icône plier / déplier avec le pseudo-élément before plutôt qu'une image de fond.
2012-02-11
  • upd: Jouer sur la taille et l'overflow de l'élément plutôt que la visibilité des enfants pour afficher / masquer le contenu.
2011-06-25
  • add: Création de l'article.