Dropdown
Click-to-open menu with outside-click close, single selection, and optional event-bus broadcast. The selected value lives in state, so the trigger label updates reactively without any DOM bookkeeping.
Live preview
Selected:
Markup
<div class="dropdown" data-component="dropdown-demo">
<button class="dropdown-toggle" @click="toggle">
<span data-text="selectedLabel() || 'Sort by'"></span>
<svg data-class="open:open">…chevron…</svg>
</button>
<div class="dropdown-menu" data-if="open">
<template data-each="options" data-key="value">
<button class="menu-item"
@click="select"
data-bind="data-value:item.value"
data-class="selected:item.value === selected">
<span data-text="item.label"></span>
</button>
</template>
</div>
</div>
Definition
Micra.define('dropdown-demo', {
state: {
open: false,
selected: '',
options: [
{ label: 'Newest first', value: 'new' },
{ label: 'Oldest first', value: 'old' },
{ label: 'Most viewed', value: 'pop' },
],
},
// derived value — never store in state
selectedLabel() {
const o = this.state.options.find(x => x.value === this.state.selected)
return o ? o.label : ''
},
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)
},
toggle() { this.state.open = !this.state.open },
select(e) {
this.state.selected = e.currentTarget.dataset.value
this.state.open = false
Micra.emit('sort:changed', this.state.selected)
},
})
Micra.define('dropdown-display', {
state: { value: '' },
onCreate() {
this.on('sort:changed', (v) => { this.state.value = v })
},
})
Integration
- Outside click closes via one document listener attached in onCreate, removed in onDestroy. this.$el is the root, so !this.$el.contains(e.target) identifies outside clicks.
- Selected label is derived, not stored — the selectedLabel() method computes it from selected and options. Two state fields, one source of truth.
- Cross-component handoff via the event bus. Micra.emit('sort:changed', value) lets any other component subscribe with this.on('sort:changed', …) — no parent-prop drilling.
- Multiple instances are isolated. Each [data-component="dropdown-demo"] element gets its own state. Pass per-instance options through data-* attrs and this.prop() if you need them.
Pitfalls
- Don't store selectedLabel in state and update it inside select(). Two fields = two sources of truth = drift.
- Prefer data-if over data-show for the menu. display:none keeps the hidden items in the accessibility tree — some screen readers still announce them — and grows the DOM for a feature that's mostly closed. Mounting only when open is cleaner.
- The outside-click listener fires for clicks on the toggle button too. Because the toggle is inside this.$el, the contains() check correctly ignores it.