Componenti HowTo - schede esplicative

Riepilogo

<howto-tabs> limita i contenuti visibili separandoli in più riquadri. È visibile un solo riquadro alla volta, mentre tutte le schede corrispondenti sono sempre visibili. Per passare da un riquadro all'altro, devi 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 mostrati interleaving con le rispettive schede. Le schede ora fungono da intestazioni.

Riferimento

Demo

Visualizza la demo dal vivo 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 tal caso, questo stile aggiunge spazio 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 facilitare la gestione degli eventi della tastiera.

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

Per evitare di chiamare il parser con .innerHTML per ogni nuova istanza, tutte le istanze <howto-tabs> condividono un modello per i contenuti dello shadow DOM.

  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, vale a dire che nessun valore viene memorizzato nella cache e, di conseguenza, cambia durante il lavoro di runtime.

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

I gestori di eventi non associati a questo elemento devono essere associati se hanno bisogno dell'accesso a this.

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

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

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

Importa il modello condiviso per creare aree 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 mentre collega semanticamente le schede e il riquadro utilizzando aria-labelledby e aria-controls. I nuovi bambini vengono messi in slot automaticamente e il cambio di slot viene attivato, senza il bisogno di MutationObservationr.

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

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

    connectedCallback() {

L'elemento deve gestire manualmente gli eventi di input per consentire il passaggio con i tasti freccia e i pulsanti 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, gli eventi slotchange non venivano attivati quando veniva eseguito l'upgrade di un elemento tramite il parser. Per questo motivo, l'elemento richiama manualmente il gestore. Una volta che il nuovo comportamento viene applicato in tutti i browser, il codice riportato di seguito 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 uno degli slot DOM shadow.

    _onSlotChange() {
      this._linkPanels();
    }

_linkPanels() collega le schede ai riquadri adiacenti utilizzando i comandi aria 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 faccia 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];

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 diventassero un problema di prestazioni. Lo svantaggio della memorizzazione è che le schede e i riquadri aggiunti dinamicamente non vengono gestiti.

Questo è un metodo e non 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 che precede quella attualmente selezionata, andando 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, se necessario, aggiungi il modulo.

      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, andando a capo quando si raggiunge 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 pannello a cui è associato newTab.

      const newPanel = this._panelForTab(newTab);

Se questo riquadro non esiste, interrompi.

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

La pressione dei tasti dei punti di manipolazione di _onKeyDown() all'interno del riquadro delle schede.

    _onKeyDown(event) {

Se la pressione dei tasti non proveniva da un elemento scheda stesso, si trattava della pressione di un tasto all'interno di un riquadro o in uno spazio vuoto. Niente da fare.

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

Non gestire le scorciatoie di modifica tipicamente utilizzate dalle tecnologie per la disabilità.

      if (event.altKey)
        return;

Il cambio tra maiuscole e minuscole determinerà quale scheda deve essere contrassegnata come attiva a seconda del 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 dei tasti viene ignorata e restituita al browser.

        default:
          return;
      }

Il browser potrebbe avere alcune funzionalità native associate ai tasti freccia, o HOME o FINE. L'elemento chiama preventDefault() per impedire al browser di eseguire azioni.

      event.preventDefault();

Seleziona la nuova scheda che è stata determinata nel caso del passaggio.

      this._selectTab(newTab);
    }

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

    _onClick(event) {

Se il clic non era stato scelto come target su un elemento della scheda stesso, si trattava 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 della scheda, seleziona quella scheda.

      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 delle 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 dell'attributo aria-controls.

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

  class HowtoTab extends HTMLElement {

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

    constructor() {
      super();
    }

    connectedCallback() {

Se questo comando viene eseguito, 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 tal caso, copia il valore ed elimina la proprietà dell'istanza in modo che non esegua lo shadowing del setter delle proprietà della classe. Infine, passa il valore all'impostazione delle proprietà della classe in modo che possa attivare eventuali effetti collaterali. Questo serve per proteggerti dai casi in cui, ad esempio, un framework potrebbe aver aggiunto l'elemento alla pagina e impostato un valore su una delle sue proprietà, ma la sua definizione è stata caricata lentamente. Senza questa protezione, l'elemento aggiornato non riuscirebbe a visualizzare questa proprietà e la proprietà dell'istanza impedirebbe la chiamata dell'impostazione della proprietà di 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. In questo modo, l'impostazione della proprietà per selected gestisce i valori veritieri/falsi e riflette questi valori nello stato dell'attributo. È importante notare che non si verificano effetti collaterali nel setter delle proprietà. Ad esempio, il setter non imposta aria-selected. ma che si svolge nell'attributeChangedCallback. Come regola generale, non rendere più stupidi i responsabili della configurazione delle proprietà 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), puoi farlo nell'attributeChangedCallback(). In questo modo eviterai di dover gestire scenari complessi di rientrata di attributi/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 un riquadro delle schede <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);
})();