Saltar al contenido principal

Cómo crear un sistema de autocompletado en inputs - Sortilegios 03

Qué vamos a hacer

En este artículo voy a explicar cómo construir un input HTML que tenga sistema de autocompletado. Además, se me ha ocurrido que sería buena idea dar alguna pincelada sobre accesibilidad, ya que yo mismo desconozco mucho sobre este tema.

Para el autocompletado voy a llamar a una API que me devuelva la lista de países para poder ofrecer una lista con resultados que sean parecidos a lo que escribe el usuario, vamos al lío.

Por cierto, te dejo el enlace a la demo de lo que vamos a hacer por si quieres ver ya el código sin leer el artículo:

https://codepen.io/Frostq/pen/oNxygGY

Este artículo pertenece a la serie de artículos con contenido práctico llamada Sortilegios 🚧

🗺️ Hoja de ruta

Creando la vista y los estilos para el autocompletado

Bien, empecemos, lo primero es crear el HTML. Mi idea es crear un formulario HTML con un input y un botón porque lo quiero es hacer un ejemplo de un buscador de ciudades.

<div class="container">
  <form>
    <input
      placeholder="Search for a country"
      aria-label="Search for a country"
      aria-autocomplete="both"
      aria-controls="autocomplete-results"
    />
    <button type="submit" aria-label="Search">
      <svg aria-hidden="true" viewBox="0 0 24 24">
        <path
          d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z"
        />
      </svg>
    </button>
    <ul
      id="autocomplete-results"
      role="listbox"
      aria-label="Search for a country"
    ></ul>
  </form>
</div>

Dentro del formulario he puesto 3 cosas: un input, un botón submit, que dentro tiene un svg para poder poner el icono de una lupa, y una lista que por el momento no tiene nada dentro.

El input sirve para que el usuario pueda escribir, el botón en este ejemplo en específico no tendrá uso porque no se puede hacer nada una vez escrito el nombre del país, y la lista vacía que la rellenaremos con el autocompletado.

Fíjate que he ido añadiendo varios atributos aria. Estos atributos están relacionados con la accesibilidad.

El input tiene un aria-label para indicar al lector de pantalla (para personas con problemas de visión) la acción que va a realizar ese elemento. El atributo aria-autocomplete como su nombre indica, sirve para decir que ese input tiene implementado un sistema de autompletado. Este atributo puede recibir varios valores.

Por último he añadido el parámetro aria-hidden al svg para indicar al lector de pantalla que ese elemento puede ignorarlo ya que solo tiene función estética.

Para el CSS no he hecho nada del otro mundo. Simplemente he puesto estilos básicos para que se vea decente y he añadido a la clase hidden un display:none para ocultar el autocompletado. Por Javascript quitaremos esas clase dinámicamente para mostrar los resultados.

* {
  border-sizing: border-box;
}
html,
body {
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
}
.container {
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #e8f0fb;
}
form {
  display: flex;
  box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
  position: relative;
}
input,
button {
  border: none;
  background: white;
  margin: 0;
}
input {
  font-size: 16px;
  padding: 10px 12px;
}
button {
  padding: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
}
svg {
  width: 18px;
  height: 18;
  fill: black;
}
ul {
  position: absolute;
  top: 100%;
  margin-top: -1px;
  width: 100%;
  z-index: 1;
  background: #fff;
  margin: 0;
  list-style: none;
  transition: none;
  padding: 0;
  border: 1px solid #bfc8c9;
}
ul li {
  padding: 6px 12px;
  transition: 0.2s background;
  cursor: pointer;
}
ul li:hover {
  background: #e6f0f2;
}
ul li + li {
  border-top: 1px solid #bfc8c9;
}
.hidden {
  display: none;
}

He añadido también estilos para los resultados del autocompletado, los usaremos más adelante. Aunque he usado directamente selectores HTML para el CSS, no te lo recomiendo (yo lo he hecho por comodidad y rapidez), siempre que puedas usa clases en el CSS.

El resultado por el momento es este:

Se observa un input para escribir y a su lado un icon de una lupa

Llamando a la API para recuperar la lista de países

Empecemos con la lógica del formulario. Para empezar, usando el método fetch de javascript, voy a llamar a una API para recuperar la lista de países del mundo y lo voy a guardar en una variable global para poder usar más tarde.

Ojo las variables globales con var las estoy creando por comodidad, pero en una web real no se recomienda hacer uso de estas variables.

var countries = [];

function init() {
  fetch("https://restcountries.com/v3.1/all")
    .then((response) => response.json())
    .then((data) => (countries = data));
}

init();

No tiene mucho misterio, se llama a la API y los resultados se convierten a objetos de javascript para mayor conveniencia.

Para este ejemplo uso una lista de países, aquí es donde tú tienes que usar la lista que quieres usar para el autocompletado, lo único que necesitas es una lista de strings o de objetos que tengan strings.

Autompletando resultados cuando el usuario escribe

Vamos ahora con lo importante de este proyecto, poder ofrecer autocompletado al usuario. Lo primero es escuchar el evento del input de presionar y soltar teclas al escribir para poder lanzar la función que autocomplete.

var countries = [];
var inputElem = null;

function init() {
  fetch("https://restcountries.eu/rest/v2/all")
    .then((response) => response.json())
    .then((data) => (countries = data));

  inputElem = document.querySelector("input");
  inputElem.addEventListener("keydown", (event) => {
    autocomplete(event);
  });
}

function autocomplete(event) {
  console.log("Evento keydown");
}

init();

Por el momento simplemente lo que hago es seleccionar el nodo del input mediante querySelector para poder escuchar del evento keydown. Dentro del keydown recojo el evento y se lo paso a una función que me he creado que servirá para autocompletar los resultados.

var countries = [];
var inputElem = null;
var resultsElem = null;

function init() {
  fetch("https://restcountries.com/v3.1/all")
    .then((response) => response.json())
    .then((data) => (countries = data));

  resultsElem = document.querySelector("ul");
  inputElem = document.querySelector("input");
  inputElem.addEventListener("keydown", (event) => {
    autocomplete(event);
  });
}

function autocomplete(event) {
  const value = inputElem.value;
  const results = countries.filter((country) => {
    return country.name.common.toLowerCase().startsWith(value.toLowerCase());
  });

  resultsElem.innerHTML = results
    .map((result, index) => {
      const isSelected = index === 0;
      return `
        <li
          id='autocomplete-result-${index}'
          class='autocomplete-result${isSelected ? " selected" : ""}'
          role='option'
          ${isSelected ? "aria-selected='true'" : ""}
        >
          ${result.name.common}
        </li>
      `;
    })
    .join("");
  resultsElem.classList.remove("hidden");
}

init();

Con el código de arriba el formulario ya se autocompleta al empezar a escribir nombres de países. Simplemente lo que hago es, dentro de la fucnión de autocompletado, hacer un filtrado de los países cogiendo el valor que ha introducido el usuario.

Una vez filtrados los países se recorren con un map para poder crear cada uno de los elementos a insertar en los resultados del autocompletado.

También he aprovechado a poner una clase especial si el elemento está seleccionado (por defecto se selecciona el primero) y para poner el aria de elemento seleccionado para mejorar la accesibilidad. Por último se elimina la clase hidden para que se muestre la lista de resultados.

Con eso ya se muestran los resultados del autocompletados pero todavía no es funcinal porque al hacer click no se rellena el input con el autocompletado seleccionado. Vamos con ello:

var countries = [];
var inputElem = null;
var resultsElem = null;

function init() {
  fetch("https://restcountries.com/v3.1/all")
    .then((response) => response.json())
    .then((data) => (countries = data));

  resultsElem = document.querySelector("ul");
  inputElem = document.querySelector("input");

  resultsElem.addEventListener("click", (event) => {
    handleResultClick(event);
  });
  inputElem.addEventListener("keydown", (event) => {
    autocomplete(event);
  });
}

function autocomplete(event) {
  const value = inputElem.value;
  const results = countries.filter((country) => {
    return country.name.common.toLowerCase().startsWith(value.toLowerCase());
  });

  resultsElem.innerHTML = results
    .map((result, index) => {
      const isSelected = index === 0;
      return `
        <li
          id='autocomplete-result-${index}'
          class='autocomplete-result${isSelected ? " selected" : ""}'
          role='option'
          ${isSelected ? "aria-selected='true'" : ""}
        >
          ${result.name.common}
        </li>
      `;
    })
    .join("");
  resultsElem.classList.remove("hidden");
}

function handleResultClick() {
  if (event.target && event.target.nodeName === "LI") {
    selectItem(event.target);
  }
}

function selectItem(node) {
  if (node) {
    inputElem.value = node.innerText;
    hideResults();
  }
}

function hideResults() {
  this.resultsElem.innerHTML = "";
  this.resultsElem.classList.add("hidden");
}

init();

He creado tres funciones nuevas. La primera función se ejecuta en el evento click en la lista de resultados, la segunda función sirve para poder seleccionar items, es decir, para sustituir el resultado del autocompletado en el input y la tercera sirve para hacer desaparecer la lista de resultados.

Con esto al clickar ya se coloca en el input la opción escogida.

Como primera versión sería pasable, pero faltan muchas cosas por hacer.

Lo siguiente que quiero añadir sería que al escribir, por defecto se rellene el input con la primera opción del formulario, para mayor comodidad. Vamos con ello.

var countries = [];
var inputElem = null;
var resultsElem = null;

function init() {
  fetch("https://restcountries.com/v3.1/all")
    .then((response) => response.json())
    .then((data) => (countries = data));

  resultsElem = document.querySelector("ul");
  inputElem = document.querySelector("input");

  resultsElem.addEventListener("click", (event) => {
    handleResultClick(event);
  });
  inputElem.addEventListener("input", (event) => {
    autocomplete(event);
  });
  inputElem.addEventListener("keyup", (event) => {
    handleResultKeyDown(event);
  });
}

function autocomplete(event) {
  const value = inputElem.value;
  if (!value) {
    hideResults();
    inputElem.value = "";
    return;
  }
  const results = countries.filter((country) => {
    return country.name.common.toLowerCase().startsWith(value.toLowerCase());
  });

  resultsElem.innerHTML = results
    .map((result, index) => {
      const isSelected = index === 0;
      return `
        <li
          id='autocomplete-result-${index}'
          class='autocomplete-result${isSelected ? " selected" : ""}'
          role='option'
          ${isSelected ? "aria-selected='true'" : ""}
        >
          ${result.name.common}
        </li>
      `;
    })
    .join("");
  resultsElem.classList.remove("hidden");
}

function handleResultClick() {
  if (event.target && event.target.nodeName === "LI") {
    selectItem(event.target);
  }
}
function handleResultKeyDown(event) {
  const { key } = event;
  switch (key) {
    case "Backspace":
      return;
    default:
      selectFirstResult();
  }
}

function selectFirstResult() {
  const value = inputElem.value;
  const autocompleteValue = resultsElem.querySelector(".selected");
  if (!value || !autocompleteValue) {
    return;
  }
  if (value !== autocompleteValue.innerText) {
    inputElem.value = autocompleteValue.innerText;
    inputElem.setSelectionRange(
      value.length,
      autocompleteValue.innerText.length
    );
  }
}
function selectItem(node) {
  if (node) {
    inputElem.value = node.innerText;
    hideResults();
  }
}

function hideResults() {
  this.resultsElem.innerHTML = "";
  this.resultsElem.classList.add("hidden");
}

init();

Lo primero que he hecho es modificar el evento que había añadido antes de keydown para usar el evento input, ya que con el evento keydown no tienes el valor escrito por el usuario actualizado, tienes el anterior valor.

Luego he creado un nuevo evento de keyup para controlar cuando el usuario termina de apretar una tecla. En ese evento se mira si la tecla es el backspace, es decir, la tecla de borrar, para en ese caso no hacer nada. Si es cualquier otra tecla se llama a la función de selectFirstResult para seleccionar por defecto el primer valor de la lista de resultados.

Dentro de ese método simplemente se selecciona el primer elemento seleccionado de la lista de resultados y se hace que el valor del input corresponda a ese valor. Además se llama a la función de setSelectionRange para que aparezca resaltado el texto del input para indicar al usuario que puede seguir escribiendo o puede pulsar la tecla hacia la derecha en el teclado para aceptar el autocompletado sin usar el ratón.

Mejorando la accesibilidad

Para el tema de la accesibilidad me voy a usar la guía de buenas prácticas de la organización W3, en concreto las propuestas para un combo box:

https://www.w3.org/TR/wai-aria-practices-1.1/#combobox

Siguiendo loa guía vamos ahora a programar los controles por teclado del autocompletado. Es decir, necesitamos hacer que_

Vamos primero a hacer lo de la tecla Escape que es lo más sencillo. Simplemente voy a modificar el handleResultKeyDown que usa el evento de keyup que creamos antes:

function handleResultKeyDown(event) {
  const { key } = event;
  switch (key) {
    case "Backspace":
      return;
    case "Escape":
      hideResults();
      inputElem.value = "";
      return;
    default:
      selectFirstResult();
  }
}

Para crear la lógica de las flechas voy a usar esa misma función. Voy a crear una variable global llamada activeIndex que sirva para saber el índice de la lista de resultados seleccionado. También voy a crear otra variable global llamada filteredResults que sirva para guardar los resultados filtrados tras cuando el usuario escribe, de esa forma sabemos los items que se muestran para poder crear la lógica de recorrerlos con el teclado.

Todo el javascript quedaría así:

var countries = [];
var inputElem = null;
var resultsElem = null;
var activeIndex = 0;
var filteredResults = [];

function init() {
  fetch("https://restcountries.com/v3.1/all")
    .then((response) => response.json())
    .then((data) => (countries = data));

  resultsElem = document.querySelector("ul");
  inputElem = document.querySelector("input");

  resultsElem.addEventListener("click", (event) => {
    handleResultClick(event);
  });
  inputElem.addEventListener("input", (event) => {
    autocomplete(event);
  });
  inputElem.addEventListener("keyup", (event) => {
    handleResultKeyDown(event);
  });
}

function autocomplete(event) {
  const value = inputElem.value;
  if (!value) {
    hideResults();
    inputElem.value = "";
    return;
  }
  filteredResults = countries.filter((country) => {
    return country.name.common.toLowerCase().startsWith(value.toLowerCase());
  });

  resultsElem.innerHTML = filteredResults
    .map((result, index) => {
      const isSelected = index === 0;
      return `
        <li
          id='autocomplete-result-${index}'
          class='autocomplete-result${isSelected ? " selected" : ""}'
          role='option'
          ${isSelected ? "aria-selected='true'" : ""}
        >
          ${result.name.common}
        </li>
      `;
    })
    .join("");
  resultsElem.classList.remove("hidden");
}

function handleResultClick() {
  if (event.target && event.target.nodeName === "LI") {
    selectItem(event.target);
  }
}
function handleResultKeyDown(event) {
  const { key } = event;
  const activeItem = this.getItemAt(activeIndex);
  if (activeItem) {
    activeItem.classList.remove("selected");
    activeItem.setAttribute("aria-selected", "false");
  }
  switch (key) {
    case "Backspace":
      return;
    case "Escape":
      hideResults();
      inputElem.value = "";
      return;
    case "ArrowUp": {
      if (activeIndex === 0) {
        activeIndex = filteredResults.length - 1;
      }
      activeIndex--;
      break;
    }
    case "ArrowDown": {
      if (activeIndex === filteredResults.length - 1) {
        activeIndex = 0;
      }
      activeIndex++;
      break;
    }
    default:
      selectFirstResult();
  }
  console.log(activeIndex);
  selectResult();
}
function selectFirstResult() {
  activeIndex = 0;
}

function selectResult() {
  const value = inputElem.value;
  const autocompleteValue = filteredResults[activeIndex].name.common;
  const activeItem = this.getItemAt(activeIndex);
  if (activeItem) {
    activeItem.classList.add("selected");
    activeItem.setAttribute("aria-selected", "true");
  }
  if (!value || !autocompleteValue) {
    return;
  }
  if (value !== autocompleteValue) {
    inputElem.value = autocompleteValue;
    inputElem.setSelectionRange(value.length, autocompleteValue.length);
  }
}
function selectItem(node) {
  if (node) {
    console.log(node);
    inputElem.value = node.innerText;
    hideResults();
  }
}

function hideResults() {
  this.resultsElem.innerHTML = "";
  this.resultsElem.classList.add("hidden");
}

function getItemAt(index) {
  return this.resultsElem.querySelector(`#autocomplete-result-${index}`);
}

init();

He modificado el método de selectResult para que sirva para seleccionar el item que marque la variable de activeIndex. Además ese método aprovecha y mete la clase de selected y el aria-selected a true para ayudar a los lectores de pantalla.

Dentro de la función de handleResultKeyDown hago la lógica de las flechas del teclado. Lo primero que hago es limpiar la clase selected y el aria-selected para que se deseleccione lo que había anteriormente.

Dentro del switch detecto la flecha pulsada y incremento o decremento la variable activeIndex. Miro si la variable es 0 al decrementar para poner activeIndex al final de la lista y al incrementar miro si ya estamos al final para volver al principio. Por último en ese método se llama a selectResult para que se seleccione el elemento marcado por activeIndex.

Faltaría terminar la lógica de seleccionar con el Enter pero eso te lo voy a dejar como deberes ahora que has visto el código para que pruebes si eres capaz de programarlo.

El resultado final es este:

En la imagen se puede ver un input y debajo una lista de resultados para autocmpletar

Conclusiones

Hoy hemos visto una parte muy superficial de los principios de accesibilidad pero espero que al menos te haya servido para aprender algún concepto nuevo sobre este tema.

Del código que hemos creado, se que no es perfecto. Hay mucho lío en el código, nonmbres de varables que no están bien puesto, funciones que hacen más de una cosa o lógica que podría simplificarse.

Si crees que podrías mejorar el código, crea un pen en codepen con tu propuesta y envíame el link a mi cuenta de Twitter: https://twitter.com/codingpotions

Me hace mucha ilusión que la gente me envíe sus propuestas porque siempre se aprende de otros programadores y me sirve para darme cuenta de que se puede llegar al mismo resultado por varios caminos.

Te dejo el link del proyecto que he hecho yo con el código que has visto en este artículo:

https://codepen.io/Frostq/pen/oNxygGY