> Manuales > Manual de Lit

Cómo realizar el acceso al DOM local del componente, es decir, a las etiquetas hijas del componente que estamos desarrollando. Veremos cómo acceder mediante Javascript y this y también con métodos específicos de Lit.

Cómo acceder mediante Javascript a los hijos del componente

En este artículo vamos a realizar algunas prácticas medianamente avanzadas para acceder al DOM del contenido del componente. No suele ser demasiado común que tengas que acceder al DOM para hacer cosas con los miembros del componente, pero en ocasiones sí que es necesario. De hecho es un recurso que acaba resultando útil en muchos tipos de componentes. Haremos algún ejemplo para demostrarlo.

Como contenido del componente nos referimos al DOM que tenga dentro de la etiqueta host. Por ejemplo, para un componente que se llamase <mi-lista>, el DOM es lo que podrías tener entre <mi-lista> y </mi-lista>. Allí podrías tener un conjunto de etiquetas, que podrías usar como slot. A veces necesitamos acceder a esas etiquetas hijas con Javascript para hacer cosas en el componente. Si te quedan dudas sigue leyendo para poder entender y ver los ejemplos de código.

Seleccionando elementos a través de this

Lo primero que debes saber es que en el código del componente, la variable this mantiene una referencia al nodo donde está ese componente. A partir de this tenemos varias posibilidades que nos entrega directamente Javascript y que algunas ya hemos tratado.

Por ejemplo, si queremos acceder al shadow DOM, puedes usar la propiedad shadowRoot, que depende de this.

this.shadowRoot

Por ejemplo, esto nos sirve para acceder al DOM del template del componente, para poder luego hacer una consulta y buscar un elemento dado un selector.

this.shadowRoot.querySelector('p');

Esto lo hemos hecho para acceder a elementos del template del componente, el que se especifica por el método render() de Lit, pero además, también podemos acceder a los elementos del DOM local o elementos hijos.

A veces al DOM local se le conoce como "Light DOM" en la jerga anglosajona del desarrollo con Web Components.

Vamos a pensar que al usar un componente tenemos el siguiente marcado:

<dw-menu>
    <p name="op1">Opción <b>uno</b></p>
    <p name="op2">Opción <b>dos</b></p>
    <p name="op3">Opción <b>tres</b></p>
</dw-menu>

Todos esos párrafos no forman parte del shadow DOM, sino que son parte del DOM local de ese componente dw-menu. Si queremos acceder a ellos podríamos usar algo como esto:

this.querySelectorAll('p')

Eso nos permitiría traernos todos los párrafos que hay dentro de la etiqueta dw-menu. En este caso sería un NodeList con 3 elementos párrafo.

También podríamos usar algo como esto.

this.querySelectorAll('*')

En este caso, usando el selector universal, estaríamos recuperando un NodeList de 6 elementos, dado que cada párrafo tiene dentro una etiqueta <b>, que también recuperaríamos con ese selector.

Todo esto es Javascript nativo, el mismo que usamos para acceder al DOM comúnmente. En realidad querySelector() es algo relativo a Javascript más que al estándar de Web Components.

Además, todo esto que estamos comentando sobre this y querySelector() para explicar cómo acceder al DOM local del componente es aplicable a Lit, pero también al estándar de Web Components en general, por lo que sería perfectamente posible hacer lo mismo en un componente nativo de Javascript en el que no usamos ninguna librería.

El siguiente código de un Web Component VanillaJS donde se realiza la selección de los elementos hijo en el constructor sería perfectamente válido:

class DwComponent extends HTMLElement {
  constructor() {
    super();
    console.log(this.querySelectorAll('*'));
  }
}
customElements.define('dw-component', DwComponent);

Seleccionando a través de assignedElements

Ahora vamos a aprender algo interesante también cuando trabajamos con slots, como es el caso de este componente dw-menu, cuyo código veremos al final de este artículo. Se trata de un método que nos permite acceder a los hijos directos que tenemos en un slot.

assignedElements() solo funcionará si esos elementos están usados en algún slot del componente. Si no están usados, puedes acceer a ellos como hemos visto, con this.

Este método assignedElements() viene al rescate en una situación. ¿Qué pasa si nos cambian la etiqueta de los elementos que están en el menú? Por ejemplo tenemos esto:

<dw-menu>
    <div name="op1">Opción <b>uno</b></div>
    <div name="op2">Opción <b>dos</b></div>
    <div name="op3">Opción <b>tres</b></div>
</dw-menu>

Ahora no tenemos párrafos, por lo que this.querySelectorAll('p') no nos daría elementos y this.querySelectorAll('*') nos devolvería más de los que queremos.

La solución es usar assignedElements(). Para ello vamos a ver este código de ejemplo:

get _slottedElements() {
    const mySlot = this.shadowRoot.querySelector('slot');
    return mySlot.assignedElements({flatten: true});
}  

Esta función nos devuelve un array con todos los elementos que son hijos directos del slot. Da igual que sean párrafos a elementos div.

Otra diferencia relevante con respecto a usar this es que el slot solo está disponible cuando el componente se ha inicializado, por lo que podremos usar assignedElements solamente cuando el template ya está renderizado. Para garantizar esta situación podríamos hacer uso a partir del método firstUpdated().

firstUpdated() es un método que pertenece al ciclo de vida de los componentes de Lit, que no hemos abordado todavía. Así que ya explicaremos más detalles de él más adelante.

firstUpdated() {
    console.log(this._slottedElements);
}

Así obtendremos siempre los hijos del slot, independientemente de si son una etiqueta u otra. Y solamente nos llegarán los hijos directos.

Componente dw-menu

Vamos a desarrollar el componente dw-menu, que acabamos de usar antes y que nos servirá para hacer un menú de selección entre varios elementos. Podríamos usarlo en diversas situaciones, como un simple navegador, pero también para hacer cosas más complejas.

En este artículo estableceremos unas bases de este componente con intención de que nos sirva como plataforma de pruebas de acceso al DOM de los hijos, realmente nada serio. Pero este conocimiento nos serviría para luego hacer componentes que usen estas técnicas para trabajos un poco más complejos que seguramente te resultarán de utilidad en el futuro.

Vamos a comenzar con este código de inicio para el componente.

import { LitElement, html, css } from 'lit';

export class DwMenu extends LitElement {
    static styles = [
        css`
            :host {
                display: block;
            }
            ::slotted(*) {
                display: block;
                border-bottom: 1px solid #ddd;
                margin: 0.5rem 0;
                padding-bottom: 0.5rem;
            }
        `
    ];

    constructor() {
        super();
        console.log(this.querySelectorAll('p'));
        console.log(this.querySelectorAll('*'));
    }

    render() {
        return html`
            <div><slot></slot></div>
        `;
    }
}
customElements.define('dw-menu', DwMenu);

Simplemente hemos colocado un constructor que se encarga de ejecutar las dos sentencias de querySelector que hemos señalado antes. Queremos empezar por aquí para comentar que el contenido del componente a partir de this está ya disponible desde la ejecución del constructor del componente.

El componente en sí todavía no hace nada, pero lo vamos a mejorar. La idea ahora es que, al pulsar alguna de las opciones del menú, capturemos el valor de la opción que tiene ese párrafo pulsado, en el atributo "name".

Para almacenar ese valor del menú creamos una propiedad "selected".

static get properties() {
    return {
        selected: { 
            type: String,
            reflect: true,
        }
    };
}

En resumen, nuestro objetivo ahora es saber el "name" del párrafo pulsado. Una primera aproximación para conseguirlo sería la siguiente.

1.- Colocar un manejador de evento click sobre la división.

render() {
    return html`
        <div @click=${this.captureSelected}><slot></slot></div>
    `;
}

2.- Dentro del manejador usar el objeto evento.

captureSelected(e) {
    let elem = e.target;
    this.selected = elem.getAttribute('name');
}

Así accedemos al elemento que ha sido pulsado y luego accedemos al valor de su atributo "name".

Esto solucionaría nuestro objetivo solamente de manera parcial, dado que el párrafo tiene dentro una etiqueta <b>. Si el clic se produce justo en esa etiqueta, no nos valdría, porque el target sería la negrita y no el párrafo. La solución a este problema es un poco compleja.

Quizás demasiado para verla ahora. Si no quieres liarte o ves que me salgo del tema, simplemente salta al próximo artículo.

La propiedad target del objeto evento funciona de esa manera, pero en los manejadores de eventos podemos usar this para acceder al elemento donde está asociado el manejador. Tal como lo hemos desarrollado hasta ahora, poniendo el manejador de manera declarativa en el elemento <div> del template no colabora para solucionarlo, pero podríamos asociar el manejador a los elementos hijos directos del slot, de esta manera:

firstUpdated() {
    let elements = this._slottedElements;
    elements.forEach(elem => {
        elem.addEventListener('click', (e) => {
            this.captureSelected(e)
        });
    })
}

Primero accedo a todos los elementos del menú, luego recorro los elementos y por último asociamos los manejadores de eventos directamente sobre los elementos.

Esto todavía no nos ha arreglado el problema, dado que al usar una función flecha como manejador hemos redefinido el scope de this dentro del manejador, que ahora hará referencia al componente (el elemento de la clase donde estamos programando). Si queremos usar el this haciendo referencia al elemento que ha recibido el evento, tenemos que cambiar de estrategia.

En resumen, en este caso, que es muy concreto y algo avanzado, necesitamos tener dos "this" dentro del manejador de eventos:

  • Un this sería el del componente, para poder cambiar una de sus propiedades.
  • El otro this sería el del elemento al que se le asoció el evento.

Tendremos un código como este:

firstUpdated() {
    let elements = this._slottedElements;
    elements.forEach(elem => {
        let that = this;
        elem.addEventListener('click', function(e) {
            that.selected = this.getAttribute('name');
        });
    })
}

Simplemente "cacheamos" en una variable that el valor de this como componente y luego en vez de una función flecha usamos una function de toda la vida, que sí que nos conserva el scope clásico de this en los manejadores de eventos.

Todavía quedaría más completo el componente si es capaz de avisar a quien quiera que lo use de la nueva opción que ha sido seleccionada.

firstUpdated() {
    let elements = this._slottedElements;
    elements.forEach(elem => {
        let that = this;
        elem.addEventListener('click', function(e) {
            let newSelected = this.getAttribute('name');
            that.selected = newSelected;
            this.dispatchEvent(new CustomEvent('dw-menu-changed', { 
                composed: true,
                bubbles: true,
                detail: {
                    selected: newSelected
                }
            }));
        });
    })
}

El código completo de este componente experimental sería el siguiente:

import { LitElement, html, css } from 'lit';

export class DwMenu extends LitElement {
    static styles = [
        css`
            :host {
                display: block;
            }
            ::slotted(*) {
                display: block;
                border-bottom: 1px solid #ddd;
                margin: 0.5rem 0;
                padding-bottom: 0.5rem;
            }
        `
    ];

    static get properties() {
        return {
        selected: { 
            type: String,
            reflect: true,
        }
        };
    }

    constructor() {
        super();
        console.log(this.querySelectorAll('p'));
        console.log(this.querySelectorAll('*'));
    }

    firstUpdated() {
        let elements = this._slottedElements;
        elements.forEach(elem => {
            let that = this;
            elem.addEventListener('click', function(e) {
                let newSelected = this.getAttribute('name');
                that.selected = newSelected;
                this.dispatchEvent(new CustomEvent('dw-menu-changed', { 
                    composed: true,
                    bubbles: true,
                    detail: {
                        selected: newSelected
                    }
                }));
            });
        })
    }

    get _slottedElements() {
        const mySlot = this.shadowRoot.querySelector('slot');
        return mySlot.assignedElements({flatten: true});
    }    

    render() {
        return html`
            <div><slot></slot></div>
        `;
    }

    captureSelected(e) {
        let elem = e.target;
        this.selected = elem.getAttribute('name');
    }
}
customElements.define('dw-menu', DwMenu);

Conclusión

Llegamos al final del artículo sobre el acceso a los elementos del DOM local del componente. La última parte es un poco avanzada y espero que no te haya dejado con más dudas que las que tenías cuando comenzamos. Afortunadamente pocas son las veces que necesitamos hilar tan fino.

Lo importante que hemos aprendido aquí es:

El resto ha sido un componente que quizás se me ha ido de las manos, por ser más complejo de lo que realmente habría querido para este momento del manual de Lit. Disculpa si lo he liado demasiado. Si es así, vuelve aquí más adelante cuando tengas más experiencia en el desarrollo de componentes y quizás lo entiendas más.

De todos modos, no pasa nada si no lo has entendido todo. Tampoco te preocupes si te asusta que, a veces, la programación se complique. Debes saber que siempre hay modos de resolver las cosas de manera más sencilla. De hecho el componente de menú se podría resolver mejor y más fácil si el menú usase siempre elementos menú hijos que fuesen custom elements también, por ejemplo algo como dw-menu-item. Esos custom elements podrían lanzar eventos personalizados cuando se pulsan, enviando el dato que se necesitaba, lo que resultaría muy sencillo porque les pertenece y lo conocen sin hacer ningún malabarismo.

Quizás veamos esa solución más adelante porque es un ejercicio interesante también. De todos modos, el código propuesto para este componente tendría la ventaja de funcionar con cualquier tipo de etiqueta hijo que se coloque dentro.

Miguel Angel Alvarez

Fundador de DesarrolloWeb.com y la plataforma de formación online EscuelaIT. Com...

Manual