24 de octubre de 2022

Delegación de eventos

La captura y el propagación nos permiten implementar uno de los más poderosos patrones de manejo de eventos llamado delegación de eventos.

La idea es que si tenemos muchos elementos manejados de manera similar podemos, en lugar de asignar un manejador a cada uno de ellos, poner un único manejador a su ancestro común.

En el manejador obtenemos event.target para ver dónde ocurrió realmente el evento y manejarlo.

Veamos un ejemplo: El diagrama Pa kua que refleja la antigua filosofía china.

Aquí está:

El HTML es este:

<table>
  <tr>
    <th colspan="3"><em>Bagua</em> Chart: Direction, Element, Color, Meaning</th>
  </tr>
  <tr>
    <td class="nw"><strong>Northwest</strong><br>Metal<br>Silver<br>Elders</td>
    <td class="n">...</td>
    <td class="ne">...</td>
  </tr>
  <tr>...2 more lines of this kind...</tr>
  <tr>...2 more lines of this kind...</tr>
</table>

La tabla tiene 9 celdas, pero puede haber 99 o 999, eso no importa.

Nuestra tarea es destacar una celda <td> al hacer clic en ella.

En lugar de asignar un manejador onclick a cada <td> (puede haber muchos), configuramos un manejador “atrapa-todo” en el elemento <table>.

Este usará event.target para obtener el elemento del clic y destacarlo.

El código:

let selectedTd;

table.onclick = function(event) {
  let target = event.target; // ¿dónde fue el clic?

  if (target.tagName != 'TD') return; // ¿no es un TD? No nos interesa

  highlight(target); // destacarlo
};

function highlight(td) {
  if (selectedTd) { // quitar cualquier celda destacada que hubiera antes
    selectedTd.classList.remove('highlight');
  }
  selectedTd = td;
  selectedTd.classList.add('highlight'); // y destacar el nuevo td
}

A tal código no le interesa cuántas celdas hay en la tabla. Podemos agregar y quitar <td> dinámicamente en cualquier momento y el realzado aún funcionará.

Pero hay un inconveniente.

El clic puede ocurrir no sobre <td>, sino dentro de él.

En nuestro caso, si miramos dentro del HTML, podemos ver tags anidados dentro de <td>, como <strong>:

<td>
  <strong>Northwest</strong>
  ...
</td>

Naturalmente, si el clic ocurre en <strong>, este se vuelve el valor de event.target.

En el manejador table.onclick debemos tomar tal event.target e indagar si el clic fue dentro de <td> o no.

Aquí el código mejorado:

table.onclick = function(event) {
  let td = event.target.closest('td'); // (1)

  if (!td) return; // (2)

  if (!table.contains(td)) return; // (3)

  highlight(td); // (4)
};

Explicación:

  1. El método elem.closest(selector) devuelve el ancestro más cercano que coincide con el selector. En nuestro caso buscamos <td> hacia arriba desde el elemento de origen.
  2. Si event.target no ocurrió dentro de algún <td>, el llamado retorna inmediatamente pues no hay nada que hacer.
  3. En caso de tablas anidadas, event.target podría ser un <td>, pero fuera de la tabla actual. Entonces verificamos que sea realmente un <td> de nuestra tabla.
  4. Y, si es así, destacarla.

Como resultado, tenemos un código de realzado rápido y eficiente al que no le afecta la cantidad total de <td> en la tabla.

Ejemplo de delegación: acciones en markup

Hay otros usos para la delegación de eventos.

Digamos que queremos hacer un menú con los botones “Save”, “Load”, “Search” y así. Y hay objetos con los métodos save, load, search… ¿Cómo asociarlos?

La primera idea podría ser asignar un controlador separado para cada botón. Pero hay una solución más elegante. Podemos agregar un controlador para el menú completo y un atributo data-action a los botones con el método a llamar:

<button data-action="save">Click to Save</button>

El manejador lee el atributo y ejecuta el método. Puedes ver el siguiente ejemplo en funcionamiento:

<div id="menu">
  <button data-action="save">Save</button>
  <button data-action="load">Load</button>
  <button data-action="search">Search</button>
</div>

<script>
  class Menu {
    constructor(elem) {
      this._elem = elem;
      elem.onclick = this.onClick.bind(this); // (*)
    }

    save() {
      alert('saving');
    }

    load() {
      alert('loading');
    }

    search() {
      alert('searching');
    }

    onClick(event) {
      let action = event.target.dataset.action;
      if (action) {
        this[action]();
      }
    };
  }

  new Menu(menu);
</script>

Ten en cuenta que this.onClick está ligado a this en (*). Esto es importante, porque de otra manera el this que está dentro haría referencia al elemento DOM (elem), no al objeto Menu, y this[action] no sería lo que necesitamos.

Entonces, ¿qué ventajas nos ofrece la delegación aquí?

  • No necesitamos escribir el código para asignar el manejador a cada botón. Simplemente hacer un método y ponerlo en el markup.
  • La estructura HTML es flexible, podemos agregar y quitar botones en cualquier momento.

Podríamos usar clases .action-save, .action-load, pero un atributo data-action es mejor semánticamente. Y podemos usarlo con reglas CSS también.

El patrón “comportamiento”

También podemos usar delegación de eventos para agregar “comportamiento” a los elementos de forma declarativa, con atributos y clases especiales.

El patrón tiene dos partes:

  1. Agregamos un atributo personalizado al elemento que describe su comportamiento.
  2. Un manejador rastrea eventos del documento completo, y si un evento ocurre en un elemento con el atributo ejecuta la acción.

Comportamiento: Contador

Por ejemplo, aquí el atributo data-counter agrega un comportamiento: “incrementar el valor con un clic” a los botones:

Counter: <input type="button" value="1" data-counter>
One more counter: <input type="button" value="2" data-counter>

<script>
  document.addEventListener('click', function(event) {

    if (event.target.dataset.counter != undefined) { // si el atributo existe...
      event.target.value++;
    }

  });
</script>

Si hacemos clic en un botón, su valor se incrementa. Lo importante aquí no son los botones sino el enfoque general.

Puede haber tantos atributos data-counter como queramos. Podemos agregar nuevos al HTML en cualquier momento. Usando delegación de eventos “extendimos” el HTML, agregando un atributo que describe un nuevo comportamiento.

Para manejadores de nivel de documento: siempre addEventListener

Cuando asignamos un manejador de evento al objeto document, debemos usar siempre addEventListener, no document.on<event>, porque este último causa conflictos: los manejadores nuevos sobrescribirán los viejos.

En proyectos reales es normal que haya muchos manejadores en document, asignados en diferentes partes del código.

Comportamiento: Conmutador (toggle)

Un ejemplo más de comportamiento. Un clic en un elemento con el atributo data-toggle-id mostrará/ocultará el elemento con el id recibido:

<button data-toggle-id="subscribe-mail">
  Show the subscription form
</button>

<form id="subscribe-mail" hidden>
  Your mail: <input type="email">
</form>

<script>
  document.addEventListener('click', function(event) {
    let id = event.target.dataset.toggleId;
    if (!id) return;

    let elem = document.getElementById(id);

    elem.hidden = !elem.hidden;
  });
</script>

Veamos una vez más lo que hicimos aquí: ahora, para agregar la funcionalidad de conmutación a un elemento, no hay necesidad de conocer JavaScript, simplemente usamos el atributo data-toggle-id.

Esto puede ser muy conveniente: no hay necesidad de escribir JavaScript para cada elemento. Simplemente usamos el comportamiento. El manejador a nivel de documento hace el trabajo para cualquier elemento de la página.

Podemos combinar múltiples comportamientos en un único elemento también.

El patrón “comportamiento” puede ser una alternativa a los mini-fragmentos de JavaScript.

Resumen

¡La delegación de eventos es verdaderamente fantástica! Es uno de los patrones más útiles entre los eventos DOM.

A menudo es usado para manejar elementos similares, pero no solamente para eso.

El algoritmo:

  1. Pone un único manejador en el contenedor.
  2. Dentro del manejador revisa el elemento de origen event.target.
  3. Si el evento ocurrió dentro de un elemento que nos interesa, maneja el evento.

Beneficios:

  • Simplifica la inicialización y ahorra memoria: no hay necesidad de agregar muchos controladores.
  • Menos código: cuando agregamos o quitamos elementos, no hay necesidad de agregar y quitar controladores.
  • Modificaciones del DOM: podemos agregar y quitar elementos en masa con innerHTML y similares.

La delegación tiene sus limitaciones por supuesto:

  • Primero, el evento debe “propagarse”. Algunos eventos no lo hacen. Además manejadores de bajo nivel no deben usar event.stopPropagation().
  • Segundo, la delegación puede agregar carga a la CPU, porque el controlador a nivel de contenedor reacciona a eventos en cualquier lugar del mismo, no importa si nos interesan o no. Pero usualmente la carga es imperceptible y no la tomamos en cuenta.

Tareas

importancia: 5

Hay una lista de mensajes con botones para borrarlos [x]. Haz que funcionen.

Como esto:

P.D. Debe haber solamente un event lintener en el contenedor, usa delegación de eventos.

Abrir un entorno controlado para la tarea.

importancia: 5

Crea un árbol que muestre y oculte nodos hijos con clics:

Requerimientos:

  • Solamente un manejador de eventos (usa delegación)
  • Un clic fuera de los nodos de títulos (en un espacio vacío) no debe hacer nada.

Abrir un entorno controlado para la tarea.

La solución tiene dos partes.

  1. Envuelve cada nodo de título del árbol dentro de <span>. Luego podemos aplicarles CSS-style en :hover y manejar los clics exactamente sobre el texto, porque el ancho de <span> es exactamente el ancho del texto (no lo será si no lo tiene).
  2. Establece el manejador al nodo raíz del tree y maneja los clics en aquellos títulos <span>.

Abrir la solución en un entorno controlado.

importancia: 4

Haz que la tabla se pueda ordenar: los clics en elementos <th> deberían ordenarla por la columna correspondiente.

Cada <th> tiene su tipo de datos en el atributo, como esto:

<table id="grid">
  <thead>
    <tr>
      <th data-type="number">Age</th>
      <th data-type="string">Name</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>5</td>
      <td>John</td>
    </tr>
    <tr>
      <td>10</td>
      <td>Ann</td>
    </tr>
    ...
  </tbody>
</table>

En el ejemplo anterior la primera columna tiene números y la segunda cadenas. La función de ordenamiento debe manejar el orden de acuerdo al tipo de dato.

Solamente los tipos "string" y "number" deben ser soportados.

Ejemplo en funcionamiento:

P.D. La tabla puede ser grande, con cualquier cantidad de filas y columnas.

Abrir un entorno controlado para la tarea.

importancia: 5

Crea código JS para el comportamiento “tooltip”.

Cuando un mouse pasa sobre un elemento con data-tooltip, el tooltip debe aparecer sobre él, y ocultarse cuando se va.

Un ejemplo en HTML comentado:

<button data-tooltip="the tooltip is longer than the element">Short button</button>
<button data-tooltip="HTML<br>tooltip">One more button</button>

Debe funcionar así:

En esta tarea suponemos que todos los elementos con data-tooltip solo tienen texto dentro. Sin tags anidados (todavía).

Detalles:

  • La distancia entre el elemento y el tooltip debe ser 5px.
  • El tooltip debe ser centrado relativo al elemento si es posible.
  • El tooltip no debe cruzar los bordes de la ventana. Normalmente debería estar sobre el elemento, pero si el elemento está en la parte superior de la página y no hay espacio para el tooltip, entonces debajo de él.
  • El contenido del tooltip está dado en el atributo data-tooltip. Este puede ser HTML arbitrario.

Necesitarás dos eventos aquí:

  • mouseover se dispara cuando el puntero pasa sobre el elemento.
  • mouseout se dispara cuando el puntero deja el elemento.

Usa delegación de eventos: prepare dos manejadores en el document para rastrear todos los “overs” y “outs” de los elementos con data-tooltip y administra los tooltips desde allí.

Después de implementar el comportamiento, incluso gente no familiarizada con JavaScript puede agregar elementos anotados.

P.D. Solamente un tooltip puede mostrarse a la vez.

Abrir un entorno controlado para la tarea.

Mapa del Tutorial

Comentarios

lea esto antes de comentar…
  • Si tiene sugerencias sobre qué mejorar, por favor enviar una propuesta de GitHub o una solicitud de extracción en lugar de comentar.
  • Si no puede entender algo en el artículo, por favor explique.
  • Para insertar algunas palabras de código, use la etiqueta <code>, para varias líneas – envolverlas en la etiqueta <pre>, para más de 10 líneas – utilice una entorno controlado (sandbox) (plnkr, jsbin, codepen…)