Componentes de instruções – guias de instruções

Resumo

<howto-tabs> limita o conteúdo visível separando-o em vários painéis. Apenas um painel fica visível por vez, enquanto todas as guias correspondentes estão sempre visíveis. Para alternar de um painel para outro, a guia correspondente precisa ser selecionada.

Ao clicar ou usar as teclas de seta, o usuário pode mudar a seleção da guia ativa.

Se o JavaScript estiver desativado, todos os painéis serão mostrados intercalados com as respectivas guias. As guias agora funcionam como títulos.

Referência

Demonstração

Conferir a demonstração ao vivo no GitHub

Exemplo de uso

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

Se o JavaScript não for executado, o elemento não vai corresponder a :defined. Nesse caso, esse estilo adiciona espaçamento entre as guias e o painel anterior.

  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>

Código

(function() {

Define códigos de tecla para ajudar a processar eventos de teclado.

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

Para evitar a invocação do analisador com .innerHTML para cada nova instância, um modelo para o conteúdo do DOM sombra é compartilhado por todas as instâncias <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>
  `;

O elemento "HowtoTabs" é um elemento de contêiner para guias e painéis.

Todos os filhos de <howto-tabs> precisam ser <howto-tab> ou <howto-tabpanel>. Esse elemento é sem estado, o que significa que nenhum valor é armazenado em cache e, portanto, muda durante o trabalho de execução.

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

Os manipuladores de eventos que não estão conectados a esse elemento precisam ser vinculados se precisarem de acesso a this.

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

Para o aprimoramento progressivo, a marcação precisa alternar entre guias e painéis. Elementos que reordenam os filhos tendem a não funcionar bem com frameworks. Em vez disso, o shadow DOM é usado para reordenar os elementos usando slots.

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

Importe o modelo compartilhado para criar os slots de guias e painéis.

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

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

Esse elemento precisa reagir a novos filhos, pois vincula semanticamente as guias e o painel usando aria-labelledby e aria-controls. Os novos filhos vão ser alocados automaticamente e causar o disparo de slotchange, então não é necessário MutationObserver.

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

connectedCallback() agrupa guias e painéis reordenando-os e garante que apenas uma guia esteja ativa.

    connectedCallback() {

O elemento precisa fazer algum tratamento manual de eventos de entrada para permitir a troca com as teclas de seta e Home / End.

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

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

Até recentemente, os eventos slotchange não eram acionados quando um elemento era atualizado pelo analisador. Por isso, o elemento invoca o gerenciador manualmente. Quando o novo comportamento for lançado em todos os navegadores, o código abaixo poderá ser removido.

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

disconnectedCallback() remove os listeners de eventos que connectedCallback() adicionou.

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

_onSlotChange() é chamado sempre que um elemento é adicionado ou removido de um dos slots do shadow DOM.

    _onSlotChange() {
      this._linkPanels();
    }

_linkPanels() vincula as guias aos painéis adjacentes usando aria-controls e aria-labelledby. Além disso, o método garante que apenas uma guia esteja ativa.

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

Dê a cada painel um atributo aria-labelledby que se refira à guia que o controla.

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

O elemento verifica se alguma das guias foi marcada como selecionada. Caso contrário, a primeira guia será selecionada.

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

Em seguida, mude para a guia selecionada. _selectTab() marca todas as outras guias como desmarcadas e oculta todos os outros painéis.

      this._selectTab(selectedTab);
    }

_allPanels() retorna todos os painéis no painel de guias. Essa função pode memorizar o resultado se as consultas do DOM se tornarem um problema de desempenho. A desvantagem da memorização é que as guias e os painéis adicionados dinamicamente não serão processados.

Esse é um método, e não um getter, porque um getter implica que a leitura é barata.

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

_allTabs() retorna todas as guias no painel de guias.

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

_panelForTab() retorna o painel que a guia controla.

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

_prevTab() retorna a guia que vem antes da selecionada no momento, retornando ao início quando chega à primeira.

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

Use findIndex() para encontrar o índice do elemento selecionado no momento e subtrair um para receber o índice do elemento anterior.

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

Adicione tabs.length para garantir que o índice seja um número positivo e faça com que o módulo seja transferido, se necessário.

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

_firstTab() retorna a primeira guia.

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

_lastTab() retorna a última guia.

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

_nextTab() recebe a guia que vem depois da selecionada no momento, retornando ao início quando chega à última.

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

reset() marca todas as guias como desmarcadas e oculta todos os painéis.

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

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

_selectTab() marca a guia especificada como selecionada. Além disso, ele mostra o painel correspondente à guia.

    _selectTab(newTab) {

Desmarque todas as guias e oculte todos os painéis.

      this.reset();

Receba o painel ao qual o newTab está associado.

      const newPanel = this._panelForTab(newTab);

Se esse painel não existir, interrompa.

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

_onKeyDown() processa pressionamentos de tecla no painel de guias.

    _onKeyDown(event) {

Se a tecla não tiver origem em um elemento de guia, ela foi pressionada dentro de um painel ou em um espaço vazio. Nada a fazer.

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

Não processa atalhos de modificador normalmente usados por tecnologia adaptativa.

      if (event.altKey)
        return;

O switch-case vai determinar qual guia será marcada como ativa, dependendo da tecla pressionada.

      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;

Qualquer outra tecla pressionada é ignorada e transmitida de volta ao navegador.

        default:
          return;
      }

O navegador pode ter alguma funcionalidade nativa vinculada às teclas de seta, "Home" ou "End". O elemento chama preventDefault() para impedir que o navegador realize qualquer ação.

      event.preventDefault();

Selecione a nova guia determinada no caso de comutação.

      this._selectTab(newTab);
    }

_onClick() processa cliques no painel de guias.

    _onClick(event) {

Se o clique não foi direcionado a um elemento de guia, foi um clique dentro de um painel ou em um espaço vazio. Nada a fazer.

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

Se ele estiver em um elemento de guia, selecione essa guia.

      this._selectTab(event.target);
    }
  }

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

howtoTabCounter conta o número de instâncias <howto-tab> criadas. O número é usado para gerar IDs novos e exclusivos.

  let howtoTabCounter = 0;

HowtoTab é uma guia para um painel de guias <howto-tabs>. O <howto-tab> sempre deve ser usado com role="heading" na marcação para que a semântica continue utilizável quando o JavaScript falhar.

Um <howto-tab> declara a qual <howto-panel> ele pertence usando o ID do painel como o valor do atributo aria-controls.

Um <howto-tab> vai gerar automaticamente um ID exclusivo se nenhum for especificado.

  class HowtoTab extends HTMLElement {

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

    constructor() {
      super();
    }

    connectedCallback() {

Se isso for executado, o JavaScript estará funcionando e o elemento mudará de função para tab.

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

Defina um estado inicial bem definido.

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

Verifica se uma propriedade tem um valor de instância. Se sim, copie o valor e exclua a propriedade de instância para que ela não ofusque o setter de propriedade da classe. Por fim, transmita o valor para o setter de propriedade de classe para que ele possa acionar os efeitos colaterais. Isso serve para proteger contra casos em que, por exemplo, um framework pode ter adicionado o elemento à página e definido um valor em uma das propriedades, mas carregado a definição de forma lenta. Sem essa proteção, o elemento atualizado perderia essa propriedade, e a propriedade de instância impediria que o setter de propriedade de classe fosse chamado.

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

As propriedades e os atributos correspondentes precisam ser espelhados. Para isso, o setter de propriedade de selected processa valores verdadeiros/falsos e os reflete no estado do atributo. É importante observar que não há efeitos colaterais no setter de propriedade. Por exemplo, o setter não define aria-selected. Em vez disso, esse trabalho acontece no attributeChangedCallback. Como regra geral, torne os setters de propriedade muito simples e, se a definição de uma propriedade ou atributo causar um efeito colateral (como definir um atributo ARIA correspondente), faça isso no attributeChangedCallback(). Isso evita a necessidade de gerenciar cenários complexos de reentrada de atributo/propriedade.

    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 é um painel para um painel de guias <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);
})();