Seleccionar página
web component

Cuando se busca en Internet apenas podemos encontrar ejemplos de componentes web de alguna complejidad. Abundan ejemplos extremadamente sencillos, prácticamente esqueletos, donde es difícil comprender cómo es un desarrollo real. La imagen que se extrae de estos ejemplos es que no se puede hacer mucho con los componentes web, quizás algunas pequeñas piezas para completar nuestra lista de etiquetas. Lo cierto es que hacen poca justicia a la potencialidad que tiene el desarrollo de componentes personalizados.

En este artículo veremos la construcción paso a paso un componente web, completamente real, con cierta complejidad y creado sólo con el estándar soportado por los navegadores modernos. Quizás algunos detalles de la implementación puedan resultar confusos, ya que están destinados a dar respuesta a determinada funcionalidad del componente, creemos que este desarrollo permite comprender cómo se pueden construir este tipo de componentes en la práctica.

Para poder tener una visión completa vamos a ir desde la especificación hasta el desarrollo. Es posible que en algunos momentos vayamos un poco deprisa y no nos paremos a describir algunos de los recursos o técnicas utilizados. El texto, junto con el código, resulta bastante extenso y hemos tenido que ser breves en algunos puntos. Si tienes interés que expliquemos en mayor detalle algo, sólo tienes que poner un comentario al final de artículo y estaremos encantados de completar la información de cualquier punto que haya podido quedar confuso. El código fuente completo se puede obtener en Github.

1. Diseño de la interacción con el usuario

Primero tenemos que definir la interacción con el usuario, es decir, cómo se va a comportar el componente web y cómo va a reaccionar a las distintas acciones del usuario, que reglas básicas tiene que aplicar y cómo va a informar al usuario del estado en que se encuentra. El objetivo es disponer de un control de selección múltiple que parezca un control de lista desplegable, parecido al típico SELECT, pero donde podemos seleccionar más de una opción, sin que por ello tenga que se una lista.

En un primer momento se muestra como una lista desplegable

componente web - cerrado

cuando pulsamos sobre él se abre y muestra las opciones disponibles

componente web - abierto

seleccionamos las opciones, que se van mostrando en el recuadro superior,

componente web - seleccionado

también podemos escribir y filtrar las opciones que se muestran,

componente web - filtrado

y si pulsamos sobre el checkbox superior seleccionaremos todas las opciones disponibles

componente web - todas las opciones seleccionadas

Es importante que el componente se pueda utilizar tanto con el teclado, como con el ratón o en una pantalla táctil, por lo que debe responder correctamente a todas las interacciones del usuario en los diferentes entornos.

2. Nombre del componente web

Tenemos que decidir cuál va a ser el nombre de la clase de nuestro componente web y, lo que es más importante, cómo se va a llamar nuestro custom element, es decir, la etiqueta que van a utilizar los demás para utilizar nuestro componente web. Si nosotros exportamos la clase y no registramos el custom element, podemos permitir a los programadores que importen la clase y que sean ellos quienes elijan con que nombre registrarlo. Esto es bastante interesante en muchos casos, pero no hemos optado por esta posibilidad.

En nuestro caso hemos decidido registrar nosotros el componente web con el nombre TODOJS-MULTISELECT. Recordemos que todos los custom elements deben tener un guion en el nombre, por eso le hemos puesto el prefijo todojs- por ser un componente de www.todojs.com. No es un alarde de creatividad, pero nos permite diferenciar estos componentes de otros.

class TodojsMultiselect extends HTMLElement {
  // Class code
}

// Register the custom element
if (!customElements.get ('todojs-multiselect')) {
  customElements.define ('todojs-multiselect', TodojsMultiselect);
}

3. Cargar el componente web

Como vimos a revisar las diferentes formas de importar un componente web, tenemos que decidir cómo será cargado nuestro componente. Como hemos decidido en el paso anterior que el componente se va a registrar directamente en nuestro código, entonces no tenemos que exportar nada y podemos utilizar import sin más, algo de este tipo:

import './components/todojs-multiselect.js';

o un simple script.

<script src="components/todojs-multiselect.js"></script>

Para que esta última modalidad funcione sin problemas, vamos a contener nuestro código dentro de una sencillas llaves { } que nos permiten definir un alcance limitado a los elementos que vamos a definir (similar a un módulo).

{
  class TodojsMultiselect extends HTMLElement {
    // Class code
  }

  // Register the custom element
  if (!customElements.get ('todojs-multiselect')) {
    customElements.define ('todojs-multiselect', TodojsMultiselect);
  }
}

4. Definir el API de nuestro componente web

Hemos intentado que utilizar el componente web TODOJS-MULTISELECT sea muy sencillo, en la práctica es bastante parecido a utilizar un SELECT estándar del HTML, aunque su comportamiento es diferente, ya que permite hacer una selección múltiple de elementos por medio de un desplegable, su programación debe ser muy similar a los que ya conocemos.

Un ejemplo básico de su interfaz como elemento HTML es:

<pre id="result"></pre>
<todojs-multiselect>
  <option value="1">uno</option>
  <option value="2">dos</option>
  <option value="3">tres</option>
  <option value="4">cuatro</option>
</todojs-multiselect>
<script>
  document
    .querySelector ('todojs-multiselect')
    .addEventListener ('change', (evt) => {
      result.innerHTML = evt.target.value.toString ();
    });
</script>

Vamos a analizar algo más en profundidad cómo se configura y se configura nuestro componente web desde el HTML y el Javascript.

Atributos

El componente, además de los atributos generales que tienen por ser un elemento HTML, acepta los siguientes atributos específicos:

OPEN

Cuando el atributo OPEN está presente, el componente se muestra desplegado. Este atributo aparece cuando se abre el componente por parte del usuario o cuando se abre por medio del método .open().

DISABLED

Cuando el atributo DISABLED está presente, el componente se muestra con un fondo gris y no es posible editarlo o abrirlo. Este atributo está asociado bidireccionalmente con la propiedad .disabled.

TABINDEX

Aunque no se especifique de forma explícita, el componente establece para él mismo un atributo TABINDEX="0". Es posible para el usuario del componente especificar cualquier otro valor para el atributo tabindexy el componente lo respetará. Este atributo es necesario para que el control sea seleccionable, es decir, pueda tener el foco.

Contenido del componente web

Para indicar las opciones que se deben mostrar en el componente web se incluyen en su interior con elementos de tipo OPTION, de forma similar a cómo se haría con un elemento SELECT.

<todojs-multiselect>
  <option value="1" selected>uno</option>
  <option value="2">dos</option>
  <option value="3">tres</option>
  <option value="4">cuatro</option>
</todojs-multiselect>

Aquellos elementos OPTION que están seleccionados tendrán el atributo SELECTED. Si este atributo se cambia directamente, el componente lo refleja automáticamente en la lista de elementos.

Esta lista de elementos está asociada con la propiedad .options, quedando reflejado cualquier cambio en el Light DOM en esa propiedad, y cualquier cambio en la propiedad se reflejará en el Light DOM.

Nota: Si los elementos OPTION no contienen un atributo ID, el componente le asignará uno automáticamente.

Propiedades

.disabled

Por medio de la propiedad .disabled se puede consultar y actualizar el atributo DISABLED. Si es true el componente se muestra con un fondo gris y no es posible editarlo o abrirlo.

.options

Por medio de la propiedad options tenemos acceso (de lectura y escritura) a la matriz de elementos OPTION que hay en el componente. Cada uno de los elementos está definido con un objeto con estas propiedades:

{"id": "one", "value": "1", "text": "uno", "selected": false}

Por medio de la propiedad .options podemos modificar las opciones, añadirlas o borrarlas. Para ello operaremos con la matriz que devuelve esta propiedad, o asignamos una matriz nueva.

<todojs-multiselect></todojs-multiselect>
<script>
  document
    .querySelector ('todojs-multiselect')
    .options = [
      {value: "1", text: "uno"},
      {value: "2", text: "dos"},
      {value: "3", text: "tres"},
      {value: "4", text: "cuatro"}
    ];
</script>

Cualquier cambio que realicemos sobre esta propiedad se ve reflejado en los elementos contenidos en el HTML del componente. De igual forma, cualquier cambio en el contenido del componente se ve reflejado en esta propiedad. Cualquier cambio en las opciones lanzan un evento update para indicar que el contenido del componente ha sido actualizado. Si sólo se modifica la propiedad SELECTED, entonces se lanza el evento change.

Nota: esta propiedad es realmente un Proxy de Javascript, por lo que en algunas herramientas de desarrollo de los navegadores podrá aparecer como tal y no como una matriz. No obstante, podemos operar con ella como matriz sin ninguna limitación.

.value

Por medio de la propiedad .value tenemos acceso (de lectura y escritura) a la matriz de los valores de las opciones que han sido seleccionados.

Cualquier cambio que realicemos sobre esta propiedad se ve reflejado en los elementos contenidos el componente. Si queremos seleccionar o deseleccionar elementos del componente basta con añadirlos o eliminarlos de la matriz de la propiedad .value.

<p><button id="update">update .value</button></p>
<p><todojs-multiselect>
  <option value="1">uno</option>
  <option value="2">dos</option>
  <option value="3">tres</option>
  <option value="4">cuatro</option>
</todojs-multiselect></p>
<script>
  update.addEventListener('click', () => {
    document
      .querySelector ('todojs-multiselect')
      .value.push('1');
  })
</script>

Los cambios en la propiedad .value lanzan un evento change para avisar que se han producido modificaciones en los valores seleccionados.

Nota: esta propiedad es realmente un Proxy de Javascript, por lo que en algunas herramientas de desarrollo de los navegadores podrá aparecer como tal y no como una matriz. No obstante, podemos operar con ella como matriz sin ninguna limitación.

Métodos

.open()

El método .open() muestra el componente de forma abierta y, como consecuencia, añade el atributo OPEN al componente.

.close()

El método .close() cierra el componente si estaba en forma abierta y, como consecuencia, elimina el atributo OPEN del componente.

Eventos

update

El evento update se lanza cuando se añaden o cambian las opciones del componente.

change

El evento change se lanza cuando se cambia la lista de valores seleccionados.

open

El evento open se lanza cuando se despliega el componte para mostrar las opciones que se pueden seleccionar.

close

El evento close se lanza cuando se cierra el componte para mostrar su vista compacta con la lista de valores seccionados.

<pre id="result"></pre>
<p><button id="remove">remove</button></p>
<script>
  const component = document.createElement ('todojs-multiselect');

  component.addEventListener('remove', () => result.innerText += 'remove event\n');
  component.addEventListener('update', () => result.innerText += 'update event\n');
  component.addEventListener('change', () => result.innerText += 'change event\n');
  component.addEventListener('open',   () => result.innerText += 'open event\n');
  component.addEventListener('close',  () => result.innerText += 'close event\n');

  component.options = [
    {value : "1", text : "uno"},
    {value : "2", text : "dos"},
    {value : "3", text : "tres"},
    {value : "4", text : "cuatro"}
  ];

  document
    .querySelector('#remove')
    .addEventListener(
      'click',
      () => document.body.removeChild(component)
    );

  document.body.appendChild(component);
</script>

Ahora que hemos dado un repaso a cómo es la interfaz de programación de nuestro componente vamos a analizar un cómo ha sido implementado y que técnicas hemos utilizado en cada caso.

5. Light DOM, Shadow DOM y gestión del estado

En nuestro componente web hemos definido que se pueden cargar las opciones por medio de etiquetas OPTION. Esto tiene varias consecuencias, la primera es que vamos a diferenciar un Light DOM que incluye la etiqueta del componente, sus atributos y las opciones, y un Shadow DOM que contendrá los elementos que se visualizan del componente, como la lista con las opciones, el campo de búsqueda, etc. En nuestro caso no vamos a utilizar slot, no tiene mucho sentido en nuestros requisitos.

Es importante decidir cómo vamos a gestionar el estado del componente web, es decir, en que forma vamos a almacenar las opciones que se han cargado y aquellas que se han seleccionado, si el componente está en estado abierto o cerrado, cual es el filtro que está realizando el usuario, etc. Toda esta información podemos guardarla en propiedades de la clase, pero también podemos utilizar el propio Light DOM para mantener este estado. Al fin y al cabo, las propiedades del componente y su contenido pueden servir perfectamente para este fin y esta es la opción que hemos seleccionado. Si un programador incluye elementos OPTION en el componente, está añadiendo información al estado. De igual forma, si consulta o modifica los atributos, por ejemplo, OPEN está cambiando el estado del componente.

Para ser sinceros, sí hay un valor de estado que vamos a gestionar como una propiedad interna del componente web, ya que no es relevante fuera del nuestro contexto interno. Esta variable se encarga de evitar una actualización en bucle desde el Light DOM al Shadow DOM y de este de nuevo al Light DOM sin fin. Para evitar que sea visible como propiedad del componente, vamos a utilizar un Symbol para su nombre:

// private property for avoid circular update
this[ RECURSIVE_REFRESH ] = false;

6. Observar los cambios en el Light DOM

Para que podamos responder adecuadamente a los cambios que se produzcan en el Light DOM, por ejemplo, cuando un usuario añade un nuevo OPTION o cambia la propiedad SELECTED de una de las opciones, tenemos que observar los cambios que se produzcan en el contenido del componente web. Para ello la forma más eficiente es utilizar un MutationObserver, que nos permite definir que queremos observar, sobre que elemento y ejecuta nuestra función pasándole todos los elementos que han cambiado.

Este MutationObserver podemos definirlo en el constructor de nuestro componente web, ya que está dirigido hacia nuestro Light DOM y no cambia cada vez que somos conectados o desconectados del DOM anfitrión. El constructor de nuestro componente quedaría de esta forma:

constructor () {
  super ();
  
  // private property for avoid circular update
  this[ RECURSIVE_REFRESH ] = false;
  
  // Create Shadow DOMs
  this.attachShadow ({mode : 'open'});
  
  // Observe Light DOM changes
  new MutationObserver (
    (mutations) => {
      let refresh = false;
      let update  = false;
      let change  = false;
      for (let mutation of mutations) {
        if (mutation.attributeName !== 'class') {
          refresh = true;
        }
        if (mutation.attributeName === 'selected') {
          change = true;
        }
        if (mutation.type === 'childList') {
          update = true;
        }
      }
      if (refresh) {
        this[ RENDER_REFRESH ] ();
      }
      if (update) {
        this.dispatchEvent (new Event ("update"));
      }
      if (change) {
        this.dispatchEvent (new Event ("change"));
      }
    }
  ).observe (
    this,
    {
      subtree         : true,
      attributes      : true,
      attributeFilter : [ 'selected' ],
      childList       : true,
      characterData   : true
    }
  );
  
  this[ RENDER_INITIAL ] ();
  
}

Como se puede ver, desde la función que es llamada en el MutationObserver emitimos los eventos update cuando hay cambios en las opciones y change cuando hay cambio en los valores. Esto lo podemos hacer desde ahí ya que hemos establecido como base de nuestro estado el Light DOM y si este cambia, debemos emitir los eventos correspondientes.

7. Observar los cambios en los atributos

Aunque podemos observar los cambios en los atributos de nuestro componente web por medio de MutationObserver, hemos decidido variar un poco y observar los cambios en nuestros atributos por medio de los mecanismos que nos ofrecen los HTMLElement. Para ello hemos tenido que crear un método estático con el nombre observedAttributes() que devuelve los nombres de las propiedades que queremos observar (OPEN y DISABLED) y un método attributeChangedCallback() que será invocado cuando se produzcan cambios en esos atributos.

Por una parte, tenemos que gestionar la propiedad DISABLED que debe hacer que nuestro componente web no sea seleccionable, por lo que le quitamos el atributo TABINDEX y el atributo OPEN para que se cierre (si es que estaba abierto).

Por otro lado, gestionamos la propiedad OPEN, que es muy importante para nuestro componente web, ya que establece cómo se visualiza, si colapsado o desplegado. Como veremos más adelante cuando repasemos el CSS que utilizamos, veremos que la presencia de este atributo es fundamental para que todo funcione como nos interesa.

static get observedAttributes () {
  return [ 'open', 'disabled' ];
}
attributeChangedCallback (name, oldValue, newValue) {
  if (name === 'disabled') {
    if (newValue === '') {
      this.removeAttribute('open');
      this.setAttribute('last-tabindex', this.getAttribute('tabindex'));
      this.removeAttribute('tabindex');
    } else {
      this.setAttribute('tabindex', this.getAttribute('last-tabindex') || '0');
    }
  } else if (name === 'open') {
    if (newValue === '') {
      if (this.hasAttribute ('disabled')) {
        this.removeAttribute ('open');
      } else {
      this.shadowRoot.querySelectorAll ('#checkboxes p.hidden').forEach (p => {
        p.classList.remove ('hidden');
      });
      const search =  this.shadowRoot.querySelector ('#search');
      search.value = '';
      search.focus ();
      this.dispatchEvent (new Event ("open"));
      }
    } else {
      this.dispatchEvent (new Event ("close"));
    }
  }
}

8. Conectar al DOM anfitrión

Hay algunas operaciones que no podemos hacer hasta que no estemos conectados el componente web a un DOM anfitrión, ya que realizarlas antes de ese momento lanza un error o puede provocar un comportamiento no deseado. Una de ellas es crear el atributo TABINDEX que necesitamos para que nuestro componente pueda obtener el foco. Lo establecemos inicialmente con valor 0, a no ser que se haya indicado algún otro valor previamente, y siempre y cuando el componente no esté deshabilitado.

connectedCallback () {
  // Allow focus
  if (!this.hasAttribute('disabled') &&
      !this.hasAttribute('tabindex') )
  {
    this.setAttribute ('tabindex', '0');
  }
}

9. Gestionar las propiedades

.disabled

La implementación de esta propiedad es muy sencilla, realmente sólo tiene que cambiar o consultar el atributo del componente, ya que toda la operativa de desactivación y activación del componente se realiza en el método attributeChangedCallback()

get disabled () {
  return this.hasAttribute ('disabled');
}
set disabled (newValue) {
  if (newValue) {
    this.setAttribute ('disabled', 'true')
  } else {
    this.removeAttribute ('disabled')
  }
}

.options

Para poder gestionar esta propiedad de forma adecuada tenemos que echar mano de características interesantes de Javascript, como son los Proxy para la matriz de opciones y un conjunto de get y set para gestionar los cambios en una opción concreta. Estas dos técnicas de observación sobre cambios en los objetos son muy útiles en este tipo de situaciones. El Proxy nos va a ayudar a controlar todas las operaciones con la matriz de opciones, de esta forma podemos capturar las operaciones de consulta y modificación del contenido de la matriz. Si se añaden nuevos elementos podremos crearlos en el Light DOM, si se eliminan, los podremos borrar igualmente. Cuando se haya obtenido el objeto correspondiente a una opción, cualquier cambio sobre su valor, texto o estado de selección lo controlaremos por medio de getter y setter sobre las propiedades.

get options () {
  const that = this;
  return new Proxy ([], {
    get (target, property) {
      if (property === 'length') {
        return that.querySelectorAll ('option').length;
      }
      if (property === Symbol.iterator) {
        const result = [ ...that.querySelectorAll ('option') ]
          .map (getOption);
        return result[ Symbol.iterator ];
      }
      if (typeof property === 'string' && isNaN (parseInt (property))) {
        return Reflect.get (target, property);
      }
      return getOption (that.querySelectorAll ('option')[property]);
    },
    set (target, property, value) {
      if (property === 'length') {
        return true;
      }
      const options = that.querySelectorAll ('option');
      let option    = options[property];
      let newOption = false;
      if (!option) {
        option    = document.createElement ('option');
        newOption = true;
      }
      option.id = value.id || uuid ();
      value.value ? option.value = value.value : undefined;
      option.innerText = value.text || '';
      if (value.selected) {
        option.setAttribute ('selected', 'true');
      } else {
        option.removeAttribute ('selected');
      }
      if (newOption) {
        that.appendChild (option);
      }
      return true;
    },
    has (target, property) {
      if (typeof property === 'symbol' ||
          (typeof property === 'string' &&
           isNaN (parseInt (property))
          )
      )
      {
        return Reflect.has (target, property);
      }
      const options = that.querySelectorAll ('option');
      return !!options[property];
    },
    deleteProperty (target, property) {
      const options = that.querySelectorAll ('option');
      const option  = options[property];
      const element = getOption (option);
      option.parentElement.removeChild (option);
      return element;
    }
  });
  // local function that return a single option observed object
  function getOption (el) {
    return {
      get id () {
        return el.id
      },
      set id (newId) {
        return el.id = newId;
      },
      get value () {
        return el.value
      },
      set value (newValue) {
        return el.value = newValue
      },
      get text () {
        return el.innerText || undefined;
      },
      set text (newText) {
        return el.innerText = newText;
      },
      get selected () {
        return el.hasAttribute ('selected');
      },
      set selected (newSelection) {
        if (newSelection) {
          el.setAttribute ('selected', 'true');
        } else {
          el.removeAttribute ('selected');
        }
      }
    };
  }
}
set options (values) {
  let child      = this.lastElementChild;
  while (child) {
    this.removeChild (child);
    child = this.lastElementChild;
  }
  values.forEach ((value) => this.options.push (value));
}

.value

Esta propiedad se implementa también como un Proxy sobre una matriz, que permite gestionar los valores seleccionados, tanto en consulta, como si queremos modificar por programa los valores seleccionados en el componente web. Como en el resto de situaciones, el estado lo estamos gestionando en el DOM, y por lo tanto, si se modifica o se añade un nuevo elemento de la matriz se comprueba si alguno de los valores está recogido entre las opciones que tiene el componente, y si es así se cambia su estado a <code>SELETED</code> dentro de Light DOM.

get value () {
  const that = this;
  return new Proxy ([], {
    get (target, property) {
      if (property === 'length') {
        return that.querySelectorAll ('option[selected]').length;
      }
      if (property === Symbol.iterator) {
        const result = [ ...that.querySelectorAll ('option[selected]') ]
          .map (e => e.value);
        return result[ Symbol.iterator ];
      }
      if (typeof property === 'string' && isNaN (parseInt (property))) {
        return Reflect.get (target, property);
      }
      return that.querySelectorAll ('option[selected]')[property].value;
    },
    set (target, property, value) {
      if (property === 'length') {
        return true;
      }
      that.querySelector (`option[value="${ value }"]`).setAttribute ('selected', 'true');
      return true;
    },
    has (target, property) {
      if (typeof property === 'symbol' ||
          (typeof property === 'string' &&
           isNaN (parseInt (property))
          )
      )
      {
        return Reflect.has (target, property);
      }
      return !!that.querySelectorAll ('option[selected]')[property];
    },
    deleteProperty (target, property) {
      if (typeof property === 'symbol' ||
          (typeof property === 'string' &&
           isNaN (parseInt (property))
          )
      )
      {
        return Reflect.deleteProperty (target, property);
      }
      that.querySelectorAll ('option[selected]')[property].removeAttribute ('selected');
      return true;
    }
  });
}
set value (values) {
  const options  = this.querySelectorAll ('option');
  for (let n = 0; n < options.length; n++) {
    if (values.indexOf (options[ n ].value) === -1) {
      options[ n ].removeAttribute ('selected');
    } else {
      options[ n ].setAttribute ('selected', 'true');
    }
  }
}

 

10. Los métodos

Los dos métodos que tiene el componente web se implementan de forma muy sencilla, ya que simplemente tienen que añadir o eliminar el atributo OPEN del elemento,

open () {
  this.setAttribute ('open', 'true');
}

close () {
  this.removeAttribute ('open');
}

11. Renderización del contenido

Para crear el contenido del Shadow DOM, se ha utilizado fundamentalmente plantillas basadas en Template String. Cómo hemos establecido como criterio base que sólo vamos a utilizar los elementos estándar, no nos hemos apoyado en ninguna librería de renderizado. Nuestro modelo se basa en dos métodos de nuestra clase, en vez de hacerlo todo de una vez y tener que volver a regenerar todo el contenido cada vez que se cambian los datos. Hemos establecido dos fases de renderizado, que nos permite gestionar con mayor precisión lo que queremos hacer, con una primera fase donde creamos todo el contenido base del componente web, es decir, el estilo, las cajas principales, el desplegable, los iconos. Si no hiciéramos nada más, el componente tendría un comportamiento bastante completo, aunque no mostraría datos. En la segunda fase de renderizado cargamos los datos para cada opción y la establecemos como marcada o no marcada.

Como estos métodos no tienen porqué ser llamados desde fuera de nuestro componente web, y por lo tanto lo podemos considerar privados, hemos optado por utilizar unos Symbol() para gestionar su visibilidad. Ya sabemos que no es la mejor forma de proteger nuestros elementos privados, pero al menos evita que de forma descuidada un programador que haga uso de nuestro componente llame a los métodos de renderización de forma explícita.

Inicial

Como hemos indicado, esta construcción inicial incorpora todos los elementos estables del componente web, que incluye la hoja de estilos, la caja principal donde se muestran los valores, el icono de apertura y cierre, el desplegable y la caja de búsqueda. El aspecto del componente abierto tras esta fase sería similar a este:

 

Especial interés tiene la parte de los estilos, donde se ha incluido la carga de una fuente por medio de una etiqueta LINK, aunque se podría hacer de otra forma, nos ha parecido un buen ejemplo de lo que se puede hacer y nos sirve para tener una fuente por defecto bastante atractiva. También disponemos de una buena cantidad de estilos CSS para gestionar los diferentes elementos y estados del componente web. En concreto, el selector :host([open]) #dropdown es muy importante. En vez de gestionar la apertura y cierre del componente por medio de Javascript, añadiendo o quitando clases, hemos optado por utilizar este selector para responder a la presencia del atributo OPEN en el componente. Cuando está presente el componente se muestra desplegado, cuando no está presente se muestra cerrado y la lista de elementos está oculta.

    // create the base shadow DOM content
    [ RENDER_INITIAL ] () {
      
      // Shadow DOM Style
      this.shadowRoot.innerHTML = `
<link rel="stylesheet"
      href="//fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic">
<style>
  :host {
    display            : inline-block;
    position           : relative;
    font-family        : Roboto, Arial, sans-serif;
    width              : 24em;
    color              : #000;
    background-color   : #FFF;
  }
  :host([disabled]) #selection #selected {
    background-color   : var(--todojs-disabled-bg-color, lightgray);
    mix-blend-mode     : multiply;
  }
  #selection {
    position           : relative;
    cursor             : pointer;
    min-width          : 14em;
    width              : 100%;
    height             : 2em;
    border             : 1px solid lightgray;
    background-color   : inherit;
  }
  #selection #selected {
    position           : absolute;
    top                : 0;
    left               : 0;
    right              : 0;
    bottom             : 0;
    padding            : 0.4em 30px 0.4em 0.4em;
    white-space        : nowrap;
    overflow           : hidden;
    text-overflow      : ellipsis;
  }
  #selection #selected:after {
    position           : absolute;
    content            : "";
    top                : calc(1em - 3px);
    right              : 10px;
    width              : 0;
    height             : 0;
    border-width       : 6px;
    border-style       : solid;
    border-color       : #000 transparent transparent transparent;
  }
  :host([open]) #selection #selected:after {
    border-color       : transparent transparent #000 transparent;
    top                : calc( 1em - 8px);
  }
  #dropdown {
    display            : none;
    left               : 0;
    right              : 0;
    top                : auto;
    width              : 100%;
    min-width          : 14em;
    max-height         : 14em;
    overflow-x         : hidden;
    overflow-y         : auto;
    position           : absolute;
    background-color   : inherit;
    border             : solid lightgray;
    border-width       : 0 1px 1px 1px;
    z-index            : 20;
  }
  :host([open]) #dropdown {
    display            : block;
  }
  #search {
    position           : absolute;
    height             : 1.6em;
    width              : calc( 100% - 36px - 1em);
    margin-top         : -0.35em;
    border             : 0;
    color              : inherit;
    background-color   : inherit;
    font-family        : inherit;
    font-size          : inherit;
  }
  #search:focus {
    outline            : none;
  }
  #search::-webkit-input-placeholder,
  #search::placeholder {
    color              : inherit;
    opacity            : 0.4;
    font-style         : italic;
  }
  #dropdown .group {
    display            : block;
    position           : relative;
    height             : 1em;
    margin             : 0;
    padding            : 0.5em 2em;
    font-size          : 1em;
    -webkit-user-select: none;
    -moz-user-select   : none;
    -ms-user-select    : none;
    user-select        : none;
   }
  #dropdown .group.hidden {
    display            : none;
  }
  #dropdown .group .option {
    position           : absolute;
    opacity            : 0;
    cursor             : pointer;
    height             : 0;
    width              : 0;
  }
  #dropdown .group .mark {
    cursor             : pointer;
    position           : absolute;
    top                : 0.5em;
    left               : 0.5em;
    height             : 1em;
    width              : 1em;
    background-color   : var(--todojs-mark-bg-color, #eee);
    z-index            : 20;
  }
  #dropdown .group .option:checked ~ .mark {
    background-color   : var(--todojs-mark-checked-bg-color, #2196F3);
  }
  #dropdown .group:hover .option ~ .mark ~ .label {
    background-color   : var(--todojs-label-hover-bg-color, #eee);
  }
  #dropdown .group .option:focus ~ .mark ~ .label {
    background-color   : var(--todojs-label-focus-bg-color, lightgrey);
  }
  #dropdown .group .mark:after {
    content            : "";
    position           : absolute;
    display            : none;
  }
  #dropdown .group .option:checked ~ .mark:after {
    display            : block;
  }
  #dropdown .group .mark:after {
    left               : 0.35em;
    top                : 0.1em;
    width              : 0.2em;
    height             : 0.5em;
    border-style       : solid;
    border-color       : var(--todojs-mark-color, white);
    border-width       : 0 2px 2px 0;
    -webkit-transform  : rotate(45deg);
    -ms-transform      : rotate(45deg);
    transform          : rotate(45deg);
  }
  #dropdown .group .label {
    cursor             : pointer;
    display            : block;
    position           : absolute;
    height             : 1em;
    top                : 0;
    left               : 0;
    right              : 0;
    padding            : 0.5em 2em;
  }
  #dropdown .group.select-all {
    border-bottom      : 1px dotted darkgray;
  }
</style>
<div id="selection">
  <div id="selected"></div>
</div>
<div id="dropdown">
  <p class="group select-all">
    <input type="checkbox" id="selectAll" class="option">
    <label for="selectAll" class="mark"></label>
    <input type="search" id="search" placeholder="escribe aquí para filtrar">
  </p>
  <div id="checkboxes"></div>
</div>`;

Una vez cargados los estilos y creado el HTML básico, el método define una serie de eventos, especialmente de teclado y de ratón, para controlar las interacciones del usuario con el componente web. De esta forma, en estos eventos, podemos saber si se ha pulsado las teclas de flecha para subir o bajar en la selección de elementos, si se está escribiendo en el campo de búsqueda, si se pulsa la tecla ESC para salir, etc. De igual forma se controla la salida del componente por medio del evento blur y podemos cerrarlo al salir.

      const selection  = this.shadowRoot.querySelector ('#selection');
      const dropdown   = this.shadowRoot.querySelector ('#dropdown');
      const search     = this.shadowRoot.querySelector ('#search');
      const checkboxes = this.shadowRoot.querySelector ('#checkboxes');
      const selectAll  = this.shadowRoot.querySelector ('#selectAll');
      
      // close when blur
      this.addEventListener ('blur', () => {
        this.removeAttribute ('open');
      });
      
      // open with enter or arrow down
      this.addEventListener ('keydown', (evt) => {
        if ((evt.key === 'Enter' ||
             evt.key === 'ArrowDown') &&
            !this.hasAttribute ('open'))
        {
          this.setAttribute ('open', 'true');
        } else if (evt.key === 'Escape') {
          this.removeAttribute ('open');
        }
      });
      
      // open with mouse
      selection.addEventListener ('click', () => {
        if (this.hasAttribute ('open')) {
          this.removeAttribute ('open');
        } else {
          this.setAttribute ('open', 'true');
        }
      });
      
      
      // filter
      search.addEventListener ('keyup', (evt) => {
        if (evt.key === 'Enter') {
          selectAll.click ();
          return evt.preventDefault();
        }
        filter ();
      });
      search.addEventListener ('change', filter);
      search.addEventListener ('search', filter);
      const that = this;
      function filter () {
        const paragraphs = checkboxes.querySelectorAll ('p');
        const text       = search.value.toLowerCase ();
        for (let n = 0; n < paragraphs.length; n++) {
          const p = paragraphs[ n ];
          if (p.innerText.toLowerCase ().search (text) === -1) {
            p.classList.add ('hidden');
          } else {
            p.classList.remove ('hidden');
          }
        }
        that[ RENDER_REFRESH_SELECTALL ] ();
      }
      
      // dropdown keys
      dropdown.addEventListener ('keydown', (evt) => {
        if (evt.key === 'ArrowDown') {
          const focus = dropdown.querySelector ('input:focus');
          if (focus) {
            const next = focus.parentElement.nextElementSibling;
            if (next) {
              next.querySelector ('input').focus ();
            } else {
              search.focus ();
            }
          } else {
            dropdown.querySelector ('input:nth-of-type(2)').focus ();
          }
          evt.preventDefault ();
        } else if (evt.key === 'ArrowUp') {
          const focus = dropdown.querySelector ('input:focus');
          if (focus) {
            if (focus === search) {
              checkboxes.querySelector ('p:last-of-type input').focus ();
            } else {
              const previous = focus.parentElement.previousElementSibling;
              if (previous) {
                previous.querySelector ('input').focus ();
              } else {
                search.focus ();
              }
            }
          } else {
            checkboxes.querySelector ('p:last-of-type input').focus ();
          }
          evt.preventDefault ();
        } else if (evt.key === 'Enter' && evt.target.type === "checkbox") {
          evt.target.click();
        }
      });
      
      // Dropdown click
      dropdown.addEventListener ('click', (evt) => {
        if (evt.target.type === 'checkbox') {
          this[ RECURSIVE_REFRESH ] = true;

          if (evt.target.id === 'selectAll') {
            const checked = evt.target.checked;
            checkboxes.querySelectorAll ('p:not(.hidden) input.option').forEach (i => {
              i.checked = checked;
              if (checked) {
                this.querySelector (`#${ i.id }`).setAttribute ('selected', 'true');
              } else {
                this.querySelector (`#${ i.id }`).removeAttribute ('selected');
              }
            });
            evt.cancelBubble = true;
            return;
          }

          if (evt.target.checked) {
            this.querySelector (`#${ evt.target.id }`).setAttribute ('selected', 'true');
          } else {
            this.querySelector (`#${ evt.target.id }`).removeAttribute ('selected');
          }
          evt.cancelBubble = true;  // Cancel bubble event
        }
      });
      
      this[ RENDER_REFRESH ] ();
      
    }

Actualización de datos

Una vez hemos construido la base de nuestro componente web, vamos a cargar las opciones que se han configurado en el componente, mostrándolas seleccionadas o sin seleccionar. Para ello entramos en la segunda fase de renderización, la que se encarga de la actualización de los datos. Este método es llamado al inicio en la carga, pero también siempre que se cambian datos en el Light DOM.. Volvemos a recordar que hemos decidido mantener el estado de nuestro componente en el DOM y por lo tanto son los cambios en este los que deben lanzar la actualización del Shadow DOM, es decir, del contenido visible del componente.

En este caso los elementos se han creado por medio de document.createElement(), y en algunos casos con .innerHTML y una Template String. Son diversos los factores que hacen más cómodo el uso de una u otra técnica, pero en cualquier caso pueden convivir sin problema alguno. Lo que sí se ha evitado de forma explícita es la asignación de gestores de eventos sobre estos elementos que cambian muy a menudo. Toda la gestión de eventos se realiza en la estructura fija del contenido del componente. Es mucho más sencillo, además de algo más eficiente.

[ RENDER_REFRESH ] () {

  // Elements
  const selected   = this.shadowRoot.querySelector ('#selected');
  const options    = this.querySelectorAll ('option');
  const checkboxes = this.shadowRoot.querySelector ('#checkboxes');
  
  // Update
  let text        = '';
  for (let n = 0; n < options.length; n++) {
    text += (options[ n ].hasAttribute ('selected') ? options[ n ].innerText + '; ' : '');
  }
  selected.innerText = text;
  
  // Avoid circular update (from shadow DOM to light DOM to shadow DOM)
  if (this[ RECURSIVE_REFRESH ]) {
    
    this[ RECURSIVE_REFRESH ] = false;
    
  } else {
    
    // Remove all elements
    let child = checkboxes.lastElementChild;
    while (child) {
      checkboxes.removeChild (child);
      child = checkboxes.lastElementChild;
    }
    
    // Get LightDOM options
    for (let n = 0; n < options.length; n++) {
      
      // Put an id if it's missing
      if (!options[ n ].id) {
        options[ n ].id = 't' + Math.random ().toString (36).substr (2, 9);
      }
      
      // Create a new checkox
      const paragraphElement = document.createElement ('p');
      paragraphElement.classList.add ('group');
      paragraphElement.innerHTML = `
        <input
          type="checkbox"
          class="option"
          id="${ options[ n ].id }"
          ${ options[ n ].hasAttribute ('selected') ? `checked` : `` }
        >
        <label class="mark" for="${ options[ n ].id }"></label>
        <label class="label" for="${ options[ n ].id }">${ options[ n ].text }</label>
    `;
      checkboxes.appendChild (paragraphElement);
      
    }
  }
  this[ RENDER_REFRESH_SELECTALL ] ();
  
}

Hay una situación especial que se llama bastantes veces y que corresponde al botón de selección de todas las opciones, que aparece junto al cuadro de búsqueda. Este debe mostrarse como seleccionado si todas las opciones que se muestran -según el filtro actual- están a su vez seleccionadas. Para gestionar de forma sencilla y coherente este comportamiento se ha creado un método específico.

[ RENDER_REFRESH_SELECTALL ] () {
  // Elements
  const selectAll  = this.shadowRoot.querySelector ('#selectAll');
  const checkboxes = this.shadowRoot.querySelector ('#checkboxes');
  const checked    = checkboxes.querySelectorAll ('p:not(.hidden) input.option:checked').length;
  const visible    = checkboxes.querySelectorAll ('p:not(.hidden) input.option').length;
  // Update
  selectAll.checked = checked === visible && checked !== 0;
}

12. Personalizar el estilo del componente web

Quizás tendríamos que haber incluido este apartado como parte del API de nuestro componente web, pero lo cierto es que este tipo de funcionalidad es bastante común que se plantee una vez la parte principal ya ha sido desarrollada y tenemos una versión “que funciona“. PAra que nuestro componente web pueda ser (re)utilizado de forma práctica,  es importante que podamos dar un estilo personalizado a nuestro componente, es decir, que se ajuste al tamaño y tipo  de la fuente, colores de fondo y principal, estructura y tamaño de la pantalla, etc. Es bastante fácil que el CSS de nuestro componente tenga cierta tendencia a imponerse sobre las características definidas fuera de él. Esto no es una buena idea. El componente se va a utilizar en un contexto desconocido inicialmente y es posible que el diseñador o programador que haga uso de él tenga la necesidad de ajustar su apariencia para hacerlo lo más parecido posible al resto de componentes que ya utiliza.

Algunas de las claves para poder ajustar el contenido de nuestro componente son:

  • utilizar el estilo inherit para utilizar el estilo que se haya definido externamente
  • aunque se ha definido una fuente inicial, si se establece un font-family para el componente este se respeta
  • utilizar prácticamente todas las dimensiones en valores em, para ajustar al font-size que se haya establecido. Los valores que están en pixeles tienen una justificación muy clara, ya que se debe huir de ellos.
  • en los casos que sea necesario, pero sin abusar de ello, utilizar calc() para ajustar algunos elementos de forma dinámica

En aquellos casos donde estas técnicas no suficientes para personalizar el componente, hemos hecho uso de variables CSS. Esta interesante funcionalidad nos permiten, por ejemplo, cambiar el color de fondo cuando el componente está deshabilitado, el color de las cajas de selección, etc. En general es una excelente solución cuando no podemos heredar directamente del contexto el los valores que tenemos que utilizar para ajustar los estilos.

Con estas técnicas podemos cambiar completamente es estilo del componente:

todojs-multiselect {
  width                         : 500px;
  font-family                   : Courier, monospace;
  font-size                     : 24px;
  background-color              : SlateGray;
  color                         : Aquamarine;
  --todojs-mark-color           : Aqua;
  --todojs-mark-bg-color        : SlateBlue;
  --todojs-mark-checked-bg-color: SlateBlue;
  --todojs-label-focus-bg-color : Purple;
  --todojs-label-hover-bg-color : RebeccaPurple;
  --todojs-disabled-bg-color    : black;
}

componente web - estilo personalizado

Es posible que se nos haya pasado algún estilo que se quiera personalizar, pero es bastante fácil añadir nuevas variables para dar respuesta a estos nuevos requisitos de personalización.

Conclusiones

Para poder evaluar la dificulta del desarrollo con estas tecnologías, es importante tener referencias de componentes web reales que muestre la sencillez y la complejidad de su construcción. Quizás la extensión del código que presentamos en este artículo sea un poco abrumadora para algunos, pero hemos preferido mostrar todas el código que ha sido necesario para gestionar los requisitos funcionales y no sólo los aspectos relacionados con los componentes web.

Los frameworks que han surgido en el mercado para la construcción de aplicaciones y que están basados en componentes simplifican en cierta medida la construcción de componentes de este tipo, pero en la práctica restringen su utilización a aplicaciones basadas en el mismo framework, ya que habitualmente exigen la carga de enormes librerías de apoyo que hacen muy poco práctica su reutilización fuera del framework en el que fueron construidos.

Los componentes web nativos tiene la gran ventaja de no requerir librerías adicionales para funcionar en los navegadores más modernos, aunque necesiten polyfills para funcionar en los navegadores que todavía no soportan el estándar. La complejidad de su construcción es menor de la que pueda parecer, aunque la calidad de los polyfill deja bastante que desear. En una próxima entrega explicaremos cómo hemos tenido que adaptar este componente a su utilización de el navegador Microsoft Edge, ya que este no soporta aún algunas de las características de los componentes web y los polyfill no siempre pueden dar respuesta a todo lo que es necesario para incorporar el estándar en los navegadores que no lo soportan de forma nativa.

Si tienes cualquier duda sobre cómo construir tu propios componentes web, estaremos encantados de ayudarte.

Novedades

HTTP2 para programadores. Enviar mensajes del servidor al cliente con Server Sent Event (sin WebSockets)

HTTP2 para programadores. Enviar mensajes del servidor al cliente con Server Sent Event (sin WebSockets)

En esta charla, organizada por MadridJS, Pablo Almunia nos muestra cómo la mayoría de nosotros cuando oímos hablar por primera vez de HTTP2 nos ilusionamos con las posibilidades que presumiblemente se abrían para el desarrollo de soluciones web avanzadas y cómo muchos nos sentimos defraudados con lo que realmente se podía implementar.

En esta charla podemos ver cómo funciona el HTTP2, que debemos tener en cuenta en el servidor para hace uso de este protocolo y, sobre todo, cómo podemos enviar información desde el servidor al cliente de forma efectiva y fácil. Veremos con detenimiento cómo por medio de los Server-Sent Events (SSE) podemos recibir en el cliente datos enviados desde el servidor sin utilizar websocket, simplificando enormemente la construcción de aplicaciones con comunicación bidireccional.

Observables en Javascript con Proxies

Observables en Javascript con Proxies

En esta charla, organizada por MadridJS, Pablo Almunia nos habla de la observación reactiva de objetos en Javascript por medio de Proxy. Se describe paso a paso cómo funcionan los Proxies y en que casos pueden ser nuestro mejor aliado. Veremos que no hay que tenerles miedo, son bastante sencillos de utilizar, y nos ofrecen una gran abanico de posibilidades.

Aplicaciones JAMStack, SEO friendly y escalables con NextJS

Aplicaciones JAMStack, SEO friendly y escalables con NextJS

En esta charla de Madrid JS, Rafael Ventura nos describe las funcionalidades clave de NextJS, nos muestra en vivo cómo desarrollar una completa aplicación JAMStack con Server Side Rendering (SSR) y Static Site Generation (SSG) y termina mostrando como publicar esta aplicación en Vercel.

Stencil JS: mejora el Time To Market de tu producto, por Rubén Aguilera

Stencil JS: mejora el Time To Market de tu producto, por Rubén Aguilera

En esta charla Rubén Aguilera nos cuenta los problemas que tienen muchas empresas a la hora de sacar productos accesibles, vistosos y usables en el Time To Market que requiere Negocio y cómo podemos minimizar este tiempo gracias al DevUI con StencilJS para adecuar una aplicación de Angular a las exigencias del mercado en tiempo record.