HowTo-Komponenten – HowTo-Tabs

Zusammenfassung

<howto-tabs> schränkt die sichtbaren Inhalte ein, indem sie in mehrere Bereiche unterteilt wird. Es ist jeweils nur ein Bereich sichtbar und alle entsprechenden Tabs sind immer sichtbar. Um von einem Bereich zum anderen zu wechseln, muss der entsprechende Tab ausgewählt werden.

Durch Klicken oder Verwenden der Pfeiltasten kann der Nutzer die Auswahl des aktiven Tabs ändern.

Wenn JavaScript deaktiviert ist, werden alle Felder übereinander mit den jeweiligen Tabs angezeigt. Die Registerkarten funktionieren jetzt als Überschriften.

Referenz

Demo

Live-Demo auf GitHub ansehen

Anwendungsbeispiel

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

Wenn JavaScript nicht ausgeführt wird, entspricht das Element nicht :defined. In diesem Fall fügt dieser Stil den Abstand zwischen den Tabs und dem vorherigen Steuerfeld hinzu.

  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() {

Definieren Sie Tastencodes für die Verarbeitung von Tastaturereignissen.

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

Damit der Parser nicht für jede neue Instanz mit .innerHTML aufgerufen wird, wird eine Vorlage für den Inhalt des Shadow-DOM von allen <howto-tabs>-Instanzen gemeinsam genutzt.

  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 sind ein Containerelement für Tabs und Steuerfelder.

Alle untergeordneten Elemente von <howto-tabs> müssen entweder <howto-tab> oder <howto-tabpanel> sein. Dieses Element ist zustandslos, d. h., es werden keine Werte im Cache gespeichert und ändert sich daher während der Laufzeit.

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

Event-Handler, die nicht an dieses Element angehängt sind, müssen gebunden werden, wenn sie Zugriff auf this benötigen.

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

Für eine progressive Verbesserung sollte das Markup zwischen Tabs und Steuerfeldern abwechselnd angezeigt werden. Elemente, die ihre Kinder neu anordnen, funktionieren in der Regel nicht gut mit Konzepten. Stattdessen wird das Schatten-DOM verwendet, um die Elemente mithilfe von Slots neu anzuordnen.

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

Importieren Sie die gemeinsam genutzte Vorlage, um die Flächen für Tabs und Bereiche zu erstellen.

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

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

Dieses Element muss auf neue untergeordnete Elemente reagieren, da es Tabs und Steuerfelder mithilfe von aria-labelledby und aria-controls semantisch miteinander verknüpft. Neue untergeordnete Elemente erhalten automatisch Slots, wodurch Slotchange ausgelöst wird, sodass MutationObserver nicht erforderlich ist.

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

connectedCallback() gruppiert Tabs und Bereiche durch Neuanordnen und sorgt dafür, dass immer nur ein Tab aktiv ist.

    connectedCallback() {

Für das Element müssen einige manuelle Eingabeereignisse festgelegt werden, damit zwischen den Pfeiltasten und Pos1 und Ende gewechselt werden kann.

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

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

Bis vor Kurzem wurden slotchange-Ereignisse nicht ausgelöst, wenn ein Element vom Parser aktualisiert wurde. Aus diesem Grund ruft das -Element den Handler manuell auf. Sobald das neue Verhalten in allen Browsern umgesetzt wird, kann der unten stehende Code entfernt werden.

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

disconnectedCallback() entfernt die von connectedCallback() hinzugefügten Event-Listener.

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

_onSlotChange() wird immer dann aufgerufen, wenn ein Element zu einer der Schatten-DOM-Slots hinzugefügt oder daraus entfernt wird.

    _onSlotChange() {
      this._linkPanels();
    }

_linkPanels() verknüpft Tabs mithilfe von aria-Steuerelementen und aria-labelledby mit den angrenzenden Bereichen. Außerdem wird mit der Methode sichergestellt, dass nur ein Tab aktiv ist.

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

Weisen Sie jedem Feld ein aria-labelledby-Attribut zu, das sich auf den Tab bezieht, über den es gesteuert wird.

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

Das Element prüft, ob einer der Tabs als ausgewählt markiert wurde. Andernfalls ist der erste Tab ausgewählt.

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

Wechseln Sie als Nächstes zum ausgewählten Tab. _selectTab() markiert alle anderen Tabs als nicht ausgewählt und blendet alle anderen Steuerfelder aus.

      this._selectTab(selectedTab);
    }

_allPanels() gibt alle Steuerfelder im Tabbereich zurück. Diese Funktion könnte sich das Ergebnis merken, falls die DOM-Abfragen zu einem Leistungsproblem werden. Der Nachteil beim Gedächtnis ist, dass dynamisch hinzugefügte Tabs und Steuerfelder nicht verarbeitet werden.

Dies ist eine Methode und kein Getter, da ein Getter impliziert, dass das Lesen günstig ist.

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

_allTabs() gibt alle Tabs im Tabbereich zurück.

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

_panelForTab() gibt das Steuerfeld zurück, das mit dem angegebenen Tab gesteuert wird.

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

_prevTab() gibt den Tab zurück, der vor dem aktuell ausgewählten Tab steht. Der Tab wird umgebrochen, wenn der erste Tab erreicht wird.

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

Verwenden Sie findIndex(), um den Index des aktuell ausgewählten Elements zu ermitteln, und subtrahiert eins, um den Index des vorherigen Elements zu erhalten.

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

Fügen Sie tabs.length hinzu, um sicherzustellen, dass der Index eine positive Zahl ist und den Modulus bei Bedarf umkehren kann.

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

_firstTab() gibt den ersten Tab zurück.

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

_lastTab() gibt den letzten Tab zurück.

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

_nextTab() ruft den Tab ab, der nach dem aktuell ausgewählten Tab kommt. Er wird umgebrochen, wenn der letzte Tab erreicht wird.

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

reset() markiert alle Tabs als nicht ausgewählt und blendet alle Steuerfelder aus.

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

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

_selectTab() markiert den angegebenen Tab als ausgewählt. Außerdem wird das Steuerfeld eingeblendet, das dem angegebenen Tab entspricht.

    _selectTab(newTab) {

Heben Sie die Auswahl aller Tabs auf und blenden Sie alle Steuerfelder aus.

      this.reset();

Ruft das Steuerfeld ab, mit dem das newTab verknüpft ist.

      const newPanel = this._panelForTab(newTab);

Wenn das Feld nicht vorhanden ist, brechen Sie den Vorgang ab.

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

_onKeyDown() verarbeitet das Drücken von Tasten im Tabbereich.

    _onKeyDown(event) {

Wenn der Tastendruck nicht von einem Tabelement selbst stammte, handelte es sich um einen Tastendruck innerhalb eines Steuerfelds oder auf einem leeren Bereich. Es sind keine Aufgaben vorhanden.

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

Umgang mit Modifikatortasten, die normalerweise von Hilfstechnologien verwendet werden, werden nicht berücksichtigt.

      if (event.altKey)
        return;

Das Switch-Case bestimmt abhängig von der gedrückten Taste, welcher Tab als aktiv markiert werden soll.

      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;

Alle anderen Tastenbetätigungen werden ignoriert und an den Browser zurückgegeben.

        default:
          return;
      }

Möglicherweise verfügt der Browser über einige native Funktionen für die Pfeiltasten, also für die Start- oder Endschaltfläche. Das Element ruft preventDefault() auf, um zu verhindern, dass der Browser Aktionen ausführt.

      event.preventDefault();

Wählen Sie den neuen Tab aus, der für den Switch-Case festgelegt wurde.

      this._selectTab(newTab);
    }

_onClick() verarbeitet Klicks im Tabbereich.

    _onClick(event) {

Wenn der Klick nicht auf ein Tabelement selbst gerichtet war, handelte es sich um einen Klick in einem Steuerfeld oder in eine leere Fläche. Es sind keine Aufgaben vorhanden.

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

Wenn es sich jedoch um ein Tabelement befand, wählen Sie diesen Tab aus.

      this._selectTab(event.target);
    }
  }

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

howtoTabCounter zählt die Anzahl der erstellten <howto-tab>-Instanzen. Die Nummer wird verwendet, um neue, eindeutige IDs zu generieren.

  let howtoTabCounter = 0;

HowtoTab ist ein Tab für einen <howto-tabs>-Tabbereich. <howto-tab> sollte immer mit role="heading" im Markup verwendet werden, damit die Semantik auch dann genutzt werden kann, wenn JavaScript fehlschlägt.

Ein <howto-tab> deklariert, zu welcher <howto-panel> er gehört, indem die ID dieses Bereichs als Wert für das Attribut „aria-controls“ verwendet wird.

<howto-tab> generiert automatisch eine eindeutige ID, wenn keine angegeben ist.

  class HowtoTab extends HTMLElement {

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

    constructor() {
      super();
    }

    connectedCallback() {

Wenn dies ausgeführt wird, funktioniert JavaScript und das Element ändert seine Rolle in tab.

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

Legen Sie einen klar definierten Anfangszustand fest.

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

Prüfen Sie, ob ein Attribut einen Instanzwert hat. Wenn ja, kopieren Sie den Wert und löschen Sie die Instanzeigenschaft, damit der Klasseneigenschafts-Setter nicht verdeckt wird. Übergeben Sie den Wert schließlich an den Klassen-Property-Setter, damit dieser alle Nebeneffekte auslösen kann. So schützen Sie sich vor Fällen, in denen ein Framework beispielsweise das Element der Seite hinzugefügt und einen Wert für eine seiner Attribute festgelegt hat, seine Definition jedoch mit Lazy Loading. Ohne diesen Schutz würde das aktualisierte Element diese Eigenschaft nicht enthalten und die Instanzeigenschaft würde verhindern, dass der Klasseneigenschafts-Setter aufgerufen wird.

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

Eigenschaften und die zugehörigen Attribute sollten einander spiegeln. Aus diesem Grund verarbeitet der Property-Setter für selected wahre/falsche Werte und spiegelt diese im Zustand des Attributs wider. Beachten Sie, dass im Property-Setter keine Nebeneffekte auftreten. Der Setter legt beispielsweise nicht aria-selected fest. Stattdessen erfolgt dieser Vorgang im attributeChangedCallback. Im Allgemeinen gilt, dass Property-Setter sehr dumm sein sollten. Wenn das Festlegen einer Eigenschaft oder eines Attributs einen Nebeneffekt verursachen sollte (z. B. das Festlegen eines entsprechenden ARIA-Attributs), funktioniert dies in attributeChangedCallback(). So müssen Sie keine komplexen Szenarien für das Abrufen von Attributen oder Properties verwalten.

    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 ist ein Bereich für einen <howto-tabs>-Tabbereich.

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