Componentes de HowTo: Pestañas de instructivos

Resumen

<howto-tabs> limita el contenido visible separándolo en varios paneles. Solo se puede ver un panel a la vez, mientras que todas las pestañas correspondientes siempre están visibles. Para cambiar de un panel a otro, se debe seleccionar la pestaña correspondiente.

El usuario puede hacer clic o usar las teclas de flecha para cambiar la selección de la pestaña activa.

Si JavaScript está inhabilitado, todos los paneles se muestran intercalados con las pestañas correspondientes. Las pestañas ahora funcionan como encabezados.

Referencia

Demostración

Ver la demostración en vivo en GitHub

Ejemplo de uso

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

Si no se ejecuta JavaScript, el elemento no coincidirá con :defined. En ese caso, este estilo agrega espacio entre las pestañas y el panel 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 teclas para ayudar a controlar los eventos del teclado.

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

Para evitar invocar al analizador con .innerHTML para cada instancia nueva, todas las instancias de <howto-tabs> comparten una plantilla para el contenido del DOM en sombra.

  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 es un elemento contenedor para pestañas y paneles.

Todos los elementos secundarios de <howto-tabs> deben ser <howto-tab> o <howto-tabpanel>. Este elemento no tiene estado, lo que significa que no se almacenan en caché ningún valor y, por lo tanto, cambia durante el trabajo del entorno de ejecución.

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

Los controladores de eventos que no están adjuntos a este elemento deben estar vinculados si necesitan acceso a this.

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

Para la mejora progresiva, el marcado debe alternar entre pestañas y paneles. Los elementos que reordenan sus elementos secundarios suelen no funcionar bien con los frameworks. En cambio, se usa el shadow DOM para reordenar los elementos con ranuras.

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

Importa la plantilla compartida para crear los espacios para las pestañas y los paneles.

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

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

Este elemento debe reaccionar a los elementos secundarios nuevos, ya que vincula las pestañas y el panel semánticamente con aria-labelledby y aria-controls. Los elementos secundarios nuevos se asignarán automáticamente y provocarán que se active slotchange, por lo que no se necesita MutationObserver.

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

connectedCallback() agrupa las pestañas y los paneles reordenándolos y se asegura de que haya exactamente una pestaña activa.

    connectedCallback() {

El elemento debe realizar un manejo manual de eventos de entrada para permitir el cambio con las teclas de flecha y Inicio / Fin.

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

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

Hasta hace poco, los eventos slotchange no se activaban cuando el analizador actualizaba un elemento. Por este motivo, el elemento invoca al controlador de forma manual. Una vez que el nuevo comportamiento se implemente en todos los navegadores, se podrá quitar el siguiente código.

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

disconnectedCallback() quita los objetos de escucha de eventos que agregó connectedCallback().

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

Se llama a _onSlotChange() cada vez que se agrega o quita un elemento de uno de los espacios del shadow DOM.

    _onSlotChange() {
      this._linkPanels();
    }

_linkPanels() vincula las pestañas con sus paneles adyacentes mediante aria-controls y aria-labelledby. Además, el método se asegura de que solo una pestaña esté activa.

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

Asigna a cada panel un atributo aria-labelledby que haga referencia a la pestaña que lo 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);
      });

El elemento verifica si alguna de las pestañas se marcó como seleccionada. De lo contrario, se seleccionará la primera pestaña.

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

A continuación, cambia a la pestaña seleccionada. _selectTab() se encarga de marcar todas las demás pestañas como no seleccionadas y ocultar todos los demás paneles.

      this._selectTab(selectedTab);
    }

_allPanels() muestra todos los paneles del panel de pestañas. Esta función podría memorizar el resultado si las consultas del DOM se convierten en un problema de rendimiento. La desventaja de la memorización es que no se controlarán las pestañas y los paneles agregados de forma dinámica.

Este es un método y no un método get, ya que un método get implica que es económico de leer.

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

_allTabs() muestra todas las pestañas del panel de pestañas.

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

_panelForTab() muestra el panel que controla la pestaña determinada.

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

_prevTab() muestra la pestaña que está antes de la seleccionada actualmente y se une cuando llega a la primera.

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

Usa findIndex() para encontrar el índice del elemento seleccionado actualmente y resta uno para obtener el índice del elemento anterior.

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

Agrega tabs.length para asegurarte de que el índice sea un número positivo y haz que el módulo se una si es necesario.

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

_firstTab() muestra la primera pestaña.

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

_lastTab() muestra la última pestaña.

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

_nextTab() obtiene la pestaña que viene después de la seleccionada actualmente y se une cuando llega a la última pestaña.

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

reset() marca todas las pestañas como no seleccionadas y oculta todos los paneles.

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

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

_selectTab() marca la pestaña determinada como seleccionada. Además, muestra el panel correspondiente a la pestaña determinada.

    _selectTab(newTab) {

Anula la selección de todas las pestañas y oculta todos los paneles.

      this.reset();

Obtén el panel con el que está asociado newTab.

      const newPanel = this._panelForTab(newTab);

Si ese panel no existe, aborta.

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

_onKeyDown() controla las pulsaciones de teclas dentro del panel de pestañas.

    _onKeyDown(event) {

Si la pulsación de tecla no se originó en un elemento de pestaña, se trata de una pulsación de tecla dentro de un panel o en un espacio vacío. No hay nada que hacer.

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

No controles los accesos directos de modificadores que suele usar la tecnología de accesibilidad.

      if (event.altKey)
        return;

El switch-case determinará qué pestaña se debe marcar como activa según la tecla que se presionó.

      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;

Se ignora cualquier otra tecla presionada y se pasa al navegador.

        default:
          return;
      }

Es posible que el navegador tenga alguna funcionalidad nativa vinculada a las teclas de flecha, Inicio o Fin. El elemento llama a preventDefault() para evitar que el navegador realice alguna acción.

      event.preventDefault();

Selecciona la pestaña nueva que se determinó en el caso de interruptor.

      this._selectTab(newTab);
    }

_onClick() controla los clics dentro del panel de pestañas.

    _onClick(event) {

Si el clic no se segmentó en un elemento de pestaña, se trata de un clic dentro de un panel o en un espacio vacío. No hay nada que hacer.

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

Sin embargo, si estaba en un elemento de pestaña, selecciona esa pestaña.

      this._selectTab(event.target);
    }
  }

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

howtoTabCounter cuenta la cantidad de instancias de <howto-tab> creadas. El número se usa para generar IDs nuevos y únicos.

  let howtoTabCounter = 0;

HowtoTab es una pestaña para un panel de pestañas <howto-tabs>. <howto-tab> siempre debe usarse con role="heading" en el lenguaje de marcado para que la semántica siga siendo utilizable cuando JavaScript falle.

Un <howto-tab> declara a qué <howto-panel> pertenece usando el ID de ese panel como valor del atributo aria-controls.

Un <howto-tab> generará automáticamente un ID único si no se especifica ninguno.

  class HowtoTab extends HTMLElement {

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

    constructor() {
      super();
    }

    connectedCallback() {

Si se ejecuta, JavaScript está funcionando y el elemento cambia su rol a tab.

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

Establece un estado inicial bien definido.

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

Comprueba si una propiedad tiene un valor de instancia. Si es así, copia el valor y borra la propiedad de instancia para que no oculte el set de propiedades de la clase. Por último, pasa el valor al método set de la propiedad de la clase para que pueda activar los efectos secundarios. Esto se hace para protegerte contra casos en los que, por ejemplo, un framework haya agregado el elemento a la página y establecido un valor en una de sus propiedades, pero haya cargado su definición de forma diferida. Sin esta protección, al elemento actualizado le faltaría esa propiedad y la propiedad de instancia impediría que se llamara al set de propiedades de la clase.

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

Las propiedades y sus atributos correspondientes deben reflejarse entre sí. Para ello, el set de propiedades de selected controla los valores verdaderos o falsos y los refleja en el estado del atributo. Es importante tener en cuenta que no hay efectos secundarios en el set de propiedades. Por ejemplo, el método set no establece aria-selected. En cambio, ese trabajo se realiza en attributeChangedCallback. Como regla general, haz que los set de propiedades sean muy simples y, si configurar una propiedad o un atributo debe causar un efecto secundario (como configurar un atributo ARIA correspondiente), haz ese trabajo en attributeChangedCallback(). Esto evitará tener que administrar situaciones complejas de reinyección de atributos o propiedades.

    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 es un panel para un panel de pestañas <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);
})();