Tag input

Token field. Type and press Enter or , to commit a tag. Backspace on an empty input removes the last one. Paste splits on commas. Duplicates are de-duped on commit.

Live preview

Markup

<div data-component="tag-input-demo">
  <label class="tag-field" @click="focusInput">
    <template data-each="tags" data-key="id">
      <span class="tag">
        <span data-text="item.text"></span>
        <button class="tag-remove" type="button"
                @click="remove" data-bind="data-id:item.id">×</button>
      </span>
    </template>
    <input class="tag-input-inner"
      data-ref="input"
      data-model="draft"
      @keydown="onKey"
      @paste="onPaste"
      @blur="commitDraft"
      placeholder="Add a tag…" />
  </label>
</div>

Definition

Micra.define('tag-input-demo', {
  state: { tags: [], draft: '' },

  serialize() {
    return this.state.tags.map(t => t.text).join(', ') || '—'
  },

  focusInput() { this.refs.input?.focus() },

  onKey(e) {
    if (e.key === 'Enter' || e.key === ',') {
      e.preventDefault()
      this.commitDraft()
    } else if (e.key === 'Backspace' && this.state.draft === '' && this.state.tags.length) {
      this.state.tags = this.state.tags.slice(0, -1)
    }
  },

  onPaste(e) {
    const text = (e.clipboardData || window.clipboardData).getData('text')
    if (!text.includes(',')) return  // single token, let it land in the input
    e.preventDefault()
    const fresh = text.split(',').map(s => s.trim()).filter(Boolean)
    this.state.tags = this._dedupe([...this.state.tags, ...fresh.map(this._make)])
  },

  commitDraft() {
    const text = this.state.draft.trim()
    if (!text) return
    this.state.tags = this._dedupe([...this.state.tags, this._make(text)])
    this.state.draft = ''
  },

  remove(e) {
    const id = e.currentTarget.dataset.id
    this.state.tags = this.state.tags.filter(t => t.id !== id)
  },

  _make(text) {
    return {
      id:   Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
      text,
    }
  },

  _dedupe(list) {
    const seen = new Set()
    return list.filter(t => {
      const k = t.text.toLowerCase()
      if (seen.has(k)) return false
      seen.add(k)
      return true
    })
  },
})

Integration

  • Tags are objects with ids. The keyed data-each needs a stable identifier — using the tag text as the key would break the moment two tags with the same text coexist.
  • Refs for focus. data-ref="input" exposes the inner input on this.refs.input so the wrapping label can forward focus.
  • Paste split handles "alpha, beta, gamma" from a spreadsheet. Single-token paste falls through to the browser's default input behavior.
  • Submit-safe. e.preventDefault() in onKey stops Enter from submitting a wrapping form. Add type="button" on the tag-remove for the same reason.

Pitfalls

  • Don't store a separate tagCount state field — derive (data-text="tags.length") so it can't drift.
  • Don't use @keydown.enter — you need to differentiate Enter, comma, and Backspace, all in one handler. Branch on e.key.
  • Don't forget commitDraft() on @blur. Otherwise a half-typed tag is discarded when the user clicks "Save" outside the input.