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.