Tab Web component con Slots

Crear un Componente Web para sistemas de Tabs con algunas propiedades customizables
Tab Web component con Slots

He creado un componente web para seguir aprendiendo. Mi anterior entrada iba sobre un componente web para añadir tooltips. En este caso he querido añadir un poco de más de complejidad e interacción

He ido aprendiendo poco a poco, algunas cosas he dudado, así que si lees la entrada completa, o pruebas el código, y ves algo que sea mejorable, estaría encantado que me escribieras. En el footer puedes encontrar diferentes maneras de contactarme :)

Arrancar proyecto con ParcelJS

Sobre la raiz del proyecto, con ParcelJS instalado (Yo tengo instalada la versión 1.12.5)

parcel index.html

HTML del WebComponent

He usado tres componentes para construirlo:

  • <iga-tab>: es la capa contenedora de todo
  • <iga-tab-item>: son cada uno de los ítems del Tab
  • <iga-tab-panel>: son las capas que tendrán el contenido de cada Tab
<iga-tab active="0" justify="space-evenly">
  <div slot="group-tabs" role="tablist">
    <iga-tab-item>
      <span slot="item">This That</span>
    </iga-tab-item>
    <iga-tab-item>
      <span slot="item">That Those</span>
    </iga-tab-item>
    <iga-tab-item>
      <span slot="item">Last Tab</span>
    </iga-tab-item>
  </div>
  <main slot="group-panels" class="tabs__panels">
    <iga-tab-panel>
      <div slot="panel">
        <h1>This is panel 1</h1>
        <p>
          1 Lorem, ipsum dolor sit amet consectetur adipisicing elit. Quia
          deleniti quisquam similique a rerum.
        </p>
        <p>
          2 Lorem ipsum dolot maxime commodi, harum distinctio nulla quibusdam
          dolorum consequatur minus. Quibusdam, sit?
        </p>
      </div>
    </iga-tab-panel>
    <iga-tab-panel>
      <div slot="panel">
        <p>
          3 Lorem, ipsum dolor sit actetur adipisicing elit. Quia deleniti
          quisquam similique a rerum.
        </p>
        <p>
          4 Lorem ipsum dolor sit amet consectetur adipisicing elit. Sint maxime
          commodi, harum distinctio nulla quibusdam dolorum consequatur minus.
          Quibusdam, sit?
        </p>
      </div>
    </iga-tab-panel>
    <iga-tab-panel>
      <div slot="panel"></div>
    </iga-tab-panel>
  </main>
</iga-tab>

Atributos HTML para personalización del Tab

La etiqueta principal admite dos atributos. Uno para la inicialización del Tab Activo active="0", admite valores positivos desde el 0 hasta como máximo el número de Tabs que tengamos. El otro atributo justify="space-evenly" para especificar como queremos la distribución de los ítems del Tab. Admite todos los posibles valores CSS de justify-content, siendo por defecto: justify-content: space-between;

Ítems del Tab

Se especifica mediante:

<iga-tab-item>
  <span slot="item">This That</span>
</iga-tab-item>

Si no se añade el <span slot="item"> el texto que se muestra será "Default Tab"

Contenidos de los Panels

Se especifica mediante:

<iga-tab-panel>
  <div slot="panel">
    <p>Contenido párrafo</p>
  </div>
</iga-tab-panel>

Si no se añade el <div slot="panel"> se mostrará una especie de placeholder. Cualquier contenido debe ir dentro de esta etiqueta y acepta cualquier etiqueta HTML

Código javascript del Tab WebComponent

De aquí en adelante iré haciendo referencia a el javascript del WebComponente, siguiente el orden de más sencillo a más "complejo":

  1. IgaTabPanel: componente Web encargado del pintado del contenido de cada Panel
  2. IgaTabItem: componente Web encargado de pintar cada Tab seleccionable
  3. IgaTab: componente Web padre que se encarga del pintado del Tab y toda su lógica

Component Web para los Panels

Por probar cosas diferentes y así trastear, he decidido crear el shadowDOM en modo cerrado. Haciéndolo así, es necesario crear una referencia para poder acceder al mismo this.shadow = this.attachShadow({ mode: "closed" }); cuando sea necesario

Sobre estilos no hablaré mucho. En este, y en los otros dos componentes, he usado un getter que devuelve una etiqueta <style> con todos los estilos del componente. La regla :host {} hace referencia a sí mismo y siguiendo la cascada normal de CSS

El contenido se añade mediante el método render(), que no es más que llamar a this.shadow.innerHTML = `` para meter dentro etiquetas HTML

class IgaTabPanel extends HTMLElement {
  constructor() {
    super()
    this.shadow = this.attachShadow({ mode: 'closed' })
  }

  connectedCallback() {
    this.render()
  }

  get style() {
    return `
      <style>
      :host {
        grid-column: 1/-1;
        grid-row: 1/-1;

        opacity: 0;
        visibility: hidden;
        transform: scale(0.6);
        transition: all var(--trans-dur) linear var(--trans-del);
      }
      :host([active-panel="true"]) {
        opacity: 1;
        visibility: visible;
        transform: scale(1);
        transition: all var(--trans-dur) linear var(--trans-del);
      }
      </style>
    `
  }

  render() {
    this.shadow.innerHTML = `
      ${this.style}
      <article class="tabs__panel">
        <slot name="panel">Default Panel Content</slot>
      </article>
    `
  }
}

customElements.define('iga-tab-panel', IgaTabPanel)

Component Web para cada ítem selector de Tab

Este tiene algo más de contenido que el anterior, no en vano, tiene que hacer los paneles visibles

No comentaré sobre el CSS y el HTML

class IgaTabItem extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
    this.render()
  }

  static get observedAttributes() {
    return ['tab']
  }
  attributeChangedCallback(name, oldValue, newValue) {
    switch (name) {
      case 'tab':
        this.tab = newValue || 0
        break
    }
  }
  connectedCallback() {
    this.addEventListener('click', this._clickedEvent.bind(this))
  }
  disconnectedCallback() {
    this.removeEventListener('click', this._clickedEvent.bind(this))
  }

  get style() {
    return `
      <style>
      :host button {
        all: unset;
        display: revert;
        box-sizing: border-box;
        width: 100%;
        cursor: pointer;
        padding: 10px 15px;
        color: var(--color-tab-active-foreground, #414141);
        border-radius: 3px;
        outline: 1px solid transparent;
        outline-offset: -3px;
        transition: color var(--trans-dur, .2s) linear var(--trans-del, .2s), outline var(--trans-dur, .2s) linear var(--trans-del, .2s);
      }
      :host button:hover,
      :host button:focus-within {
        outline: 2px solid var(--color-tab-active-background, #5A3A31);
      }
      :host([aria-selected="true"]) {
        pointer-events: none;
      }
      :host([aria-selected="true"]) button {
        cursor: default;
      }
      </style>
    `
  }
  render() {
    this.shadowRoot.innerHTML = `
      ${this.style}
      <button type="button"><slot name="item">Default Tab</slot></button>
    `
  }

  _clickedEvent() {
    this.dispatchEvent(
      new CustomEvent('tab-clicked', {
        bubbles: true,
        detail: { tab: () => this.tab },
      })
    )
  }
}

customElements.define('iga-tab-item', IgaTabItem)

En el constructor de la clase creamos el shadowDOM abierto y añadimos el HTML y CSS con el método render() y el getter style

Desde este componente necesitamos mandar al componente padre cual es el Tab que el usuario ha seleccionado. La forma que he optado para esto ha sido crear un nuevo evento "tab-clicked"

Las opciones que usamos son:

  1. bubbles: true: para que se propague hacía arriba y así poder usarlo en el componente padre
  2. detail: { tab: () => this.tab },*: para enviarle al padre cual fue el elemento clickeado

La referencia al atributo tab todavía no exista, sea crear y asigna valor en el componente padre, que es cuando se conocerá cuantos Tabs tendrá. También se podría crear manualmente en la vista HTML, pero quise dejar más limpio el HTML, y además, trastear lo más posible

_clickedEvent() {
  this.dispatchEvent(
    new CustomEvent("tab-clicked", {
      bubbles: true,
      detail: { tab: () => this.tab },
    })
  );
}

Este evento se envía cuando se hace click sobre algún Tab, este evento se registra dentro de la implementación de connectedCallback()

connectedCallback() {
  this.addEventListener("click", this._clickedEvent.bind(this));
}

Y lo destruímos el evento cuando el componente se elimina

disconnectedCallback() {
  this.removeEventListener("click", this._clickedEvent.bind(this));
}

Como he comentado antes, usamos atributos para poder diferenciar los diferente Tabs. Como estos atributos cambiarán necesitamos especificarlo en este componente

static get observedAttributes() {
  return ["tab"];
}
attributeChangedCallback(name, oldValue, newValue) {
  switch (name) {
    case "tab":
      this.tab = newValue || 0;
      break;
  }
}

Component Web padre del Tab

Por seguir con la idea del componente anterior. En este componente necesitamos conocer que elemento Tab ha clickeado el usuario

Conocer Tab clikeado en componente padre

Añadimos (y eliminamos) listener para el evento "tab-clicked" que nos mandará el número ordinal del componente hijo y que guaramos en una propiedad

Está troceado en varios métodos para poder usar según interese

connectedCallback() {
  this.handlerEvents();
}
handlerEvents() {
  this.addEventListener("tab-clicked", this._clickedEvent.bind(this));
  //
}
removeEvents() {
  this.removeEventListener("tab-clicked", this._clickedEvent.bind(this));
  //
}
_clickedEvent(event) {
  this.active = event.detail.tab();
  //
}

Establecer un Tab activo

Este componente permite la definción del Tab activo mediante la asignación directa como atributo, que por defecto estableco a 0 active = 0;

Contenidos de los Tabs

Para que el componente web tenga sentido, lo normal es que al insertar el componente el usuario pueda definirlo a su gusto, con la cantidad de Tabs que necesite así textos del menú y los panels. Para esto usamos los slots

Para esto he creado un método que recorra los slots e inicialice los atributos en el HTML, que es llamado dentro del constructor

slotsDOM() {
  const slots = this.shadowRoot.querySelectorAll("slot");
  slots.forEach((slot) => {
    slot.addEventListener("slotchange", (event) => {
      const slot = event.target;
      if (slot.name == "group-tabs") {
        this.tabs = [...slot.assignedNodes()[0].children];
        this.tabs.forEach((tab, i) => tab.setAttribute("tab", i));
        if (this.active >= this.tabs.length)
          alert("Has indicado un Tab activo que no existe");
      }

      if (slot.name == "group-panels") {
        this.panels = [...slot.assignedNodes()[0].children];
        this.setActiveTab();
      }
    });
  });
}

Tenemos un listener del evento "slotchange" que detectará que el usuario puso contenido dentro de slots. Los dos slots que nos interesa escuchar son el grupo de "group-tabs" y "group-panels". Guardaremos en dos arrays los Tabs y los Paneles

En este punto asignamos también los atributos tab con valores ordinales para poder indetificarlos

Una vez construido el array de panels, llamamos al método setActiveTab() que irá llamando a otros métodos para pocer activar los Tabs junto a sus animaciones, estos son:

setAttrs() {
  this.setTabAttrs();
  this.setPanelAttrs();
}
setPanelAttrs() {
  this.panels.forEach((panel) => panel.setAttribute("active-panel", false));
  this.panels[this.active].setAttribute("active-panel", true);
}
setTabAttrs() {
  this.tabs.forEach((tab) => tab.setAttribute("aria-selected", false));
  this.tabs[this.active].setAttribute("aria-selected", true);
}
setActiveTab() {
  this.setAttrs();
  this.setAnimations();
}
setAnimations() {
  const [decorWidth, decorHeight, decorOffsetX, decorOffsetY] =
    this.findActiveTabParams();

  this.styleActiveTabBG(decorWidth, decorHeight, decorOffsetX, decorOffsetY);
}
findActiveTabParams() {
  const activeTab = this.tabs[this.active];

  const activeItemWidth = activeTab.offsetWidth;
  const activeItemHeight = activeTab.offsetHeight;

  const activeItemOffsetLeft = activeTab.offsetLeft;
  const activeItemOffsetTop = activeTab.offsetTop;

  return [
    activeItemWidth,
    activeItemHeight,
    activeItemOffsetLeft,
    activeItemOffsetTop,
  ];
}
styleActiveTabBG(decorWidth, decorHeight, decorOffsetX, decorOffsetY) {
  this.activeTabBG.style.width = `${decorWidth}px`;
  this.activeTabBG.style.height = `${decorHeight}px`;
  this.activeTabBG.style.transform = `translate(${decorOffsetX}px, ${decorOffsetY}px)`;
}

Dijimos que la elección del Tab activo podría ser definido al usar el componente. Para esto, tenemos que implementar el método attributeChangedCallback(...), escuchando la propiedad "active"

attributeChangedCallback(name, oldValue, newValue) {
  switch (name) {
    case "active":
      this.active = newValue || 0;
      break;
  }
}

Para poder escucharlo usamos el método estático observedAttributes(), en el devolvemos un array que contenga la propiedad que queramos escuchar

static get observedAttributes() {
  return ["active"];
}

Codepen del Tab Webcomponent

En este PEN puede verse el WebComponente funcionando

Github del Tab Webcomponent

En este repositorio puede verse el código del Webcomponente