HowTo-Komponenten – HowTo-Tabs

Zusammenfassung

<howto-tabs> Begrenzen Sie die sichtbaren Inhalte, indem Sie sie in mehrere Bereiche unterteilen. Es ist jeweils nur ein Steuerfeld sichtbar, während alle entsprechenden Tabs immer sichtbar sind. Wenn Sie von einem Bereich zu einem anderen wechseln möchten, muss der entsprechende Tab ausgewählt sein.

Der Nutzer kann entweder durch Klicken oder mit den Pfeiltasten den aktiven Tab auswählen.

Wenn JavaScript deaktiviert ist, werden alle Bereiche überlappend mit den jeweiligen Tabs angezeigt. Die Tabs dienen jetzt als Überschriften.

Referenz

Demo

Live-Demo auf GitHub ansehen

Nutzungsbeispiel

<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, stimmt das Element nicht mit :defined überein. In diesem Fall wird durch diesen Stil ein Abstand zwischen den Tabs und dem vorherigen Bereich hinzugefügt.

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

Schlüsselcodes definieren, um die Verarbeitung von Tastaturereignissen zu unterstützen

  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 von allen <howto-tabs>-Instanzen eine Vorlage für den Inhalt des Shadow DOM gemeinsam verwendet.

  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“ ist ein Containerelement für Tabs und Bereiche.

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

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

Bei der progressiven Verbesserung sollte das Markup zwischen Tabs und Bereichen wechseln. Elemente, die ihre untergeordneten Elemente neu anordnen, funktionieren in der Regel nicht gut mit Frameworks. Stattdessen wird das Shadow-DOM verwendet, um die Elemente mithilfe von Slots neu anzuordnen.

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

Importieren Sie die freigegebene Vorlage, um die Slots 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 Steuerfeld semantisch mithilfe von aria-labelledby und aria-controls verknüpft. Neue untergeordnete Elemente werden automatisch platziert und lösen das Ereignis „slotchange“ aus. MutationObserver ist daher nicht erforderlich.

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

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

    connectedCallback() {

Für das Element muss eine manuelle Eingabeereignisbehandlung erfolgen, damit der Wechsel mit den Pfeiltasten und Pos1/Ende möglich ist.

      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 eingeführt wurde, kann der Code unten entfernt werden.

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

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

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

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

    _onSlotChange() {
      this._linkPanels();
    }

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

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

Weisen Sie jedem Steuerfeld 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 jetzt der erste Tab ausgewählt.

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

Wechseln Sie als Nächstes zum ausgewählten Tab. _selectTab() sorgt dafür, dass alle anderen Tabs als nicht ausgewählt markiert und alle anderen Bereiche ausgeblendet werden.

      this._selectTab(selectedTab);
    }

_allPanels() gibt alle Bereiche im Tabbereich zurück. Diese Funktion kann das Ergebnis speichern, falls die DOM-Abfragen zu Leistungsproblemen führen. Der Nachteil des Merkens ist, dass dynamisch hinzugefügte Tabs und Bereiche nicht verarbeitet werden.

Dies ist eine Methode und kein Getter, da ein Getter impliziert, dass die Lesevorgänge kostengünstig sind.

    _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 der angegebene Tab steuert.

    _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 liegt. Wenn der erste Tab erreicht wird, wird der Zähler wieder auf „1“ gesetzt.

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

Mit findIndex() wird der Index des aktuell ausgewählten Elements ermittelt und davon 1 abgezogen, 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 erhalten Sie bei Bedarf den Modulus, den Sie umschließen können.

      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 folgt. Der letzte Tab wird umgebrochen.

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

reset() hebt die Auswahl aller Tabs auf 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 jeweiligen Tab als ausgewählt. Außerdem wird der Bereich für den entsprechenden Tab eingeblendet.

    _selectTab(newTab) {

Auswahl aller Tabs aufheben und alle Steuerfelder ausblenden.

      this.reset();

Rufen Sie den Bereich ab, mit dem das newTab verknüpft ist.

      const newPanel = this._panelForTab(newTab);

Wenn dieser Bereich 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 Tastendrücke im Tab-Steuerfeld.

    _onKeyDown(event) {

Wenn die Tastenpresse nicht von einem Tab-Element selbst stammte, war es eine Tastenpresse innerhalb eines Bereichs oder auf einem leeren Bereich. Es gibt nichts zu tun.

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

Verwenden Sie keine Modifikatorkürzel, die üblicherweise von Hilfstechnologien verwendet werden.

      if (event.altKey)
        return;

Im Switch-Case wird festgelegt, welcher Tab je nach gedrückter Taste 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 Tastendrücke werden ignoriert und an den Browser zurückgegeben.

        default:
          return;
      }

Der Browser hat möglicherweise einige native Funktionen, die an die Richtungstasten, „Pos1“ oder „Ende“ gebunden sind. Das Element ruft preventDefault() auf, um zu verhindern, dass der Browser Aktionen ausführt.

      event.preventDefault();

Wählen Sie den neuen Tab aus, der im Switch-Case-Block festgelegt wurde.

      this._selectTab(newTab);
    }

_onClick() verarbeitet Klicks im Tabbereich.

    _onClick(event) {

Wenn der Klick nicht auf ein Tab-Element selbst ausgerichtet war, war es ein Klick innerhalb eines Bereichs oder auf eine leere Stelle. Es gibt nichts zu tun.

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

Wenn es sich jedoch um ein Tab-Element handelt, wählen Sie den Tab aus.

      this._selectTab(event.target);
    }
  }

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

howtoTabCounter zählt die Anzahl der erstellten <howto-tab>-Instanzen. Anhand der Nummer werden neue, eindeutige IDs generiert.

  let howtoTabCounter = 0;

HowtoTab ist ein Tab für einen <howto-tabs>-Tabbereich. <howto-tab> sollte im Markup immer mit role="heading" verwendet werden, damit die Semantik auch bei einem JavaScript-Fehler verwendet werden kann.

Ein <howto-tab> gibt an, zu welchem <howto-panel> es gehört, indem die ID dieses Steuerfelds als Wert für das Attribut „aria-controls“ verwendet wird.

Wenn keine eindeutige ID angegeben wird, wird sie automatisch von <howto-tab> generiert.

  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, ob eine Property einen Instanzwert hat Falls ja, kopieren Sie den Wert und löschen Sie die Instanzeigenschaft, damit sie den Setzer der Klasseneigenschaft nicht überschattet. Übergeben Sie den Wert abschließend an den Setzer der Klasseneigenschaft, damit er Nebenwirkungen auslösen kann. So wird verhindert, dass ein Framework beispielsweise das Element der Seite hinzugefügt und einen Wert für eine seiner Eigenschaften festgelegt, aber die Definition lazy geladen hat. Ohne diesen Guard fehlt diese Property dem aktualisierten Element und die Instanzeigenschaft verhindert, dass der Setzer der Klasseneigenschaft überhaupt aufgerufen wird.

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

Unterkünfte und ihre entsprechenden Attribute sollten übereinstimmen. Dazu verarbeitet der Property-Setter für selected wahrheitsgemäße/falsche Werte und überträgt sie auf den Status des Attributs. Es ist wichtig zu beachten, dass beim Festlegen der Property keine Nebenwirkungen auftreten. Beispielsweise legt der Setter aria-selected nicht fest. Stattdessen geschieht dies in der attributeChangedCallback. Als Faustregel gilt: Machen Sie Property-Setter möglichst einfach. Wenn das Festlegen einer Property oder eines Attributs zu Nebenwirkungen führen sollte (z. B. das Festlegen eines entsprechenden ARIA-Attributs), führen Sie diese Arbeit im attributeChangedCallback() aus. So müssen Sie keine komplexen Szenarien für die erneute Ausführung von Attributen/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 den Tabbereich <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);
})();