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 Steuerfelder zwischen den entsprechenden 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,
  };

Um zu vermeiden, dass der Parser 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 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 Steuerfelder durch Neuanordnung und sorgt dafür, dass genau ein Tab aktiv ist.

    connectedCallback() {

Das Element muss einige manuelle Eingabeereignisse verarbeiten, um den Wechsel mit den Richtungspfeilen und Pos1 / Ende zu ermöglichen.

      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 implementiert ist, kann der Code unten entfernt werden.

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

disconnectedCallback() entfernt die Event-Listener, die connectedCallback() hinzugefügt hat.

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

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

    _onSlotChange() {
      this._linkPanels();
    }

_linkPanels() verknüpft Tabs mit den zugehörigen Steuerelementen mithilfe von aria-controls 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. Mit _selectTab() wird die Auswahl aller anderen Tabs aufgehoben und alle anderen Bereiche ausgeblendet.

      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, damit der Index eine positive Zahl ist, und lassen Sie den Modulus bei Bedarf auf „0“ zurücksetzen.

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

Mit _nextTab() wird der Tab nach dem aktuell ausgewählten Tab abgerufen. Wenn der letzte Tab erreicht ist, wird wieder von vorn angefangen.

    _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 entsprechenden Tab als ausgewählt. Außerdem wird der Bereich für den entsprechenden Tab eingeblendet.

    _selectTab(newTab) {

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

      this.reset();

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

      const newPanel = this._panelForTab(newTab);

Wenn das Steuerfeld nicht vorhanden ist, beenden Sie den Vorgang.

      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;

Modifikatorkürzel, die in der Regel von Hilfstechnologien verwendet werden, werden nicht verarbeitet.

      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 wird aria-selected nicht vom Setzer festgelegt. 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 einem Nebeneffekt 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 Steuerfeld für einen <howto-tabs>-Tab.

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