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.