I’m in the process of adding some new features to a web application I created several years ago. It’s an app that makes it easy for a handful of non-technical users to manage users and groups in a third-party system. It’s a multipage app that doesn’t use a lot of JavaScript, but where it does it uses jQuery (don’t judge, we were all using jQuery when this thing was written).
I don’t have time to completely refactor the entire app, but I’d like to start the process of moving away from jQuery, so I figured I’d avoid using it for the new functionality. AlpineJS is one of my favorite JavaScript libraries right now, and I figured it would be the perfect tool to use for this project, since it would give me modern, reactive-style support while still working within the confines of the existing multipage framework. Alpine can do most of what I need pretty easily–things like modals and input checking. One of the things that I didn’t have a good answer for, however, was the autocomplete.
In the current version of the application, adding users is done by selecting a group and entering a username into a form field. Of course we don’t expect that the application’s users will necessarily know the usernames of the people they’re adding, so I added a jQueryUI autocomplete which is tied to a script that does an LDAP search and returns a list of names. As the user types in the field, a list of possible people pops up and, when one is selected, the proper username is entered into the field and the form can be submitted.
The new functionality that I am adding also needs a user lookup. Of course, there are lots of “vanilla” autocompletes out there that I could use, but ideally I’d like to limit the number of extra libraries I need to include. I’ve also been working on another project lately that involves form processing with JavaScript and, at some point when I was looking at the MDN site for something, I was reminded of the HTML 5 <datalist>
element.
If you aren’t aware, <datalist>
lets you create a static list of options, similar to a <select>
element, that can be attached to an <input>
. Unlike a <select>
, however, the <datalist>
list is only a list of suggestions; values that are not in the list can still be entered.
A <datalist>
looks a lot like a <select>
:
<datalist id="animals">
<option>Dog</option>
<option>Cat</option>
<option>Mouse</option>
</datalist>
It can also accept a key-value list, just like a <select>
. The only difference is that when an item is selected, the value shown in the field will be that of the value attribute rather than the label text.
<datalist id="animals">
<option value="dog">Dog</option>
<option value="cat">Cat</option>
<option value="mouse">Mouse</option>
</datalist>
A <datalist>
is tied to an <input>
element by adding a list
attribute to the <input>
element. The list
attribute should be set to the id
of the <datalist>
that’s to be used:
<input type="text" name="animal" list="animals" />
Normally the <datalist>
would contain static values that are included in the page when it is rendered on the server, but this wouldn’t work for my use case…it would be impractical to include all 20,000 or so user accounts that we have on every page load. Instead I need to build the list dynamically. This is where AlpineJS comes in.
First, I need a datasource. As I mentioned above, the app already has an endpoint, “/lookup
” that is used by jQueryUI. This takes a query string parameter “term
” and returns a JSON array that looks similar to this:
[
{
"value": "jsmith",
"label": "John Smith (jsmith, Student)",
},
{
"value": "sjones",
"label": "Susan Jones (sjones, Faculty)",
},
]
Next, I need AlpineJS. In this case I’m using both the AlpineJS base library as well as Alpine Fetch, a third-party plugin that helps with fetching remote data into Alpine. As with all Alpine plugins, Alpine Fetch needs to be included before the base library.
<script defer src="https://gitcdn.link/cdn/hankhank10/alpine-fetch/main/alpine-fetch.js"></script>
<script src="//unpkg.com/alpinejs" defer></script>
Now I can create the AlpineJS component. That looks like this:
<div x-data="{
results: null,
term: null
}">
<label for="username">Username:</label>
<input type="text" id="username" name="username" list="userlist"
x-model="term"
@keypress.throttle="results = await $fetch('/lookup?term=' + term)"
/>
<datalist id="userlist">
<template x-for="item in JSON.parse(results)">
<option :value="item.value" x-text="item.label"></option>
</template>
</datalist>
</div>
That’s it! We now have a fully working autocomplete.
Let me break down what’s going on here:
- In the first line, the
x-data
attribute on the<div>
signals to Alpine that we are creating a new component. The value of that attribute contains the default values for variables that we’ll be using within the component.results
contains the results that were received the last time the/lookup
endpoint was queried. It will contain a JSON string. We don’t have any initial data, so it is initialized tonull
.term
will contain the term that is being searched. It will be linked to the value of the input element, but since we aren’t starting with an initial value, it is also set tonull
.
- The
<input>
element on line 6 is where most of the interaction occurs:- We link the field to the
<datalist>
using thelist="userlist"
attribute. - The
x-model="term
” attribute establishes a two-way linkage between the field’s value and theterm
variable we initialized inx-data
. This means that any time the field value is changed, the variable will be updated to reflect it. Likewise, if the variable is ever changed directly (which never happens in this context), the field value will also change to reflect it. - Finally, the
@keypress
attribute sets an event handler that calls/lookup
with the current value of the field each time a key is pressed. The.throttle
modifier is used to limit these calls to no more than once every 250 ms to prevent flooding the server.$fetch()
is a magic method provided by Alpine Fetch that makes a web request and returns the result body as a string, which we store in the results variable we created inx-data
.
- We link the field to the
- Alpine watches for changes to variables and reacts to them, so once we get new results, the
x-for
loop in the<template>
on line 11 gets triggered. This creates new<option>
tags within the<datalist>
for each result in the returned JSON data, replacing any that were there previously. Sinceresults
contains the raw string that was returned from the web request, we callJSON.parse()
on it to parse it into a JavaScript array. - On each
<option>
tag that’s created, the:value="item.value"
attribute tells Alpine to set avalue
attribute with the value from the result item and thex-text="item.label"
tells it to set the element’sinnerText
to the value of the result item’s label.
So far this approach seems to work great. The only downside is that each browser has its own way to format the <datalist>
display, and there’s no way to customize it with CSS. That’s not a big deal to me in an app that only has a handful of users, but it might be if it’s used on a large, public-facing, well-branded site. If that’s the case, it probably wouldn’t be too difficult to modify this approach to use, say, an absolute-positioned <ul>
list, the way more traditional autocomplete utilities do things, though that would require a couple of additional event handlers, some ARIA tags to ensure accessibility, and a bunch of CSS.
Thanks for sharing, nice idea to use datalist – an example demo would be great, to see how look & feel is.