Composants HowTo – Guides d'utilisation

Résumé

<howto-tabs> limiter le contenu visible en le divisant en plusieurs panneaux. Un seul panneau est visible à la fois, tandis que tous les onglets correspondants sont toujours visibles. Pour passer d'un panneau à un autre, vous devez sélectionner l'onglet correspondant.

L'utilisateur peut modifier la sélection de l'onglet actif en cliquant ou en utilisant les touches fléchées.

Si JavaScript est désactivé, tous les panneaux sont affichés entrelacés avec les onglets respectifs. Les onglets fonctionnent désormais comme des en-têtes.

Référence

Démo

Voir une démonstration en direct sur GitHub

Exemple d'utilisation

<style>
  howto-tab {
    border: 1px solid black;
    padding: 20px;
  }
  howto-panel {
    padding: 20px;
    background-color: lightgray;
  }
  howto-tab[selected] {
    background-color: bisque;
  }

Si JavaScript ne s'exécute pas, l'élément ne correspondra pas à :defined. Dans ce cas, ce style ajoute un espacement entre les onglets et le panneau précédent.

  howto-tabs:not(:defined), howto-tab:not(:defined), howto-panel:not(:defined) {
    display: block;
  }
</style>

<howto-tabs>
  <howto-tab role="heading" slot="tab">Tab 1</howto-tab>
  <howto-panel role="region" slot="panel">Content 1</howto-panel>
  <howto-tab role="heading" slot="tab">Tab 2</howto-tab>
  <howto-panel role="region" slot="panel">Content 2</howto-panel>
  <howto-tab role="heading" slot="tab">Tab 3</howto-tab>
  <howto-panel role="region" slot="panel">Content 3</howto-panel>
</howto-tabs>

Code

(function() {

Définissez des codes de touche pour faciliter la gestion des événements de clavier.

  const KEYCODE = {
    DOWN: 40,
    LEFT: 37,
    RIGHT: 39,
    UP: 38,
    HOME: 36,
    END: 35,
  };

Pour éviter d'appeler l'analyseur avec .innerHTML pour chaque nouvelle instance, un modèle pour le contenu du Shadow DOM est partagé par toutes les instances <howto-tabs>.

  const template = document.createElement('template');
  template.innerHTML = `
    <style>
      :host {
        display: flex;
        flex-wrap: wrap;
      }
      ::slotted(howto-panel) {
        flex-basis: 100%;
      }
    </style>
    <slot name="tab"></slot>
    <slot name="panel"></slot>
  `;

HowtoTabs est un élément de conteneur pour les onglets et les panneaux.

Tous les enfants de <howto-tabs> doivent être <howto-tab> ou <howto-tabpanel>. Cet élément est sans état, ce qui signifie qu'aucune valeur n'est mise en cache et qu'il change donc pendant l'exécution.

  class HowtoTabs extends HTMLElement {
    constructor() {
      super();

Les gestionnaires d'événements qui ne sont pas associés à cet élément doivent être liés s'ils ont besoin d'accéder à this.

      this._onSlotChange = this._onSlotChange.bind(this);

Pour une amélioration progressive, le balisage doit alterner entre les onglets et les panneaux. Les éléments qui réorganisent leurs enfants ont tendance à ne pas bien fonctionner avec les frameworks. Le Shadow DOM est utilisé pour réorganiser les éléments à l'aide de slots.

      this.attachShadow({ mode: 'open' });

Importez le modèle partagé pour créer les emplacements des onglets et des panneaux.

      this.shadowRoot.appendChild(template.content.cloneNode(true));

      this._tabSlot = this.shadowRoot.querySelector('slot[name=tab]');
      this._panelSlot = this.shadowRoot.querySelector('slot[name=panel]');

Cet élément doit réagir aux nouveaux enfants, car il lie sémantiquement les onglets et les panneaux à l'aide de aria-labelledby et aria-controls. Les nouveaux enfants seront placés automatiquement et déclencheront l'événement slotchange. MutationObserver n'est donc pas nécessaire.

      this._tabSlot.addEventListener('slotchange', this._onSlotChange);
      this._panelSlot.addEventListener('slotchange', this._onSlotChange);
    }

connectedCallback() regroupe les onglets et les panneaux en les réorganisant et s'assurent qu'un seul onglet est actif.

    connectedCallback() {

L'élément doit gérer manuellement les événements d'entrée pour permettre le basculement à l'aide des touches fléchées et des touches Accueil/Fin.

      this.addEventListener('keydown', this._onKeyDown);
      this.addEventListener('click', this._onClick);

      if (!this.hasAttribute('role'))
        this.setAttribute('role', 'tablist');

Jusqu'à récemment, les événements slotchange ne se déclenchaient pas lorsqu'un élément était mis à niveau par l'analyseur. C'est pourquoi l'élément appelle le gestionnaire manuellement. Une fois que le nouveau comportement sera disponible dans tous les navigateurs, vous pourrez supprimer le code ci-dessous.

      Promise.all([
        customElements.whenDefined('howto-tab'),
        customElements.whenDefined('howto-panel'),
      ])
        .then(() => this._linkPanels());
    }

disconnectedCallback() supprime les écouteurs d'événements ajoutés par connectedCallback().

    disconnectedCallback() {
      this.removeEventListener('keydown', this._onKeyDown);
      this.removeEventListener('click', this._onClick);
    }

_onSlotChange() est appelé chaque fois qu'un élément est ajouté ou supprimé de l'un des emplacements DOM ombragés.

    _onSlotChange() {
      this._linkPanels();
    }

_linkPanels() associe les onglets à leurs panneaux adjacents à l'aide d'aria-controls et de aria-labelledby. De plus, la méthode garantit qu'un seul onglet est actif.

    _linkPanels() {
      const tabs = this._allTabs();

Attribuez à chaque panneau un attribut aria-labelledby faisant référence à l'onglet qui le contrôle.

      tabs.forEach((tab) => {
        const panel = tab.nextElementSibling;
        if (panel.tagName.toLowerCase() !== 'howto-panel') {
          console.error(`Tab #${tab.id} is not a` +
            `sibling of a <howto-panel>`);
          return;
        }

        tab.setAttribute('aria-controls', panel.id);
        panel.setAttribute('aria-labelledby', tab.id);
      });

L'élément vérifie si l'un des onglets a été marqué comme sélectionné. Sinon, le premier onglet est sélectionné.

      const selectedTab =
        tabs.find((tab) => tab.selected) || tabs[0];

Passez ensuite à l'onglet sélectionné. _selectTab() se charge de marquer tous les autres onglets comme désélectionnés et de masquer tous les autres panneaux.

      this._selectTab(selectedTab);
    }

_allPanels() renvoie tous les panneaux du panneau des onglets. Cette fonction peut mémoriser le résultat si les requêtes DOM deviennent un problème de performances. L'inconvénient de la mémorisation est que les onglets et les panneaux ajoutés dynamiquement ne seront pas gérés.

Il s'agit d'une méthode et non d'un getter, car un getter implique qu'il est peu coûteux à lire.

    _allPanels() {
      return Array.from(this.querySelectorAll('howto-panel'));
    }

_allTabs() renvoie tous les onglets du panneau d'onglets.

    _allTabs() {
      return Array.from(this.querySelectorAll('howto-tab'));
    }

_panelForTab() affiche le panneau contrôlé par l'onglet donné.

    _panelForTab(tab) {
      const panelId = tab.getAttribute('aria-controls');
      return this.querySelector(`#${panelId}`);
    }

_prevTab() renvoie l'onglet qui précède celui actuellement sélectionné, en revenant au premier lorsqu'il est atteint.

    _prevTab() {
      const tabs = this._allTabs();

Utilisez findIndex() pour trouver l'indice de l'élément actuellement sélectionné et soustraire un pour obtenir l'indice de l'élément précédent.

      let newIdx = tabs.findIndex((tab) => tab.selected) - 1;

Ajoutez tabs.length pour vous assurer que l'indice est un nombre positif et que le modulo est arrondi, si nécessaire.

      return tabs[(newIdx + tabs.length) % tabs.length];
    }

_firstTab() renvoie le premier onglet.

    _firstTab() {
      const tabs = this._allTabs();
      return tabs[0];
    }

_lastTab() renvoie le dernier onglet.

    _lastTab() {
      const tabs = this._allTabs();
      return tabs[tabs.length - 1];
    }

_nextTab() récupère l'onglet qui suit celui actuellement sélectionné, en effectuant un retour à la ligne lorsqu'il atteint le dernier onglet.

    _nextTab() {
      const tabs = this._allTabs();
      let newIdx = tabs.findIndex((tab) => tab.selected) + 1;
      return tabs[newIdx % tabs.length];
    }

reset() désélectionne tous les onglets et masque tous les panneaux.

    reset() {
      const tabs = this._allTabs();
      const panels = this._allPanels();

      tabs.forEach((tab) => tab.selected = false);
      panels.forEach((panel) => panel.hidden = true);
    }

_selectTab() marque l'onglet donné comme sélectionné. De plus, il affiche le panneau correspondant à l'onglet donné.

    _selectTab(newTab) {

Désélectionnez tous les onglets et masquez tous les panneaux.

      this.reset();

Obtenez le panneau auquel le newTab est associé.

      const newPanel = this._panelForTab(newTab);

Si ce panneau n'existe pas, arrêtez le processus.

      if (!newPanel)
        throw new Error(`No panel with id ${newPanelId}`);
      newTab.selected = true;
      newPanel.hidden = false;
      newTab.focus();
    }

_onKeyDown() gère les pressions sur les touches dans le panneau d'onglets.

    _onKeyDown(event) {

Si la pression de touche ne provient pas d'un élément d'onglet lui-même, il s'agit d'une pression de touche à l'intérieur d'un panneau ou dans un espace vide. Rien à faire.

      if (event.target.getAttribute('role') !== 'tab')
        return;

Ne gérez pas les raccourcis de modification généralement utilisés par les technologies d'assistance.

      if (event.altKey)
        return;

Le cas du commutateur déterminera quel onglet doit être marqué comme actif en fonction de la touche sur laquelle vous avez appuyé.

      let newTab;
      switch (event.keyCode) {
        case KEYCODE.LEFT:
        case KEYCODE.UP:
          newTab = this._prevTab();
          break;

        case KEYCODE.RIGHT:
        case KEYCODE.DOWN:
          newTab = this._nextTab();
          break;

        case KEYCODE.HOME:
          newTab = this._firstTab();
          break;

        case KEYCODE.END:
          newTab = this._lastTab();
          break;

Toute autre pression sur une touche est ignorée et renvoyée au navigateur.

        default:
          return;
      }

Certaines fonctionnalités natives du navigateur peuvent être liées aux touches fléchées, à la touche Accueil ou à la fin. L'élément appelle preventDefault() pour empêcher le navigateur d'effectuer des actions.

      event.preventDefault();

Sélectionnez le nouvel onglet déterminé dans le cas du commutateur.

      this._selectTab(newTab);
    }

_onClick() gère les clics dans le panneau des onglets.

    _onClick(event) {

Si le clic n'était pas ciblé sur un élément d'onglet lui-même, il s'agissait d'un clic dans un panneau ou sur un espace vide. Rien à faire.

      if (event.target.getAttribute('role') !== 'tab')
        return;

Toutefois, si l'élément se trouvait sur un onglet, sélectionnez cet onglet.

      this._selectTab(event.target);
    }
  }

  customElements.define('howto-tabs', HowtoTabs);

howtoTabCounter compte le nombre d'instances <howto-tab> créées. Ce nombre permet de générer de nouveaux ID uniques.

  let howtoTabCounter = 0;

HowtoTab est un onglet du panneau d'onglets <howto-tabs>. <howto-tab> doit toujours être utilisé avec role="heading" dans le balisage afin que la sémantique reste utilisable en cas d'échec de JavaScript.

Une <howto-tab> déclare à quel <howto-panel> elle appartient en utilisant l'ID de ce panneau comme valeur pour l'attribut aria-controls.

Un <howto-tab> génère automatiquement un ID unique si aucun n'est spécifié.

  class HowtoTab extends HTMLElement {

    static get observedAttributes() {
      return ['selected'];
    }

    constructor() {
      super();
    }

    connectedCallback() {

Si cette instruction est exécutée, JavaScript fonctionne et l'élément change de rôle pour devenir tab.

      this.setAttribute('role', 'tab');
      if (!this.id)
        this.id = `howto-tab-generated-${howtoTabCounter++}`;

Définissez un état initial bien défini.

      this.setAttribute('aria-selected', 'false');
      this.setAttribute('tabindex', -1);
      this._upgradeProperty('selected');
    }

Vérifiez si une propriété possède une valeur d'instance. Si tel est le cas, copiez la valeur et supprimez la propriété d'instance afin qu'elle ne masque pas le setter de la propriété de classe. Enfin, transmettez la valeur au setter de la propriété de classe afin qu'il puisse déclencher des effets secondaires. Cela permet de se prémunir contre les cas où, par exemple, un framework a ajouté l'élément à la page et défini une valeur sur l'une de ses propriétés, mais a chargé de manière paresseuse sa définition. Sans cette protection, cette propriété serait manquante dans l'élément mis à niveau, et la propriété d'instance empêcherait l'appel du setter de propriété de classe.

    _upgradeProperty(prop) {
      if (this.hasOwnProperty(prop)) {
        let value = this[prop];
        delete this[prop];
        this[prop] = value;
      }
    }

Les propriétés et leurs attributs correspondants doivent se mettre en miroir. À cet effet, le setter de propriété pour selected gère les valeurs vraies/fausses et les reflète dans l'état de l'attribut. Il est important de noter qu'aucun effet secondaire ne se produit dans le setter de propriété. Par exemple, le setter ne définit pas aria-selected. Au lieu de cela, cette tâche est effectuée dans le attributeChangedCallback. En règle générale, faites en sorte que les sétteurs de propriété soient très simples. Si la définition d'une propriété ou d'un attribut doit entraîner un effet secondaire (comme la définition d'un attribut ARIA correspondant), effectuez cette tâche dans attributeChangedCallback(). Cela évite d'avoir à gérer des scénarios de réentrée d'attributs/propriétés complexes.

    attributeChangedCallback() {
      const value = this.hasAttribute('selected');
      this.setAttribute('aria-selected', value);
      this.setAttribute('tabindex', value ? 0 : -1);
    }

    set selected(value) {
      value = Boolean(value);
      if (value)
        this.setAttribute('selected', '');
      else
        this.removeAttribute('selected');
    }

    get selected() {
      return this.hasAttribute('selected');
    }
  }

  customElements.define('howto-tab', HowtoTab);

  let howtoPanelCounter = 0;

HowtoPanel est un panneau pour un panneau d'onglets <howto-tabs>.

  class HowtoPanel extends HTMLElement {

    constructor() {
      super();
    }

    connectedCallback() {
      this.setAttribute('role', 'tabpanel');
      if (!this.id)
        this.id = `howto-panel-generated-${howtoPanelCounter++}`;
    }
  }

  customElements.define('howto-panel', HowtoPanel);
})();