Combobox
A typeahead select with full keyboard navigation. The visible options are a
filtered() method over the query, and arrow keys move a highlighted index
through that filtered list.
Markup
<input
role="combobox"
data-model="query"
@input="open"
@keydown.down.prevent="move(1)"
@keydown.up.prevent="move(-1)"
@keydown.enter.prevent="choose"
@keydown.escape="close"
data-bind="aria-expanded:isOpen ? 'true' : 'false'"
/>
<ul role="listbox" data-show="isOpen">
<template data-each="filtered()" data-key="id">
<li
role="option"
@click="pick"
data-bind="class:optClass($index), data-i:$index, aria-selected:$index === active ? 'true' : 'false'"
data-text="item.name"
></li>
</template>
</ul>
Component
Micra.define("combobox", {
state: {
query: "",
isOpen: false,
active: 0,
options: [
{ id: 1, name: "Astro" },
{ id: 2, name: "Rails" },
{ id: 3, name: "Laravel" },
{ id: 4, name: "Django" },
{ id: 5, name: "Phoenix" },
{ id: 6, name: "Express" },
{ id: 7, name: "Sinatra" },
],
},
filtered() {
const q = this.state.query.trim().toLowerCase();
return this.state.options.filter((o) => o.name.toLowerCase().includes(q));
},
optClass(i) {
const base = "cursor-pointer rounded-md px-3 py-2 text-sm ";
return base + (i === this.state.active ? "bg-indigo-600 text-white" : "hover:bg-zinc-100");
},
open() {
this.state.isOpen = true;
this.state.active = 0;
},
close() {
this.state.isOpen = false;
},
move(d) {
const n = this.filtered().length;
if (!n) return;
this.state.isOpen = true;
this.state.active = (this.state.active + d + n) % n;
},
choose() {
const f = this.filtered()[this.state.active];
if (f) {
this.state.query = f.name;
this.state.isOpen = false;
}
},
pick(e) {
const f = this.filtered()[Number(e.currentTarget.dataset.i)];
if (f) {
this.state.query = f.name;
this.state.isOpen = false;
}
},
onCreate() {
this._outside = (e) => {
if (!this.$el.contains(e.target)) this.state.isOpen = false;
};
document.addEventListener("click", this._outside);
},
onDestroy() {
document.removeEventListener("click", this._outside);
},
});
$index is the position within the rendered data-each, which is exactly the
filtered list — so the highlighted row and the keyboard cursor never drift apart.