Componenti HowTo - schede esplicative

Riepilogo

<howto-tabs> limita i contenuti visibili separandoli in più riquadri. Solo sia visibile un riquadro alla volta, mentre tutte le schede corrispondenti sono sempre visibile. Per passare da un riquadro all'altro, la scheda corrispondente deve essere selezionato.

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

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

Riferimento

Demo

Visualizza la demo live 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 dei tasti per semplificare 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, tutte le istanze <howto-tabs> condividono un modello per i contenuti del DOM shadow.

  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 gli elementi secondari di <howto-tabs> devono essere <howto-tab> o <howto-tabpanel>. Questo elemento è stateless, il che significa che nessun valore viene memorizzato nella cache e, di conseguenza, viene modificato durante il lavoro di runtime.

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

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

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

Per un miglioramento progressivo, il markup dovrebbe alternarsi tra schede e riquadri. Gli elementi che riordinano i figli tendono a non funzionare bene con i framework. Viene invece utilizzato shadow DOM per riordinare gli elementi mediante slot.

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

Importa il modello condiviso per creare 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 quando collega semanticamente le schede e i riquadri utilizzando aria-labelledby e aria-controls. I nuovi elementi secondari verranno inseriti automaticamente e causeranno l'attivazione di slotchange, quindi non è necessario MutationObserver.

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

connectedCallback() raggruppa schede e riquadri riordinandoli e assicura che sia attiva esattamente una scheda.

    connectedCallback() {

L'elemento deve gestire manualmente gli eventi di input per consentire il passaggio con i tasti freccia e i tasti Home / Fine.

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

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

Fino a poco tempo fa, slotchange eventi non si sono attivati quando un elemento è stato aggiornato dal parser. Per questo motivo, l'elemento richiama il gestore manualmente. Una volta che il nuovo comportamento viene applicato a tutti i browser, il codice seguente può 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 una delle aree DOM shadow.

    _onSlotChange() {
      this._linkPanels();
    }

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

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

Assegna a ogni riquadro un attributo aria-labelledby che fa riferimento 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 è stata contrassegnata come selezionata. In caso contrario, viene selezionata la prima scheda.

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

Passa quindi 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 nel caso in cui le query DOM diventino un problema di prestazioni. Lo svantaggio della memorizzazione è che le schede e i riquadri aggiunti in modo dinamico non verranno gestiti.

Si tratta di un metodo, non di un getter, perché il getter implica che la lettura è economica.

    _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 che precede quella attualmente selezionata, spostandosi a capo quando raggiungi la prima.

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

Usa findIndex() per trovare l'indice dell'elemento attualmente selezionato e sottrae uno 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 ottenere il wrapping del modulo 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 e si sposta quando raggiungi l'ultima scheda.

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

reset() contrassegna tutte le schede come deselezionate 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, mostra il riquadro corrispondente alla scheda specificata.

    _selectTab(newTab) {

Deseleziona tutte le schede e nascondi tutti i riquadri.

      this.reset();

Recupera il riquadro a cui è associato l'oggetto newTab.

      const newPanel = this._panelForTab(newTab);

Se il riquadro 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 delle schede.

    _onKeyDown(event) {

Se la pressione di un tasto non proviene da un elemento tabulazione, si è trattato di un tasto all'interno di un riquadro o di uno spazio vuoto. Niente da fare.

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

Non gestire i tasti di modifica utilizzati in genere dalle tecnologie per la disabilità.

      if (event.altKey)
        return;

La custodia determinerà quale scheda deve essere contrassegnata come attiva in base al tasto premuto.

      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 restituita 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 azioni.

      event.preventDefault();

Selezionare la nuova scheda che è stata determinata nel caso del cambio.

      this._selectTab(newTab);
    }

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

    _onClick(event) {

Se il clic non era indirizzato su un elemento scheda stesso, si tratta di un clic all'interno di un riquadro o su uno spazio vuoto. Niente da fare.

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

Se invece si trovava su un elemento tab, selezionala.

      this._selectTab(event.target);
    }
  }

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

howtoTabCounter conteggia il numero di <howto-tab> istanze 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 di quel riquadro come valore per l'attributo aria-controls.

Un <howto-tab> genererà automaticamente un ID univoco se non ne viene specificato nessuno.

  class HowtoTab extends HTMLElement {

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

    constructor() {
      super();
    }

    connectedCallback() {

Se viene eseguita, JavaScript funziona e l'elemento cambia il proprio ruolo in 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');
    }

Verificare se una proprietà ha un valore di istanza. In questo caso, copia il valore ed elimina la proprietà istanza in modo che non faccia shadowing del setter della proprietà della classe. Infine, passa il valore al setter delle proprietà della classe in modo che possa attivare eventuali effetti collaterali. Questo serve a proteggere 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 aver caricato la sua definizione tramite caricamento lento. Senza questa protezione, l'elemento di cui è stato eseguito l'upgrade non considererebbe quella proprietà e la proprietà instance impedirebbe di chiamare il setter della proprietà della classe.

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

Le proprietà e gli attributi corrispondenti devono essere speculari. A questo scopo, il setter della proprietà per selected gestisce i valori reali/falsi e li riflette allo stato dell'attributo. È importante notare che non si verificano effetti collaterali nel criterio di impostazione delle proprietà. Ad esempio, il setter non imposta aria-selected. Questo lavoro avviene nel attributeChangedCallback. Come regola generale, rendi i setter delle proprietà molto stupidi e, se l'impostazione di una proprietà o di un attributo dovrebbe causare un effetto collaterale (ad esempio l'impostazione di un attributo ARIA corrispondente), puoi farlo in attributeChangedCallback(). In questo modo eviterai di dover gestire scenari complessi di rientro di attributi o proprietà.

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