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.