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

<div data-component="combobox" @click.outside="close">
  <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>
</div>

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;
    }
  },
});

$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. @click.outside="close" on the root closes the list when the click lands anywhere else on the page; Micra removes the listener on destroy (2.9+).