HowTo-Komponenten – HowTo-Tabs

Zusammenfassung

<howto-tabs> schränkt sichtbare Inhalte ein, indem sie in mehrere Felder unterteilt werden. Nur Es ist immer ein Bereich sichtbar, während alle zugehörigen Tabs immer sichtbar sind. sichtbar sind. Um von einem Feld zum anderen zu wechseln, muss der entsprechende Tab ausgewählt.

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

Wenn JavaScript deaktiviert ist, werden alle Felder überlappen mit dem auf den jeweiligen Tabs. Die Tabs funktionieren 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 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 Schlüsselcodes, um die Verarbeitung von Tastaturereignissen zu erleichtern.

  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 Steuerfelder.

Alle untergeordneten Elemente von <howto-tabs> müssen entweder <howto-tab> oder <howto-tabpanel> sein. Dieses Element ist zustandslos. Das bedeutet, dass keine Werte im Cache gespeichert werden und daher Änderungen während der Laufzeit vorgenommen werden.

  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 Progressive Enhancement sollte das Markup zwischen Tabs und Steuerfeldern wechseln. Elemente, die ihre untergeordneten Elemente neu anordnen, funktionieren in der Regel nicht gut mit Frameworks. Stattdessen wird Shadow 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 Registerkarten und Steuerfelder 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 semantisch mit aria-labelledby und aria-controls verknüpft. Neue untergeordnete Elemente erhalten automatisch Slotted und lösen Slotchange aus, sodass MutationObserver nicht benötigt wird.

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

Das Element muss einige manuelle Eingabeereignisse verarbeiten, damit ein Wechsel über die 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 verfügbar ist, kann der folgende Code 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 sorgt die Methode dafür, dass nur ein Tab aktiv ist.

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

Weisen Sie jedem Bereich ein aria-labelledby-Attribut zu, das auf den Tab verweist, ü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 Tabs als ausgewählt markiert sind. Ist dies nicht der Fall, wird 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 könnte sich das Ergebnis merken, falls die DOM-Abfragen zu einem Leistungsproblem werden würden. Der Nachteil ist, dass dynamisch hinzugefügte Tabs und Bereiche nicht verarbeitet werden können.

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 den Bereich zurück, den 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 angezeigt wird. Der Tab wird umgebrochen, wenn Sie den ersten Tab erreichen.

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

Verwenden Sie findIndex(), um den Index des derzeit ausgewählten Elements zu ermitteln, und subtrahieren Sie 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 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];
    }

Mit reset() werden alle Tabs als nicht ausgewählt markiert und alle Bereiche werden ausgeblendet.

    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 das entsprechende Steuerfeld 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 Tabbereich.

    _onKeyDown(event) {

Wenn der Tastendruck nicht von einem Tab-Element selbst stammt, war es ein Tastendruck innerhalb eines Steuerfelds oder in einem leeren Bereich. Sie müssen nichts weiter tun.

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

Verwenden Sie keine Modifikatortasten, die normalerweise von Hilfstechnologien verwendet werden.

      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;

Jeder andere Tastendruck wird ignoriert und an den Browser zurückgegeben.

        default:
          return;
      }

Möglicherweise verfügt der Browser über native Funktionen, die an die Pfeiltasten, den Startbildschirm oder das 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 bestimmt wurde.

      this._selectTab(newTab);
    }

_onClick() verarbeitet Klicks innerhalb des Tabbereichs.

    _onClick(event) {

Wenn der Klick nicht auf ein Tab-Element selbst ausgerichtet wurde, war es ein Klick innerhalb eines Steuerfelds oder auf eine leere Fläche. Sie müssen nichts weiter tun.

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

Wenn sie jedoch in einem Tab-Element enthalten war, 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 den <howto-tabs>-Tabbereich. <howto-tab> sollte immer zusammen mit role="heading" im Markup verwendet werden, damit die Semantik auch dann verwendet werden kann, wenn JavaScript ausfällt.

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.

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

  class HowtoTab extends HTMLElement {

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

    constructor() {
      super();
    }

    connectedCallback() {

Wenn er 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 eine Property einen Instanzwert hat. Ist dies der Fall, kopieren Sie den Wert und löschen Sie die Instanzeigenschaft, damit der Klassenattribut-Setter nicht verdeckt wird. Übergeben Sie den Wert schließlich an den Klassen-Property-Setter, damit er Nebenwirkungen auslösen kann. Dies schützt vor Fällen, in denen beispielsweise ein Framework das Element der Seite hinzugefügt und einen Wert für eine seiner Eigenschaften festgelegt, aber die Definition per Lazy geladen hat. Ohne diesen Guard würde das aktualisierte Element diese Eigenschaft nicht erfassen 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 ihre entsprechenden Attribute sollten sich widerspiegeln. Der Property-Setter für selected verarbeitet also wahrheitsgemäße/falsche Werte und spiegelt sie dem Status des Attributs wider. Beachten Sie, dass bei der Eigenschaftsfestlegung keine Nebenwirkungen auftreten. Beispielsweise legt der Setter aria-selected nicht fest. Stattdessen finden diese Aufgaben im attributeChangedCallback statt. Im Allgemeinen sollten Sie Property-Setter sehr blöd machen. Wenn das Festlegen einer Eigenschaft oder eines Attributs einen Nebeneffekt verursachen soll (z. B. das Festlegen eines entsprechenden ARIA-Attributs), sollten Sie dies in der attributeChangedCallback() tun. So müssen Sie sich nicht um komplizierte Szenarien bei Attribut-/Property-Abrufen kümmern.

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