Componenti HowTo - schede esplicative

Riepilogo

<howto-tabs> limita i contenuti visibili dividendoli in più riquadri. È visibile solo un riquadro alla volta, mentre le tutte le schede corrispondenti sono sempre visibili. Per passare da un riquadro all'altro, è necessario selezionare la scheda corrispondente.

Facendo clic o utilizzando i tasti freccia, l'utente può modificare la selezione della scheda attiva.

Se JavaScript è disattivato, tutti i riquadri vengono visualizzati interlacciati con le rispettive schede. Le schede ora funzionano come intestazioni.

Riferimento

Demo

Visualizza la demo in tempo reale su GitHub

Esempio di utilizzo

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

Se JavaScript non viene eseguito, l'elemento non corrisponderà a :defined. In questo caso, questo stile aggiunge spaziatura tra le schede e il riquadro precedente.

  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>

Codice

(function() {

Definisci i codici tasti per facilitare la gestione degli eventi della tastiera.

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

Per evitare di richiamare il parser con .innerHTML per ogni nuova istanza, un modello per i contenuti del DOM ombra è condiviso da tutte le istanze <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 è un elemento contenitore per schede e riquadri.

Tutti i figli di <howto-tabs> devono essere <howto-tab> o <howto-tabpanel>. Questo elemento è stateless, il che significa che non vengono memorizzati nella cache i valori e, di conseguenza, le modifiche durante il runtime.

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

I gestori di eventi non associati a questo elemento devono essere associati se devono accedere a this.

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

Per il miglioramento progressivo, il markup deve alternare schede e riquadri. Gli elementi che riordinano i propri elementi secondari tendono a non funzionare bene con i framework. Al contrario, lo shadow DOM viene utilizzato per riordinare gli elementi utilizzando gli slot.

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

Importa il modello condiviso per creare gli spazi per schede e riquadri.

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

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

Questo elemento deve reagire ai nuovi elementi secondari poiché collega le schede e il riquadro semanticamente utilizzando aria-labelledby e aria-controls. I nuovi elementi secondari verranno inseriti automaticamente e attiveranno l'evento slotchange, pertanto non è necessario MutationObserver.

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

connectedCallback() raggruppa le schede e i riquadri riordinandoli e assicurandosi che sia attiva una sola scheda.

    connectedCallback() {

L'elemento deve eseguire alcune operazioni di gestione degli eventi di input manuale per consentire il passaggio con i tasti freccia e Inizio / Fine.

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

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

Fino a poco tempo fa, gli eventi slotchange non si attivavano quando un elemento veniva sottoposto ad upgrade dal parser. Per questo motivo, l'elemento richiama manualmente l'handler. Una volta che il nuovo comportamento sarà disponibile in tutti i browser, il codice riportato di seguito potrà essere rimosso.

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

disconnectedCallback() rimuove i listener di eventi aggiunti da connectedCallback().

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

_onSlotChange() viene chiamato ogni volta che un elemento viene aggiunto o rimosso da uno degli slot DOM shadow.

    _onSlotChange() {
      this._linkPanels();
    }

_linkPanels() collega le schede ai relativi riquadri adiacenti utilizzando aria-controls e aria-labelledby. Inoltre, il metodo assicura che sia attiva una sola scheda.

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

Assegna a ogni riquadro un attributo aria-labelledby che rimandi alla scheda che lo controlla.

      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'elemento controlla se una delle schede è contrassegnata come selezionata. In caso contrario, la prima scheda sarà selezionata.

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

Quindi, passa alla scheda selezionata. _selectTab() si occupa di contrassegnare tutte le altre schede come deselezionate e di nascondere tutti gli altri riquadri.

      this._selectTab(selectedTab);
    }

_allPanels() restituisce tutti i riquadri nel riquadro delle schede. Questa funzione potrebbe memorizzare il risultato se le query DOM dovessero diventare un problema di prestazioni. Lo svantaggio della memorizzazione è che le schede e i riquadri aggiunti dinamicamente non verranno gestiti.

Si tratta di un metodo e non di un getter, perché un getter implica che è economico da leggere.

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

_allTabs() restituisce tutte le schede nel riquadro delle schede.

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

_panelForTab() restituisce il riquadro controllato dalla scheda specificata.

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

_prevTab() restituisce la scheda precedente a quella attualmente selezionata, tornando all'inizio quando raggiunge la prima.

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

Utilizza findIndex() per trovare l'indice dell'elemento attualmente selezionato e sottrai 1 per ottenere l'indice dell'elemento precedente.

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

Aggiungi tabs.length per assicurarti che l'indice sia un numero positivo e fai in modo che il modulo giri, se necessario.

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

_firstTab() restituisce la prima scheda.

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

_lastTab() restituisce l'ultima scheda.

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

_nextTab() recupera la scheda successiva a quella attualmente selezionata, tornando all'inizio quando raggiunge l'ultima scheda.

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

reset() deseleziona tutte le schede e nasconde tutti i riquadri.

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

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

_selectTab() contrassegna la scheda specificata come selezionata. Inoltre, viene mostrato il riquadro corrispondente alla scheda specificata.

    _selectTab(newTab) {

Deseleziona tutte le schede e nascondi tutti i riquadri.

      this.reset();

Recupera il pannello a cui è associato newTab.

      const newPanel = this._panelForTab(newTab);

Se la scheda non esiste, interrompi l'operazione.

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

_onKeyDown() gestisce le pressioni dei tasti all'interno del riquadro della scheda.

    _onKeyDown(event) {

Se la pressione del tasto non ha avuto origine dall'elemento della scheda stessa, si è trattato di una pressione del tasto all'interno di un riquadro o in uno spazio vuoto. Non devi fare nulla.

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

Non gestire le scorciatoie dei modificatori generalmente utilizzate dalle tecnologie per la disabilità.

      if (event.altKey)
        return;

L'istruzione switch determinerà quale scheda deve essere contrassegnata come attiva a seconda della chiave premuta.

      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;

Qualsiasi altra pressione del tasto viene ignorata e passata di nuovo al browser.

        default:
          return;
      }

Il browser potrebbe avere alcune funzionalità native associate ai tasti freccia, Home o Fine. L'elemento chiama preventDefault() per impedire al browser di eseguire qualsiasi azione.

      event.preventDefault();

Seleziona la nuova scheda, che è stata determinata nel comando switch.

      this._selectTab(newTab);
    }

_onClick() gestisce i clic all'interno del riquadro delle schede.

    _onClick(event) {

Se il clic non è stato scelto come target per un elemento della scheda, si tratta di un clic all'interno di un riquadro o su uno spazio vuoto. Non devi fare nulla.

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

Se si trovava in un elemento di tabulazione, seleziona la scheda.

      this._selectTab(event.target);
    }
  }

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

howtoTabCounter conteggia il numero di istanze <howto-tab> create. Il numero viene utilizzato per generare nuovi ID univoci.

  let howtoTabCounter = 0;

HowtoTab è una scheda per un riquadro di schede <howto-tabs>. <howto-tab> deve essere sempre utilizzato con role="heading" nel markup in modo che la semantica rimanga utilizzabile in caso di errore di JavaScript.

Un <howto-tab> dichiara a quale <howto-panel> appartiene utilizzando l'ID del riquadro come valore per l'attributo aria-controls.

Se non viene specificato alcun valore, <howto-tab> genera automaticamente un ID univoco.

  class HowtoTab extends HTMLElement {

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

    constructor() {
      super();
    }

    connectedCallback() {

Se viene eseguito, JavaScript funziona e il ruolo dell'elemento diventa tab.

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

Imposta uno stato iniziale ben definito.

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

Controlla se una proprietà ha un valore dell'istanza. In questo caso, copia il valore ed elimina la proprietà dell'istanza in modo che non oscuri il settatore della proprietà della classe. Infine, passa il valore al settatore della proprietà della classe in modo che possa attivare eventuali effetti collaterali. Questo serve a evitare i casi in cui, ad esempio, un framework potrebbe aver aggiunto l'elemento alla pagina e impostato un valore su una delle sue proprietà, ma ne ha caricato la definizione in modo lazy. Senza questa verifica, all'elemento di cui è stato eseguito l'upgrade mancherebbe questa proprietà e la proprietà di istanza impedirebbe che il set della proprietà di classe venga chiamato.

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

Le proprietà e i relativi attributi corrispondenti devono essere uguali. A questo scopo, il settatore della proprietà per selected gestisce i valori veri/falsi e li riflette nello stato dell'attributo. È importante notare che non si verificano effetti collaterali nel settatore della proprietà. Ad esempio, il settaggio non imposta aria-selected. Invece, questo lavoro viene eseguito in attributeChangedCallback. Come regola generale, rendi i setter delle proprietà molto semplici e, se l'impostazione di una proprietà o di un attributo deve causare un effetto collaterale (ad esempio l'impostazione di un attributo ARIA corrispondente), esegui questa operazione in attributeChangedCallback(). In questo modo eviterai di dover gestire scenari di ricorsione di attributi/proprietà complessi.

    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 è un riquadro per un riquadro della scheda <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);
})();