Composants HowTo – Guides d'utilisation

Résumé

Les <howto-tabs> limitent le contenu visible en le divisant en plusieurs panneaux. Uniquement un panneau est visible à la fois, alors que tous les onglets correspondants sont toujours visible. Pour passer d'un panneau à un autre, l'onglet correspondant doit être sélectionnée.

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

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

Référence

Démo

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>

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 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 est donc modifié lors de l'exécution.

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

Les gestionnaires d'événements qui ne sont pas associés à cet élément doivent l'être 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. À la place, le Shadow DOM permet de 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 des onglets et des panneaux sémantiquement à l'aide de aria-labelledby et aria-controls. Les nouveaux enfants seront insérés automatiquement, ce qui déclenchera "slotchange". Par conséquent, MutationObserver n'est 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 de saisie pour permettre le basculement avec les touches fléchées et Origine / 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. Pour cette raison, l'élément appelle le gestionnaire manuellement. Une fois que le nouveau comportement sera disponible dans tous les navigateurs, le code ci-dessous pourra être supprimé.

      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 Shadow DOM.

    _onSlotChange() {
      this._linkPanels();
    }

_linkPanels() associe des onglets à leurs panneaux adjacents à l'aide de commandes aria et de aria-labelledby. De plus, cette méthode permet de s'assurer 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 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 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 s'affiche automatiquement lorsque vous atteignez le premier.

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

La fonction findIndex() permet de trouver l'index de l'élément actuellement sélectionné, puis soustrait 1 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 obtenez un module à encapsuler 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é et entoure le curseur lorsque le dernier onglet s'affiche.

    _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électionner tous les onglets et masquer 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 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 de 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 de l'onglet.

    _onClick(event) {

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

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

En revanche, si elle é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. Le numéro est utilisé pour générer de nouveaux identifiants 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 identifiant unique si aucun identifiant n'est spécifié.

  class HowtoTab extends HTMLElement {

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

    constructor() {
      super();
    }

    connectedCallback() {

Si c'est le cas, JavaScript fonctionne et l'élément change de 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é 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 d'éviter les cas où, par exemple, un framework peut avoir ajouté l'élément à la page et défini une valeur pour 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 passerait à côté de cette propriété, et la propriété de l'instance empêcherait le setter de la propriété de classe avant d'être appelé.

    _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. Dans ce cas, le setter de la propriété pour selected gère les valeurs exactes/falsifiées et les reflète à l'état de l'attribut. Il est important de noter que le setter de la propriété n'a aucun effet secondaire. Par exemple, le setter ne définit pas aria-selected. Ce travail s'effectue dans attributeChangedCallback. En règle générale, faites en sorte que les setters de propriété soient très bêtes. 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(). Cela vous évitera d'avoir à gérer des scénarios complexes de réintégration 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);
})();