Stimulus Autocomplete with Combobox Navigation
I’ve been slowly migrating a client’s Rails app from jQuery and UJS to Hotwire in between feature work. One of the last remaining pieces has been replacing jQuery UI Autocomplete, a pretty common library to see in web applications of a certain age.
A datalist
Digression
For this particular use-case, the list of autocomplete values was available in the rendered view and didn’t require a server request on keypress. So at first I thought I might be able to use the native datalist HTML element. It’s intended to work with inputs for this very purpose.
<label for="course_subject">Subject</label>
<input list="subjects" id="course_subject" name="course_subject" />
<datalist id="subjects">
<option value="Math"></option>
<option value="Science"></option>
<option value="English"></option>
<option value="History"></option>
</datalist>
Alas, the native datalist widget can’t yet be styled visually. And, at least currently in Chrome, it does not follow the UI conventions of many autocomplete implementations, namely a list box as wide as the input field, and a constrained height, allowing results to be scrolled when the list exceeds that height.
A <datalist> is exactly the functionality I want for this autosuggest field, but the native styling looks super weird to me…I don’t think I can do it. pic.twitter.com/kIukrwpj2y
— Jeremy Smith (@jeremysmithco) October 23, 2024
So instead, I reached for GitHub’s Combobox Navigation, which is a lower-level JS library used by a few of their open source Web Components.
The Stimulus Controller
Creating Stimulus controller wrappers for third-party libraries is often pretty straightforward. Import the library, map targets for the DOM elements involved, add setup and teardown method calls in the connect()
and disconnect()
lifecycle callbacks, and set actions on the appropriate DOM elements. But in this case, there’s more to it than normal, as autocompletion has a somewhat surprising number of specific behaviors, depending on what element has focus, what was clicked or what key was pressed, and what has already been entered in the input field.
First we have the Stimulus controller, which makes use of both the Combobox Navigation library, as well as Stimulus Use, a utility library that’s really helpful when working with Stimulus.
// app/javascript/controllers/autocomplete_controller.js
import { Controller } from "@hotwired/stimulus";
import Combobox from "@github/combobox-nav";
import { useClickOutside } from "stimulus-use";
export default class extends Controller {
static targets = ["input", "list", "item"];
connect() {
useClickOutside(this);
this.combobox = new Combobox(this.inputTarget, this.listTarget);
this.arrowMapping = new Map([["ArrowDown", 1], ["ArrowUp", -1]]);
}
disconnect() {
this.combobox.destroy();
}
show() {
this.listTarget.hidden = false;
this.combobox.start();
}
hide() {
this.listTarget.hidden = true;
this.combobox.clearSelection();
this.combobox.stop();
}
search() {
const query = this.inputTarget.value;
this.itemTargets.forEach(item => item.hidden = !this.isMatch(item, query));
const filteredCount = this.itemTargets.filter(item => !item.hidden).length;
if (filteredCount > 0) {
this.show();
} else {
this.hide();
}
}
isMatch(item, query) {
return item.textContent.toLowerCase().includes(query.toLowerCase());
}
// Only triggers search on closed list, doesn't handle traversing open list
arrowSearch(event) {
if (!this.arrowMapping.has(event.key)) return;
if (!this.listTarget.hidden) return;
this.search();
if (this.listTarget.hidden) return;
this.combobox.navigate(this.arrowMapping.get(event.key));
}
// Does not work for clicks outside controller (no relatedTarget set),
// but this handles tabbing away and useClickOutside handles clicks.
blur(event) {
if (this.listTarget.hidden) return;
if (!event.relatedTarget) return;
if (this.element.contains(event.relatedTarget)) return;
this.hide();
}
clickOutside(event) {
if (this.listTarget.hidden) return;
this.hide();
}
commit(event) {
this.inputTarget.value = event.target.textContent;
this.hide();
}
}
The combobox is initialized on connect()
and destroyed on disconnect()
. We can call start()
on the combobox to display options and intercept keyboard events, we can call stop()
to hide options and stop interecepting keyboard events, navigate()
to move selection up or down in the list, and clearSelection()
to reset the selection.
When search()
is called, it filters all options and shows only those where the input value is true for isMatch()
, and hides the option list if it turns out that no options match. The isMatch()
method handles the string comparison between current input value and the given option passed in. This implementation considers only case-insensitive matching, but this is what you’d modify if you needed to handle things like diacritics or fuzzy search.
When the input field has focus and the option list is closed, the up and down arrows should trigger a search and, if any results, a selection of the first or last item. The arrowSearch()
handles this, checking for the correct key press, calling search, and passing the proper value from arrowMapping
to the combobox navigate()
method.
When an autocomplete option list is open, it should be hidden whenever the user tabs to another field, hits Escape, makes a selection, or clicks anything other than the input field or a list item. The blur()
method handles when the user tabs away, causing the input field to lose focus. But we can’t rely solely on the blur
event, because the user may be clicking on an autocomplete list option (making a selection), and the blur
event would fire before the selection could be made. For this, we rely on useClickOutside from Stimulus Use to determine if a click occurs somewhere within the controller element (leaving the list open) or outside of it (causing it to be hidden).
Combobox Navigation comes with a couple events of it’s own: combobox-commit
and combobox-select
. We only use combobox-commit
here for handling selection. The commit()
method in the controller is called when a selection is made, leading to the input value being set to the selection text.
Sidenote: it’s generally not a good practice to name your Stimulus methods after the event that triggers them, such as blur()
, but instead to name them after what the method does. However, in the case of this controller, each event is handled with such specific conditions that I decided it would be clearer to the method names reflected their corresponding events.
The Form Partial
Now it’s time to wire up the Stimulus controller in the following ERB form partial. The Stimulus controller is connected to a div wrapping the input and the (initially) hidden option list. The controller can’t be set directly on the input field, which might seem preferable, because it needs to be able to target the input as well as the list beneath it. The wrapping div is also a useful place to apply the relative
Tailwind class so that the combobox list can be absolutely positioned relative to the input. (I’ve added extra line breaks to try to improve readability.)
<%= tag.div data: { controller: "autocomplete" }, class: "relative" do %>
<%= f.text_field :subject,
autocomplete: "off", data: {
autocomplete_target: "input",
action: "input->autocomplete#search focus->autocomplete#search
keydown.up->autocomplete#arrowSearch keydown.down->autocomplete#arrowSearch
keydown.esc->autocomplete#hide blur->autocomplete#blur"
} %>
<%= tag.ul role: "listbox",
hidden: true,
data: {
autocomplete_target: "list",
action: "combobox-commit->autocomplete#commit"
},
class: "z-40 absolute w-full max-h-60 overflow-auto mt-1
rounded border border-gray-300 shadow-lg p-1 bg-white" do %>
<% @subjects.each do |subject| %>
<%= tag.li subject.name,
role: "option",
id: dom_id(subject, dom_id(course, :listbox)),
data: { autocomplete_target: "item" },
class: "w-full text-left cursor-pointer p-2
hover:bg-gray-100 aria-selected:bg-gray-100 aria-selected:font-semibold" %>
<% end %>
<% end %>
<% end %>
As I mentioned earlier, the input field has more actions than I typically find are needed with Stimulus, due to the particularities of autocomplete as a feature. Here we have special handling for: input
, focus
, keydown.up
, keydown.down
, keydown.esc
, and blur
.
The combobox-commit->autocomplete#commit
action on the unordered list element handles the combobox-commit
event from the Combobox Navigation library. Note that we’re using the hidden
HTML attribute, rather than relying on Tailwind classes to show and hide. But we are using Tailwind to set the option list to be the full width of the input, with a max height and overflow auto so that the height is constrained and the list can scroll, if necessary.
Within the unordered list, we generate a list item for each subject. Combobox Navigation requires that all list options have a unique DOM id
attribute. Since this same list of subjects could be repeated for multiple course inputs on a single page, we can’t simply use dom_id(subject)
, or even dom_id(subject, :listitem)
, as they aren’t guaranteed to be unique. Instead, we can use dom_id(course, :listbox)
as the optional prefix argument for the subject’s dom_id
. (Sidenote: this is a good example of why I really wish dom_id
accepted a list of records or classes, so that you could scope instances for a better guarantee of id
uniqueness.)
Combobox Navigation sets the current list selection with the aria-selected
attribute. And thanks to Tailwind modifiers, we can change the selected item to be semibold with a light gray background with: aria-selected:bg-gray-100 aria-selected:font-semibold
.
Perhaps at some point, native styling for the datalist
element will improve, or will allow customization. Until then, we have a fairly lightweight, reusable solution that looks decent and matches typical user expectations around autocomplete mechanics.
Need help building or maintaining a Rails app?
Book a one-hour project inquiry call or reach out via email.