Composants HowTo – Guides d'utilisation

Résumé

<howto-tabs> limite le contenu visible en le séparant en plusieurs panneaux. Un seul panneau est visible à la fois, tandis que tous les onglets correspondants sont toujours visibles. Pour passer d'un panneau à l'autre, l'onglet correspondant doit être sélectionné.

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

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

Reference

Démonstration

Voir la 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>

Coder

(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'elle est donc modifiée pendant le travail d'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 cadres. À la place, le Shadow DOM sert à réorganiser les éléments à l'aide d'emplacements.

      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 associe les onglets et le panneau de manière sémantique à l'aide de aria-labelledby et aria-controls. Les nouveaux enfants seront insérés automatiquement et déclencheront le changement d'emplacement. 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 réorganisant les onglets, et garantit qu'un seul onglet est actif.

    connectedCallback() {

L'élément doit gérer les événements de saisie manuelle pour pouvoir basculer à l'aide des touches fléchées et des options 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 manuellement le gestionnaire. 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 du Shadow DOM.

    _onSlotChange() {
      this._linkPanels();
    }

_linkPanels() établit un lien entre les onglets et leurs panneaux adjacents à l'aide des commandes aria et aria-labelledby. De plus, cette 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 des onglets ont été marqués comme sélectionnés. Sinon, le premier onglet est maintenant 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 d'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 de manière dynamique ne sont 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() renvoie 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é, et se retourne automatiquement lorsque le premier est atteint.

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

Utilisez findIndex() pour trouver l'indice de l'élément actuellement sélectionné et soustrait 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'index est un nombre positif et faire en sorte que le module s'encapsule 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() obtient l'onglet qui suit celui actuellement sélectionné, et se retourne automatiquement 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() marque tous les onglets comme désélectionnés 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é. En outre, elle 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 newTab est associé.

      const newPanel = this._panelForTab(newTab);

Si ce panneau n'existe pas, annulez.

      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 des onglets.

    _onKeyDown(event) {

Si l'appui sur la touche ne provenait pas d'un élément de tabulation lui-même, il s'agissait d'une pression sur une touche dans 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 switch-case détermine l'onglet à marquer 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;
      }

Le navigateur peut avoir une fonctionnalité native liée aux touches fléchées, à la page d'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 boîtier de commutation.

      this._selectTab(newTab);
    }

_onClick() gère les clics à l'intérieur du panneau des onglets.

    _onClick(event) {

Si le clic ne ciblait pas un élément de l'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;

En revanche, si c'était sur un élément d'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 est utilisé pour générer de nouveaux identifiants uniques.

  let howtoTabCounter = 0;

HowtoTab est un onglet d'un 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 le <howto-panel> auquel elle appartient en utilisant l'ID de ce panneau comme valeur pour l'attribut aria-controls.

Si aucun identifiant n'est spécifié, <howto-tab> génère automatiquement un identifiant unique.

  class HowtoTab extends HTMLElement {

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

    constructor() {
      super();
    }

    connectedCallback() {

Si cette exécution est exécutée, JavaScript fonctionne et l'élément change son rôle en 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é a une valeur d'instance. Si c'est le cas, copiez la valeur et supprimez la propriété d'instance afin d'éviter que le setter de la propriété de classe soit bloqué. Enfin, transmettez la valeur au setter de la propriété de classe afin qu'elle puisse déclencher tous les effets secondaires. Cela permet d'éviter les cas où, par exemple, un framework peut avoir ajouté l'élément à la page et défini une valeur sur l'une de ses propriétés, mais chargé sa définition de manière différée. Sans cette protection, l'élément mis à niveau manquerait cette propriété, et la propriété d'instance empêcherait l'appel du setter de la 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 être dupliqués. À cet effet, le setter de propriété pour selected gère les valeurs véridiques/falsifiées et les reflète dans l'état de l'attribut. Il est important de noter qu'aucun effet secondaire n'a lieu 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 attributeChangedCallback. En règle générale, faites en sorte que les setters 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 définir un attribut ARIA correspondant), faites-le dans attributeChangedCallback(). Vous éviterez ainsi de devoir gérer des scénarios complexes de location d'attributs ou de propriétés.

    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);
})();