Autocomplete input

Introduction

The <datalist> element is one of those web platform features that filled a huge gap in the platform, and has not gotten anywhere near the attention that it deserves. It's a way of creating the middle ground between a select box and a regular text input: put <option> tags in a <datalist>, give it an ID, and then link it to the input by specifying the "list" attribute. Ta-da! You have an auto-complete text input that (theoretically) works everywhere.

<input list="marsupials">
<datalist id="marsupials">
  <option>Wombat</option>
  <option>Wallaby</option>
  <option>Kangaroo</option>
</datalist>

Of course, one of the reasons that it probably hasn't gotten that attention is because the UI is platform-dependent, which is a fun way of saying "obtuse." For a graphic tracking hospitalization rates during the COVID pandemic, my team built a graphic using the <datalist> to populate US counties, but found that on iOS Safari the stock UI was too subtle (the options appear as auto-suggest phrases above the keyboard), and people were unable to use it effectively.

This web component, then, is a single-file custom element that is a drop-in replacement with a more traditional drop-down UI. You just swap out your <input> for an <autocomplete-input>, and it should just work. This component also goes to some pains to be accessible in screen readers, using ARIA to identify its markup accordingly. We've since used it on a few other graphics.

autocomplete-input.js

var styles = `
  :host {
    position: relative;
    display: block;
  }

  * {
    box-sizing: border-box;
  }

  input {
    display: block;
    width: 100%;
  }

  .dropdown {
    position: absolute;
    width: 100%;
    margin: 0;
    padding: 0;
    max-height: 180px;
    list-style-type: none;
    z-index: 999;
    overflow-y: auto;
  }

  .above .dropdown {
    bottom: 100%;
  }

  .dropdown li {
    padding: 2px 4px;
    background: white;
    border-bottom: 1px solid #DDD;
    text-align: left;
    cursor: pointer;
  }

  .dropdown .selected {
    background: #DDD;
  }
`;

var guid = 0;
var COMPOSED = { composed: true, bubbles: true };

class AutocompleteInput extends HTMLElement {

  constructor() {
    super();
    var id = guid++;
    this.value = null;
    this.attachShadow({ mode: "open" });
    this.cancelBlur = false;

    var autoBind = [
      "onMenuClick",
      "onMenuTouch",
      "onBlur",
      "onInput",
      "onKeyPress",
      "onMutation",
      "closeMenu"
    ];
    autoBind.forEach(f => this[f] = this[f].bind(this));

    // add style
    var style = document.createElement("style");
    style.innerHTML = styles;
    this.shadowRoot.appendChild(style);

    this.container = document.createElement("div");
    this.shadowRoot.appendChild(this.container);
    this.container.setAttribute("role", "combobox");
    this.container.setAttribute("aria-haspopup", "listbox");
    this.container.setAttribute("aria-owns", `listbox-${id}`);

    this.input = document.createElement("input");
    this.input.setAttribute("aria-controls", `listbox-${id}`);
    this.input.setAttribute("aria-activedescendant", "");
    this.container.appendChild(this.input);

    var bounce = null;
    // debounce the inputs
    this.input.addEventListener("input", e => {
      if (bounce) {
        clearTimeout(bounce);
      }
      bounce = setTimeout(() => {
        bounce = null;
        this.onInput();
      }, 150);
    });
    // don't debounce arrow keys
    this.input.addEventListener("keydown", this.onKeyPress);
    this.input.addEventListener("blur", this.onBlur);

    this.observer = new MutationObserver(this.onMutation);
    this.list = null;
    this.entries = [];
    this.selectedIndex = -1;

    this.menuElement = document.createElement("ul");
    this.menuElement.id = `listbox-${id}`;
    this.menuElement.setAttribute("role", "listbox");
    this.menuElement.classList.add("dropdown");
    this.container.appendChild(this.menuElement);
    this.menuElement.addEventListener("click", this.onMenuClick);
    this.menuElement.addEventListener("mousedown", this.onMenuTouch);
    this.menuElement.addEventListener("touchstart", this.onMenuTouch);
  }

  connectedCallback() {
    if (document.readyState != "complete") {
      document.addEventListener("load", () => {
        var id = this.getAttribute("list");
        if (!this.list && id) this.attributeChangedCallback("list", id, id);
      });
    }
  }

  // reflect inner input value to the host component
  get value() {
    return this.input ? this.input.value : "";
  }

  set value(v) {
    if (this.input) {
      var updated = this.input.value != v;
      if (updated) {
        this.input.value = v;
        var changeEvent = new CustomEvent("change", COMPOSED);
        this.dispatchEvent(changeEvent);
      }
    }
  }

  static get observedAttributes() {
    return [
      "list"
    ]
  }

  attributeChangedCallback(attr, was, is) {
    switch (attr) {
      case "list":
        // un-observe the old list
        if (this.list) {
          this.observer.disconnect();
          this.list = null;
        }
        // find and monitor the list
        this.list = document.querySelector("#" + is);
        if (this.list) {
          this.observer.observe(this.list, {
            childList: true,
            characterData: true
          });
          // update with existing items
          this.updateListEntries();
        }
        break;

    }
  }

  // if <datalist> changes, update our internal representation
  onMutation(e) {
    this.updateListEntries();
  }

  // read the contents of the <datalist> and build an internal array of options
  updateListEntries() {
    if (!this.list) return;
    this.entries = Array.from(this.list.children).map(function(option, index) {
      if (!option.value) return;
      return {
        value: option.value,
        label: option.innerHTML,
        index
      }
    }).filter(v => v);
  }

  // actually produce the menu when typing
  onInput() {
    var value = this.input.value;
    this.menuElement.innerHTML = "";
    if (!value) return;

    // filter the entries via a regex
    var matcher = new RegExp(value, "i");
    var matching = this.entries.filter(e => e.label.match(matcher));
    if (!matching.length) return;

    // limit the matches
    matching = matching.slice(0, 100);
    var found = matching.find(r => r.index == this.selectedIndex);
    if (!found) this.selectedIndex = matching[0].index;
    // populate the dropdown with options
    var listItems = matching.forEach(entry => {
      var li = document.createElement("li");
      li.dataset.index = entry.index;
      li.dataset.value = entry.value;
      li.innerHTML = entry.label;
      li.setAttribute("role", "option");
      li.id = `list-${guid}-item-${entry.index}`;
      if (entry.index == this.selectedIndex) {
        li.classList.add("selected");
        this.input.setAttribute("aria-activedescendant", li.id);
      }
      this.menuElement.appendChild(li);
    });
    var position = this.input.getBoundingClientRect();
    var below = window.innerHeight - position.bottom;
    this.container.classList.toggle("above", below < this.menuElement.offsetHeight);
    this.container.setAttribute("aria-expanded", "true");
  }

  // handle arrow keys and enter/escape
  onKeyPress(e) {
    switch (e.code) {
      case "ArrowDown":
      case "ArrowUp":
        var shift = e.code == "ArrowDown" ? 1 : -1;
        var current = this.menuElement.querySelector(".selected");
        var newIndex;
        if (current) {
          var currentIndex = Array.from(this.menuElement.children).indexOf(current);
          var newIndex = (currentIndex + shift) % this.menuElement.children.length;
          if (newIndex < 0) newIndex = this.menuElement.children.length + newIndex;
          current.classList.remove("selected");
        } else {
          newIndex = shift == 1 ? 0 : this.menuElement.children.length - 1;
        }
        var li = this.menuElement.children[newIndex];
        if (li) {
          li.classList.add("selected");
          this.input.setAttribute("aria-activedescendant", li.id);
          this.selectedIndex = li.dataset.index;
        }
      break;

      case "Enter":
        var chosen = this.entries[this.selectedIndex];
        if (!chosen) return;
        this.setValue(chosen);
      break;

      case "Escape":
        this.input.value = "";
        this.closeMenu();
      break;
    }
  }

  // called when a menu item is clicked or user presses enter
  setValue(entry) {
    if (entry) {
      this.input.value = entry.label;
      this.menuElement.innerHTML = "";
      this.value = this.input.value;
        var changeEvent = new CustomEvent("change", COMPOSED);
      this.dispatchEvent(changeEvent);
    } else {
      this.input.value = "";
    }
    this.closeMenu();
  }

  onMenuClick(e) {
    var index = e.target.dataset.index;
    if (index == null) return;
    this.menuElement.innerHTML = "";
    this.selectedIndex = index;
    var entry = this.entries[index];
    this.setValue(entry);
  }

  onMenuTouch() {
    this.cancelBlur = true;
  }

  onBlur() {
    if (this.cancelBlur) return;
    this.closeMenu();
  }

  closeMenu() {
    this.menuElement.innerHTML = "";
    this.container.setAttribute("aria-expanded", "false");
    this.input.setAttribute("aria-activedescendant", "");
    this.cancelBlur = false;
  }

}

Demo

Notes

The constructor

Since I wrote this as a quick component that could be dropped into graphics, I didn't build it off our standard element class. That means we spend a lot of time in the constructor manually creating DOM elements and adding them to the shadow root, then attaching event listeners to them. In the future, if we keep using this component, I will probably move this to a pattern that handles more of the boilerplate.

We also create a Mutation Observer for later use — this will monitor the <datalist> element to keep track of our autocomplete options.

connectedCallback()

Generally, the JavaScript bundle that includes this element definition is at the end of the page, so we don't have to worry about the order of the <autocomplete-input> relative to its linked <datalist> — both should be in the DOM when the upgrade happens. However, just in case this script was loaded early for some reason and it wasn't able to find the element by ID, we add an event listener in the connectedCallback() to try again when the document is fully loaded.

For a more elaborate element, I'd probably use the association/control pattern from earlier in this book, where the component actually watches the document for the addition or removal of a specific ID. But again, this element is almost always used in a very small page, containing a single graphic embed. We can sacrifice a little robustness in that case.

The value getters and setters

As this is a drop-in replacement for <input>, we need to be able to proxy its value back out to the custom element itself. There's some additional logic in the setter to dispatch an event if the contents are different — since the input is in shadow, we can't rely on the normal propagation to get out.

Accessible dropdowns

Making an element like this accessible is harder than it should be, but ultimately not too hard to understand. Most of the hard work is in setting the correct roles and relationships for various elements.

All of this guidance is taken from the WAI-ARIA authoring practices and their related examples. I tested the code in NVDA and VoiceOver, which probably wasn't enough. It took a little while to get it all hooked up correctly, but ultimately it's not that much work, and it's gratifying to see and hear the component behave like a normal system UI widget.

Blur warning

One notable event being handled is the onMenuTouch() listener, which is called for "touchstart" and "mousedown" events on the menu element. All this does is set a cancelBlur flag property — so what's the point?

During testing, some people (but only some, and not all the time) found that tapping a menu item wouldn't correctly update the input value. Instead, the menu would vanish, but the half-typed value would stay in place and no "input" events would fire. Having cut corners on this kind of UI in the past, I suspected the culprit immediately: a kind of race condition in the event listeners.

Basically, the problem is that our component handles two different events that can close a menu. One is clicking a menu item, but the other is clicking or tapping anywhere else on the page, which "blurs" the input (the opposite of focus). On my machine, and other browsers where the autocomplete worked correctly, the order looked like this:

But in some browsers, the "blur" would fire directly after "mousedown", like this:

The sloppy way I had originally tried to fix it was to insert a timeout after the blur event, to give the click time to kick in. Like most hacks, this worked sometimes, but if (for whatever reason) the click took too long to fire, the blur would still win the race.

I don't know which event order is technically correct, and ultimately it doesn't matter. But to fix it is relatively easy — enough that I should have just done it that way from the start. When the menu sees a "mousedown" event, it sets a flag so that the component can ignore any input "blur" that follows. Clicking outside on the rest of the page doesn't set that flag, so you can still tap elsewhere to close the menu, and we reset the flag each time the menu is opened to keep it from getting stuck.