Combobox

Searchable select. Type to filter, click or press Enter to pick. Arrow keys move the highlight; Escape closes. The filtered list and the highlighted option are derived from query + highlight — nothing is mirrored in state.

Live preview

No matches

Picked:

Markup

<div class="combobox" data-component="combobox-demo">
  <input class="combobox-input"
    data-model="query"
    @focus="openList"
    @keydown="onKey"
    placeholder="Search…"
    aria-autocomplete="list" />

  <div class="combobox-list" data-if="open">
    <template data-each="filtered()" data-key="value">
      <button class="menu-item"
        @mouseenter="setHighlight"
        @click="pickItem"
        data-bind="data-value:item.value, data-i:$index"
        data-class="highlight:$index === highlight">
        <span data-text="item.label"></span>
      </button>
    </template>
    <p data-if="filtered().length === 0">No matches</p>
  </div>
</div>

Definition

Micra.define('combobox-demo', {
  state: {
    query: '',
    open: false,
    highlight: 0,
    selected: '',
    options: [
      { label: 'Argentina',  value: 'AR' },
      { label: 'Brazil',     value: 'BR' },
      { label: 'Canada',     value: 'CA' },
      { label: 'France',     value: 'FR' },
      { label: 'Germany',    value: 'DE' },
      { label: 'Japan',      value: 'JP' },
      { label: 'Mexico',     value: 'MX' },
      { label: 'Netherlands',value: 'NL' },
      { label: 'Sweden',     value: 'SE' },
    ],
  },

  filtered() {
    const q = this.state.query.trim().toLowerCase()
    if (!q) return this.state.options
    return this.state.options.filter(o => o.label.toLowerCase().includes(q))
  },

  onCreate() {
    this._outside = (e) => {
      if (!this.$el.contains(e.target)) this.state.open = false
    }
    document.addEventListener('click', this._outside)
  },
  onDestroy() {
    document.removeEventListener('click', this._outside)
  },

  openList() { this.state.open = true; this.state.highlight = 0 },

  setHighlight(e) {
    const i = parseInt(e.currentTarget.dataset.i, 10)
    if (!Number.isNaN(i)) this.state.highlight = i
  },

  onKey(e) {
    const list = this.filtered()
    if (e.key === 'ArrowDown') {
      e.preventDefault()
      this.state.open = true
      this.state.highlight = (this.state.highlight + 1) % list.length
    } else if (e.key === 'ArrowUp') {
      e.preventDefault()
      this.state.highlight = (this.state.highlight - 1 + list.length) % list.length
    } else if (e.key === 'Enter') {
      e.preventDefault()
      const pick = list[this.state.highlight]
      if (pick) this._select(pick)
    } else if (e.key === 'Escape') {
      this.state.open = false
    }
  },

  pickItem(e) {
    const value = e.currentTarget.dataset.value
    const pick = this.state.options.find(o => o.value === value)
    if (pick) this._select(pick)
  },

  _select(pick) {
    this.state.selected = pick.value
    this.state.query = pick.label
    this.state.open = false
    Micra.emit('combobox:picked', pick)
  },
})

Integration

  • filtered() is a method, called from both data-each and data-if. Two reads per render, same answer — Micra's expression cache makes this cheap.
  • Keyboard. ↓ opens and moves the highlight, ↑ moves it back, Enter picks, Escape closes. Mouse hover also updates the highlight via @mouseenter.
  • Picked value is announced via Micra.emit('combobox:picked', pick) — any other component can listen with this.on(...).

Pitfalls

  • Don't store filtered as a state field — every query change requires a recompute. Methods run on read; state fields drift.
  • Don't bind highlight via index alone if your list can re-order — pair the index with data-key="value" so keyed diff preserves DOM identity even when the highlight shifts.
  • Don't bind @keydown.enter — Enter inside the search input is also "submit" for a wrapping form. Branch on e.key and call e.preventDefault() explicitly.