Componentes de HowTo: Pestañas de instructivos

Resumen

<howto-tabs> limita el contenido visible, ya que lo separa en varios paneles. Solo es visible un panel a la vez, mientras que todas las pestañas correspondientes son siempre 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 respectivas. Las pestañas ahora funcionan como encabezados.

Reference

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>

Escribe código

(function() {

Define códigos de tecla para ayudar a controlar los eventos del teclado.

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

A fin de 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 contenedor para las pestañas y los 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 valores en caché y, por lo tanto, cambian 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 vincularse si necesitan acceso a this.

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

Para una mejora progresiva, el lenguaje de marcado debe alternar entre pestañas y paneles. Los elementos que reordenan sus elementos secundarios tienden a no funcionar bien con los frameworks. En cambio, se usa shadow DOM para reordenar los elementos por medio de 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 nuevos elementos secundarios a medida que vincula las pestañas y el panel de forma semántica con aria-labelledby y aria-controls. Los elementos secundarios nuevos se agregarán automáticamente y harán que se active el cambio de ranura, por lo que no se necesita MutationObserver.

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

connectedCallback() reorganiza las pestañas y los paneles para agruparlos y garantizar que solo una pestaña esté activa.

    connectedCallback() {

El elemento debe realizar cierta administración manual de los eventos de entrada para permitir el cambio con las teclas de flecha y las teclas 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 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 mediante controles de aria y aria-labelledby. Además, el método se asegura de que solo una pestaña esté activa.

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

Otorga 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, ahora estará seleccionada la primera pestaña.

      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 el resto de los 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 las pestañas y los paneles agregados de forma dinámica no se controlarán.

Este es un método, no un método get, ya que un método get implica que su lectura es económica.

    _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 una pestaña determinada.

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

_prevTab() muestra la pestaña que aparece antes de la seleccionada actualmente y se ajusta 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 obtener el módulo correspondiente 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 pestaña 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 correspondiente como seleccionada. Además, se muestra el panel correspondiente a la pestaña correspondiente.

    _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 el newTab.

      const newPanel = this._panelForTab(newTab);

Si 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 la tecla no se originó desde un elemento de tabulación en sí, se trata de una pulsación de tecla dentro de un panel o en un espacio vacío. No tienes que hacer nada.

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

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

      if (event.altKey)
        return;

Las mayúsculas y minúsculas determinarán qué pestaña se debe marcar como activa en función de 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 ignorará cualquier otra presión de teclas y se pasará al navegador.

        default:
          return;
      }

Es posible que el navegador tenga algunas funcionalidades nativas vinculadas a las teclas de flecha, la pantalla principal o el final. El elemento llama a preventDefault() para evitar que el navegador realice acciones.

      event.preventDefault();

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

      this._selectTab(newTab);
    }

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

    _onClick(event) {

Si el clic no se orientó en un elemento de pestaña en sí, fue un clic dentro del panel o en un espacio vacío. No tienes que hacer nada.

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

Una <howto-tab> declara a qué <howto-panel> pertenece mediante el ID de ese panel como el 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 funciona y el elemento cambia su función 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 la instancia para que no oculte el método set de propiedades de la clase. Por último, pasa el valor al método set de propiedades de clase para que pueda activar cualquier efecto secundario. De esta manera, se brinda protección contra los 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 de forma diferida cargó su definición. Sin esta protección, el elemento actualizado perdería esa propiedad, y la propiedad de la instancia evitaría que se llame al método set de propiedad 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í. Para ello, el método set 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 hay efectos secundarios en el método set de la propiedad. Por ejemplo, el método set no establece aria-selected. En cambio, ese trabajo se realiza en attributeChangedCallback. Como regla general, haz que los métodos set de propiedades sean muy tontos y, si configurar una propiedad o un atributo debe causar un efecto secundario (como establecer un atributo ARIA correspondiente), hazlo en attributeChangedCallback(). Esto evitará tener que administrar situaciones complejas de reincorporació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 el panel de 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);
})();