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

Resumo

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

Clicando ou usando as teclas de seta, o usuário pode alterar a a seleção da guia ativa.

Se o JavaScript estiver desativado, todos os painéis serão mostrados intercalados com o respectivas guias. As guias agora funcionam como cabeçalhos.

Referência

Demonstração

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

Defina códigos de tecla para ajudar a lidar com eventos de teclado.

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

Para evitar invocar o analisador com .innerHTML para cada nova instância, um modelo para o conteúdo do shadow DOM é compartilhado por todas as instâncias de <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 é um elemento 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 no ambiente de execução.

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

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

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

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

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

Importe o modelo compartilhado para criar espaços para 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, já que ele vincula guias e painéis semanticamente usando aria-labelledby e aria-controls. Novos filhos serão alocados automaticamente e acionarão a mudança de slot, portanto, o MutationObserver não é necessário.

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

O connectedCallback() agrupa guias e painéis reorganizando e garante que exatamente uma guia esteja ativa.

    connectedCallback() {

O elemento precisa fazer alguns processamentos manuais de eventos de entrada para permitir a alternância com 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é pouco tempo atrás, eventos slotchange não eram disparados quando um elemento era atualizado pelo analisador. Por esse motivo, o elemento invoca o gerenciador manualmente. Quando o novo comportamento chegar a 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);
    }

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

    _onSlotChange() {
      this._linkPanels();
    }

O _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, alterne para a guia selecionada. O _selectTab() se encarrega de marcar todas as outras guias como desmarcadas e ocultar todos os outros painéis.

      this._selectTab(selectedTab);
    }

_allPanels() retorna todos os painéis do painel da guia. Essa função pode memorizar o resultado se as consultas 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 tratados.

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 controlado pela guia especificada.

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

_prevTab() retorna a guia que vem antes da selecionada no momento, quebrando ao chegar à primeira.

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

Use findIndex() para encontrar o índice do elemento selecionado no momento e subtraia um para chegar ao í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 encapsule o módulo, 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() acessa a guia que aparece depois da selecionada no momento, envolvendo-a quando chegar à ú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 fornecida como selecionada. Além disso, o painel correspondente à guia especificada é reexibido.

    _selectTab(newTab) {

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

      this.reset();

Consiga o painel ao qual o newTab está associado.

      const newPanel = this._panelForTab(newTab);

Se esse painel não existir, cancele a operação.

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

_onKeyDown() processa os pressionamentos de tecla dentro do painel da guia.

    _onKeyDown(event) {

Se o pressionamento de tecla não se originou de um elemento tab em si, foi um pressionamento de tecla dentro de um painel ou em um espaço vazio. Nada para fazer.

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

Não processam atalhos modificadores normalmente usados por tecnologia adaptativa.

      if (event.altKey)
        return;

O switch-case determinará qual guia deve 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 outro pressionamento de tecla é ignorado e retornado ao navegador.

        default:
          return;
      }

O navegador pode ter funcionalidades nativas vinculadas à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, que foi determinado no caso de alternância.

      this._selectTab(newTab);
    }

_onClick() processa cliques dentro do painel da guia.

    _onClick(event) {

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

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

No entanto, se estava 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>. Use <howto-tab> sempre com role="heading" na marcação para que a semântica continue utilizável quando o JavaScript falhar.

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

Um <howto-tab> 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');
    }

Verifique se uma propriedade tem um valor de instância. Nesse caso, copie o valor e exclua a propriedade da instância para que ela não seja ocultada pelo setter de propriedades da classe. Por fim, transmita o valor para o setter da propriedade de classe para que ele possa acionar 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 lentamente a definição. Sem essa proteção, o elemento atualizado perderia essa propriedade, e a propriedade da instância impediria que o setter da 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 iguais. 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 da propriedade. Por exemplo, o setter não define aria-selected. Em vez disso, esse trabalho acontece na attributeChangedCallback. Como regra geral, torne os setters de propriedade muito burros e, se a definição de uma propriedade ou um atributo causar um efeito colateral (como definir um atributo ARIA correspondente), faça isso no attributeChangedCallback(). Isso evita ter que gerenciar cenários complexos de reentrância de atributos/propriedades.

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