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.

Com um clique ou usando as teclas de seta, o usuário pode alterar 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 cabeçalhos.

Referência

Demonstração

Confira 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 não tem 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 estiverem anexados a esse elemento precisarão ser vinculados se precisarem de acesso a this.

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

Para um aprimoramento progressivo, a marcação deve alternar entre guias e painéis. Elementos que reordenam seus 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 os 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 aos novos filhos, já que ele vincula as guias e o painel semanticamente usando aria-labelledby e aria-controls. Novos filhos serão colocados automaticamente e causarão o disparo do slotchange. 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 reordenando e garante que exatamente uma guia esteja ativa.

    connectedCallback() {

O elemento precisa realizar uma manipulação manual de eventos de entrada para permitir a alternância entre 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, eventos slotchange não eram disparados quando um elemento era atualizado pelo analisador. Por esse motivo, o elemento invoca o gerenciador manualmente. Assim que o novo comportamento for aplicado 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 adicionados pelo connectedCallback().

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

_linkPanels() vincula guias aos painéis adjacentes usando controles Aria 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. _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 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 guias e painéis adicionados dinamicamente não serão manipulados.

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

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

_allTabs() retorna todas as guias no painel da guia.

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

_panelForTab() retorna o painel que a guia especificada controla.

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

_prevTab() retorna a guia anterior à selecionada no momento, substituindo a primeira.

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

Use findIndex() para encontrar o índice do elemento selecionado no momento e subtraia 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 agrupado, 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 vem depois da selecionada no momento, contornando ao acessar a última guia.

    _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 em questão é mostrado.

    _selectTab(newTab) {

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

      this.reset();

Acessa o painel ao qual o newTab está associado.

      const newPanel = this._panelForTab(newTab);

Cancele se ele não existir.

      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 de guia, ele foi pressionado dentro de um painel ou em um espaço vazio. Nada para fazer.

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

Não processe atalhos modificadores geralmente usados por tecnologia adaptativa.

      if (event.altKey)
        return;

Ela determina 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 transmitido de volta ao navegador.

        default:
          return;
      }

O navegador pode ter algumas funcionalidades nativas vinculadas às teclas de seta, home ou end. O elemento chama preventDefault() para impedir que o navegador realize ações.

      event.preventDefault();

Selecione a nova guia, que foi determinada no caso de alternância.

      this._selectTab(newTab);
    }

_onClick() processa os cliques dentro do painel da guia.

    _onClick(event) {

Se o clique não foi segmentado 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 ele 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>. <howto-tab> precisa ser usado com role="heading" na marcação para que a semântica continue utilizável quando o JavaScript falhar.

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

Uma <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á a própria 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 oculte o setter da propriedade de classe. Por fim, transmita o valor para o setter da propriedade de classe para que ele possa acionar quaisquer efeitos colaterais. Isso serve para proteger contra casos em que, por exemplo, uma estrutura pode ter adicionado o elemento à página e definido um valor em uma das propriedades, mas carregar 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 se espelhar. Para esse efeito, o setter de propriedades de selected processa valores true/falsy e os reflete para o estado do atributo. É importante observar que não ocorrem efeitos colaterais no setter de propriedades. 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 burros e, se definir uma propriedade ou um atributo precisar causar um efeito colateral (como definir um atributo ARIA correspondente), isso vai funcionar no attributeChangedCallback(). Isso evita a necessidade de gerenciar cenários complexos de reentrada 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 guia <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);
})();