Componentes de HowTo: Pestañas de instructivos

Resumen

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

Al hacer clic o usar las teclas de flecha, el usuario puede cambiar la de la pestaña activa.

Si JavaScript está inhabilitado, se mostrarán todos los paneles intercalados con el pestañas respectivas. Las pestañas ahora funcionan como encabezados.

Referencia

Demostración

Mira una 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 JavaScript no se ejecuta, 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 el analizador con .innerHTML para cada instancia nueva, todas las instancias de <howto-tabs> comparten una plantilla para el contenido del 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 es un elemento de contenedor de 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 tiempo 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 lenguaje de marcado debe alternar entre pestañas y paneles. Los elementos que reordenan a sus elementos secundarios tienden a no funcionar bien con los frameworks. Como alternativa, se utiliza un shadow DOM para reordenar los elementos usando ranuras.

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

Importa la plantilla compartida para crear las ranuras 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 nuevos elementos secundarios, ya que vincula semánticamente las pestañas y el panel con aria-labelledby y aria-controls. Los elementos secundarios nuevos se colocarán automáticamente en ranuras y harán que se active el cambio de espacio, por lo que no se necesita MutationObserver.

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

connectedCallback() agrupa pestañas y paneles reordenando y se asegura de que solo una pestaña esté activa.

    connectedCallback() {

El elemento debe administrar manualmente los eventos de entrada para permitir el cambio con las teclas de flecha y las opciones Inicio / Fin.

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

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

Hasta hace poco, no se activaban los eventos slotchange cuando el analizador actualizaba un elemento. Por este motivo, el elemento invoca al controlador de forma manual. Una vez que el nuevo comportamiento llegue a todos los navegadores, podrás 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 se quita un elemento de uno de los espacios del shadow DOM.

    _onSlotChange() {
      this._linkPanels();
    }

_linkPanels() vincula las pestañas con sus paneles adyacentes utilizando controles ARIA y aria-labelledby. Además, el método garantiza 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 se marcó alguna de las pestañas como seleccionadas. De lo contrario, la primera pestaña ahora está seleccionada.

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

Luego, 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 la lectura es económica.

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

_allTabs() muestra todas las pestañas en el 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 se encuentra antes de la seleccionada actualmente y se une cuando se 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 hacer que el módulo se ajuste 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 aparece después de la seleccionada y se une cuando se 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 el panel no existe, anula la suscripción.

      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 teclas no se originó a partir de un elemento de pestaña en sí, fue una pulsación de teclas dentro del panel o en un espacio vacío. No tienes que hacer nada.

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

No manejes las combinaciones de teclas de modificador que normalmente usa la tecnología de accesibilidad.

      if (event.altKey)
        return;

La tecla switch-case determinará qué pestaña se debe marcar como activa según la tecla que se haya presionado.

      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;

Cualquier otra presión de teclas se ignora y se envía de vuelta 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 acciones.

      event.preventDefault();

Selecciona la pestaña nueva que se determinó en la sentencia switch.

      this._selectTab(newTab);
    }

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

    _onClick(event) {

Si el clic no estaba orientado a un elemento de pestaña en sí, era 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 estuviera en un elemento de pestaña, elígela.

      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>. Siempre se debe usar <howto-tab> con role="heading" en el lenguaje de marcado para que la semántica siga siendo útil cuando falle JavaScript.

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

Una <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 funciona 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');
    }

Verifica si una propiedad tiene un valor de instancia. Si es así, copia el valor y borra la propiedad de la instancia para que no reemplace el método set de propiedades de la clase. Por último, pasa el valor al establecedor de propiedades de la clase para que pueda activar cualquier efecto secundario. De este modo, se brinda protección contra casos en los que, por ejemplo, un framework puede haber agregado el elemento a la página y configurado un valor en una de sus propiedades, pero cargó su definición de forma diferida. Sin esta protección, el elemento actualizado pasaría por alto esa propiedad y la propiedad de la instancia impediría que se llame al método set de propiedades de 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í. A tal efecto, el establecedor de propiedades para selected controla los valores veraces o falsos, y los refleja en el estado del atributo. Es importante tener en cuenta que no se producen efectos secundarios en el método set de propiedades. Por ejemplo, el método set no establece aria-selected. En cambio, ese trabajo se realiza en el attributeChangedCallback. Como regla general, los métodos set de propiedades deben ser muy tontos y, si establecer una propiedad o un atributo debería causar un efecto secundario (como establecer un atributo ARIA correspondiente), hazlo en attributeChangedCallback(). Esto evitará tener que administrar situaciones complejas de reingresió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);
})();