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":
IgaTabPanel
: componente Web encargado del pintado del contenido de cada PanelIgaTabItem
: componente Web encargado de pintar cada Tab seleccionableIgaTab
: 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:
bubbles: true
: para que se propague hacía arriba y así poder usarlo en el componente padredetail: { 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