Creación de un elemento personalizado desde cero | Programar Plus

En el último artículo, nos ensuciamos las manos con los componentes web al crear una plantilla HTML que está en el documento pero que no se procesa hasta que la necesitamos.

A continuación, continuaremos nuestra búsqueda para crear una versión de elemento personalizado del componente de diálogo a continuación que actualmente solo usa HTMLTemplateElement:

Así que sigamos adelante creando un elemento personalizado que consuma nuestro template#dialog-template elemento en tiempo real.

Serie de artículos:

  1. Introducción a los componentes web
  2. Creación de plantillas HTML reutilizables
  3. Creación de un elemento personalizado desde cero (esta publicación)
  4. Encapsulación de estilo y estructura con Shadow DOM
  5. Herramientas avanzadas para componentes web

Creando un elemento personalizado

El pan y la mantequilla de los componentes web son elementos personalizados. El customElements La API nos brinda una ruta para definir etiquetas HTML personalizadas que se pueden usar en cualquier documento que contenga la clase definitoria.

Piense en ello como un componente React o Angular (p. Ej. ), pero sin la dependencia React o Angular. Los elementos personalizados nativos tienen este aspecto: . Más importante aún, considérelo como un elemento estándar que se puede utilizar en su React, Angular, Vue, [insert-framework-you’re-interested-in-this-week] aplicaciones sin mucho alboroto.

Básicamente, un elemento personalizado consta de dos piezas: una nombre de etiqueta y un clase que extiende el incorporado HTMLElement clase. La versión más básica de nuestro elemento personalizado se vería así:

class OneDialog extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `<h1>Hello, World!</h1>`;
  }
}

customElements.define('one-dialog', OneDialog);

A lo largo de un elemento personalizado, el this value es una referencia a la instancia del elemento personalizado.

En el ejemplo anterior, definimos un nuevo elemento HTML compatible con los estándares, <one-dialog></one-dialog>. No hace mucho … todavía. Por ahora, usando el <one-dialog> en cualquier documento HTML creará un nuevo elemento con un <h1> etiqueta que dice “¡Hola, mundo!”.

Definitivamente vamos a querer algo más robusto y estamos de suerte. En el último artículo, vimos cómo crear una plantilla para nuestro diálogo y, como tendremos acceso a esa plantilla, usémosla en nuestro elemento personalizado. Agregamos una etiqueta de secuencia de comandos en ese ejemplo para hacer algo de magia de diálogo. eliminemos eso por ahora, ya que trasladaremos nuestra lógica de la plantilla HTML al interior de la clase de elemento personalizado.

class OneDialog extends HTMLElement {
  connectedCallback() {
    const template = document.getElementById('one-dialog');
    const node = document.importNode(template.content, true);
    this.appendChild(node);
  }
}

Ahora, nuestro elemento personalizado (<one-dialog>) está definido y se le indica al navegador que represente el contenido de la plantilla HTML donde se llama al elemento personalizado.

Nuestro siguiente paso es mover nuestra lógica a nuestra clase de componentes.

Métodos de ciclo de vida de elementos personalizados

Como React o Angular, los elementos personalizados tienen métodos de ciclo de vida. Ya te han presentado pasivamente connectedCallback, que se llama cuando nuestro elemento se agrega al DOM.

El connectedCallback está separado de la del elemento constructor. Mientras que el constructor se utiliza para configurar los elementos básicos del elemento, el connectedCallback se utiliza normalmente para agregar contenido al elemento, configurar detectores de eventos o inicializar el componente.

De hecho, el constructor no se puede utilizar para modificar o manipular los atributos del elemento por diseño. Si tuviéramos que crear una nueva instancia de nuestro diálogo usando document.createElement, se llamaría al constructor. Un consumidor del elemento esperaría un nodo simple sin atributos ni contenido insertado.

El createElement La función no tiene opciones para configurar el elemento que se devolverá. Es lógico, entonces, que el constructor no debería tener la capacidad de modificar el elemento que crea. Eso nos deja con el connectedCallback como el lugar para modificar nuestro elemento.

Con elementos integrados estándar, el estado del elemento se refleja típicamente por los atributos que están presentes en el elemento y los valores de esos atributos. Para nuestro ejemplo, veremos exactamente un atributo: [open]. Para hacer esto, necesitaremos estar atentos a los cambios en ese atributo y necesitaremos attributeChangedCallback Para hacer eso. Este segundo método de ciclo de vida se llama siempre que uno de los constructores del elemento observedAttributes se actualizan.

Eso puede sonar intimidante, pero la sintaxis es bastante simple:

class OneDialog extends HTMLElement {
  static get observedAttributes() {
    return ['open'];
  }
  
  attributeChangedCallback(attrName, oldValue, newValue) {
    if (newValue !== oldValue) {
      this[attrName] = this.hasAttribute(attrName);
    }
  }
  
  connectedCallback() {
    const template = document.getElementById('one-dialog');
    const node = document.importNode(template.content, true);
    this.appendChild(node);
  }
}

En nuestro caso anterior, solo nos importa si el atributo está establecido o no, no nos importa un valor (esto es similar al HTML5 required atributo en las entradas). Cuando se actualiza este atributo, actualizamos el elemento open propiedad. Una propiedad existe en un objeto JavaScript mientras que un atributo existe en un HTMLElement, este método de ciclo de vida nos ayuda a mantener los dos sincronizados.

Envolvemos el actualizador dentro del attributeChangedCallback dentro de una verificación condicional para ver si el nuevo valor y el valor anterior son iguales. Hacemos esto para evitar un bucle infinito dentro de nuestro programa porque más adelante vamos a crear un captador y definidor de propiedades que mantendrá la propiedad y los atributos sincronizados estableciendo el atributo del elemento cuando la propiedad del elemento se actualice. El attributeChangedCallback hace lo inverso: actualiza la propiedad cuando cambia el atributo.

Ahora, un autor puede consumir nuestro componente y la presencia del open El atributo dictará si el cuadro de diálogo se abrirá o no de forma predeterminada. Para hacerlo un poco más dinámico, podemos agregar captadores y definidores personalizados a la propiedad abierta de nuestro elemento:

class OneDialog extends HTMLElement {
  static get boundAttributes() {
    return ['open'];
  }
  
  attributeChangedCallback(attrName, oldValue, newValue) {
    this[attrName] = this.hasAttribute(attrName);
  }
  
  connectedCallback() {
    const template = document.getElementById('one-dialog');
    const node = document.importNode(template.content, true);
    this.appendChild(node);
  }
  
  get open() {
    return this.hasAttribute('open');
  }
  
  set open(isOpen) {
    if (isOpen) {
      this.setAttribute('open', true);
    } else {
      this.removeAttribute('open');
    }
  }
}

Nuestro getter y setter mantendrá el open atributos (en el elemento HTML) y propiedades (en el objeto DOM) sincronizados. Añadiendo el open el atributo se establecerá element.open a true y ambientación element.open a true agregará el open atributo. Hacemos esto para asegurarnos de que el estado de nuestro elemento se refleje en sus propiedades. Esto no es un requisito técnico, pero se considera una práctica recomendada para la creación de elementos personalizados.

Esto inevitablemente conduce a un poco de repetición, pero crear una clase abstracta que los mantenga sincronizados es una tarea bastante trivial al recorrer la lista de atributos observados y usar Object.defineProperty.

class AbstractClass extends HTMLElement {
  constructor() {
    super();
    // Check to see if observedAttributes are defined and has length
    if (this.constructor.observedAttributes && this.constructor.observedAttributes.length) {
      // Loop through the observed attributes
      this.constructor.observedAttributes.forEach(attribute => {
        // Dynamically define the property getter/setter
        Object.defineProperty(this, attribute, {
          get() { return this.getAttribute(attribute); },
          set(attrValue) {
            if (attrValue) {
              this.setAttribute(attribute, attrValue);
            } else {
              this.removeAttribute(attribute);
            }
          }
        }
      });
    }
  }
}

// Instead of extending HTMLElement directly, we can now extend our AbstractClass
class SomeElement extends AbstractClass { /* Omitted */ }

customElements.define('some-element', SomeElement);

El ejemplo anterior no es perfecto, no tiene en cuenta la posibilidad de atributos como open que no tienen un valor asignado, sino que dependen únicamente de la presencia del atributo. Hacer una versión perfecta de esto estaría más allá del alcance de este artículo.

Ahora que sabemos si nuestro diálogo está abierto o no, agreguemos algo de lógica para mostrar y ocultar:

class OneDialog extends HTMLElement {  
  /** Omitted */
  constructor() {
    super();
    this.close = this.close.bind(this);
    this._watchEscape = this._watchEscape.bind(this);
  }
  
  set open(isOpen) {
    this.querySelector('.wrapper').classList.toggle('open', isOpen);
    this.querySelector('.wrapper').setAttribute('aria-hidden', !isOpen);
    if (isOpen) {
      this._wasFocused = document.activeElement;
      this.setAttribute('open', '');
      document.addEventListener('keydown', this._watchEscape);
      this.focus();
      this.querySelector('button').focus();
    } else {
      this._wasFocused && this._wasFocused.focus && this._wasFocused.focus();
      this.removeAttribute('open');
      document.removeEventListener('keydown', this._watchEscape);
      this.close();
    }
  }
  
  close() {
    if (this.open !== false) {
      this.open = false;
    }
    const closeEvent = new CustomEvent('dialog-closed');
    this.dispatchEvent(closeEvent);
  }
  
  _watchEscape(event) {
    if (event.key === 'Escape') {
        this.close();   
    }
  }
}

Están sucediendo muchas cosas aquí, pero repasemos esto. Lo primero que hacemos es agarrar nuestra envoltura y alternar el .open clase basada en isOpen. Para mantener nuestro elemento accesible, necesitamos alternar el aria-hidden atributo también.

Si el diálogo está abierto, entonces queremos guardar una referencia al elemento enfocado previamente. Esto es para tener en cuenta los estándares de accesibilidad. También agregamos un oyente de keydown al documento llamado watchEscape que hemos ligado al elemento this en el constructor en un patrón similar a cómo React maneja las llamadas a métodos en los componentes de la clase.

Hacemos esto no solo para asegurar la unión adecuada para this.close, pero también porque Function.prototype.bind devuelve una instancia de la función con el sitio de llamada vinculado. Al guardar una referencia al método recién vinculado en el constructor, podemos eliminar el evento cuando se desconecta el cuadro de diálogo (más sobre eso en un momento). Terminamos enfocándonos en nuestro elemento y poniendo el foco en el elemento adecuado en nuestra raíz de sombra.

También creamos un pequeño método de utilidad para cerrar nuestro cuadro de diálogo que envía un evento personalizado que alerta a algún oyente de que el cuadro de diálogo se ha cerrado.

Si el elemento está cerrado (es decir !open), verificamos para asegurarnos de que this._wasFocused La propiedad está definida y tiene un focus y llámelo para devolver el enfoque del usuario al DOM normal. Luego eliminamos nuestro detector de eventos para evitar pérdidas de memoria.

Hablando de limpiar después de nosotros mismos, eso nos lleva a otro método de ciclo de vida: disconnectedCallback. El disconnectedCallback es la inversa de la connectedCallback porque el método se llama una vez que el elemento se elimina del DOM y nos permite limpiar cualquier detector de eventos o MutationObservers adjunto a nuestro elemento.

Da la casualidad de que tenemos algunos oyentes de eventos más para conectar:

class OneDialog extends HTMLElement {
  /* Omitted */
  
  connectedCallback() {    
    this.querySelector('button').addEventListener('click', this.close);
    this.querySelector('.overlay').addEventListener('click', this.close);
  }
  
  disconnectedCallback() {
    this.querySelector('button').removeEventListener('click', this.close);
    this.querySelector('.overlay').removeEventListener('click', this.close);
  }  
}

Ahora tenemos un elemento de diálogo accesible y que funciona bien. Hay algunos pulidos que podemos hacer, como capturar el enfoque en el elemento, pero eso está fuera del alcance de lo que estamos tratando de aprender aquí.

Hay un método de ciclo de vida más que no se aplica a nuestro elemento, el adoptedCallback, que se activa cuando el elemento se adopta en otra parte del DOM.

En el siguiente ejemplo, ahora verá que nuestro elemento de plantilla está siendo consumido por un estándar <one-dialog> elemento.

Otra cosa: componentes no relacionados con la presentación

El <one-template> que hemos creado hasta ahora es un elemento personalizado típico en el sentido de que incluye marcado y comportamiento que se inserta en el documento cuando se incluye el elemento. Sin embargo, no todos los elementos necesitan renderizarse visualmente. En el ecosistema React, los componentes se utilizan a menudo para administrar el estado de la aplicación o alguna otra funcionalidad importante, como <Provider /> en react-redux.

Imaginemos por un momento que nuestro componente es parte de una serie de diálogos en un flujo de trabajo. Cuando se cierra un cuadro de diálogo, debería abrirse el siguiente. Podríamos crear un componente contenedor que escuche nuestro dialog-closed evento y progresa a través del flujo de trabajo.

class DialogWorkflow extends HTMLElement {
  connectedCallback() {
    this._onDialogClosed = this._onDialogClosed.bind(this);
    this.addEventListener('dialog-closed', this._onDialogClosed);
  }

  get dialogs() {
    return Array.from(this.querySelectorAll('one-dialog'));
  }

  _onDialogClosed(event) {
    const dialogClosed = event.target;
    const nextIndex = this.dialogs.indexOf(dialogClosed);
    if (nextIndex !== -1) {
      this.dialogs[nextIndex].open = true;
    }
  }
}

Este elemento no tiene ninguna lógica de presentación, pero sirve como controlador para el estado de la aplicación. Con un poco de esfuerzo, podríamos recrear un sistema de administración de estado similar a Redux usando nada más que un elemento personalizado que podría administrar el estado de una aplicación completa en el mismo que lo hace el contenedor Redux de React.

Esa es una mirada más profunda a los elementos personalizados

Ahora tenemos una comprensión bastante buena de los elementos personalizados y nuestro diálogo está comenzando a reunirse. Pero todavía tiene algunos problemas.

Tenga en cuenta que hemos tenido que agregar algo de CSS para cambiar el estilo del botón de diálogo porque los estilos de nuestro elemento están interfiriendo con el resto de la página. Si bien podríamos utilizar estrategias de nomenclatura (como BEM) para asegurarnos de que nuestros estilos no creen conflictos con otros componentes, existe una forma más amigable de aislar estilos. ¡Revelación! Es shadow DOM y eso es lo que veremos en la siguiente parte de esta serie sobre componentes web.

Otra cosa que debemos hacer es definir una nueva plantilla para cada componente o encontrar alguna forma de cambiar las plantillas de nuestro diálogo. Tal como está, solo puede haber un tipo de diálogo por página porque la plantilla que utiliza debe estar siempre presente. Entonces, o necesitamos alguna forma de inyectar contenido dinámico o una forma de intercambiar plantillas.

En el próximo artículo, veremos formas de aumentar la usabilidad de la <one-dialog> elemento que acabamos de crear incorporando estilo y encapsulación de contenido usando el DOM de sombra.

Serie de artículos:

  1. Introducción a los componentes web
  2. Creación de plantillas HTML reutilizables
  3. Creación de un elemento personalizado desde cero (esta publicación)
  4. Encapsulación de estilo y estructura con Shadow DOM
  5. Herramientas avanzadas para componentes web
(Visited 4 times, 1 visits today)