busy-office-ui
Menu

Tag input

Multiple discrete values on one field — cost centers tagged on a record, several recipients on an approval-routing rule. No native element covers this (unlike file/date/segmented), so it's real JS — the same class as Combobox. The framework owns removal (it's deleting its own rendered chip); addition is yours: Enter dispatches an event with the typed text, you decide whether to accept it. Not for a small fixed set — three or four known options read better as checkboxes in a form field, and if only ONE value is allowed it is a Combobox.

Basic — cost centers

CC-4021 CC-2205

Type a value and press Enter to add it; click a tag's × (or press Backspace in the empty field) to remove the last one. Try it — this demo rejects exact duplicates, same as a real consumer would.

Approval-routing recipients

j.kim@busy-office.example

Empty

The container works with zero tags — no special empty-state markup needed.

What happens when a tag is removed

Removing a chip with the keyboard used to leave focus on <body> — the reader lost their place in the form. Three rules, in this order:

  1. bo:tag-remove fires first, from the chip, while it is still in the document — so a listener can read its position or siblings — and it bubbles, carrying detail.value.
  2. Then the chip is removed.
  3. Then focus moves to that same group's .bo-tag-input__field — but only if the chip being removed contained focus. Removing a chip the user was not on never moves them.

The field is the destination because it already exists in the markup contract and is where the next value goes, so no new attribute, option or roving tab order is introduced. If your own bo:tag-remove listener moves focus somewhere else, that wins — the framework checks and does not override a deliberate move.

The status sentence is yours. The framework moves focus; it does not announce the removal. If removing a tag should be spoken (“CC-4021 removed, 2 remaining”), write it into your own live region from your bo:tag-remove listener — the same “framework does visuals, you do the data” split as adding a tag. What is verified here is where focus lands and the order of the three steps above, in a real browser with real key presses; whether a screen reader speaks it is a separate question these checks do not answer.

Markup

<!-- The framework owns REMOVAL (it's the framework's own rendered
     chip) but not ADDITION: Enter dispatches bo:tag-add with the typed
     text and clears the field — YOUR code validates/dedupes and appends
     the actual .bo-tag-input__tag markup if accepted. -->
<div class="bo-tag-input" role="group" aria-label="Cost centers">
  <span class="bo-tag-input__tag">CC-4021
    <button class="bo-tag-input__remove" type="button" aria-label="Remove CC-4021">×</button>
  </span>
  <input class="bo-tag-input__field" type="text" aria-label="Add a cost center" placeholder="Add a cost center…">
</div>
<script type="module">
  import { initTagInput } from "@busy-office/ui/js";
  initTagInput();

  document.addEventListener("bo:tag-add", (e) => {
    const group = e.target.closest(".bo-tag-input");
    // Your validation/dedup goes here — this demo just rejects blanks
    // (already guarded by the behavior) and exact duplicates.
    const existing = [...group.querySelectorAll(".bo-tag-input__tag")]
      .map((t) => t.firstChild.textContent.trim());
    if (existing.includes(e.detail.value)) return;

    const tag = document.createElement("span");
    tag.className = "bo-tag-input__tag";
    tag.append(e.detail.value + " ");
    const remove = document.createElement("button");
    remove.className = "bo-tag-input__remove";
    remove.type = "button";
    remove.setAttribute("aria-label", `Remove ${e.detail.value}`);
    remove.textContent = "×";
    tag.append(remove);
    group.querySelector(".bo-tag-input__field").before(tag);
  });
</script>

Quick reference generated

ClassRole
.bo-tag-input block (root)
.bo-tag-input--seamless variant: seamless
.bo-tag-input__field part: field
.bo-tag-input__remove part: remove
.bo-tag-input__tag part: tag

API generated from CSS

Blocks bo-tag-input
Parts bo-tag-input__fieldbo-tag-input__removebo-tag-input__tag
Variants bo-tag-input--seamless
JS required Required: initTagInput() — without it, Enter/Backspace do nothing and remove buttons don't remove their tag.
  • The framework owns REMOVAL only — a remove-button click or Backspace-on-empty deletes the framework's own rendered `.bo-tag-input__tag` and dispatches `bo:tag-remove`. Addition is yours: Enter dispatches `bo:tag-add` with the typed text and clears the field; validate/dedupe and append the tag markup yourself.
  • Keyboard access to remove buttons is plain Tab order — they're real button elements, no reimplemented roving-tabindex needed.
  • Wrap the group in `role="group"` with an `aria-label` naming what the tags represent (cost centers, recipients) — a native group has no accessible name of its own.
  • Works with zero tags — no special empty-state markup.
Contrast — 27 AA pairs this component uses (generated, CI-gated)
TextonLightDark
text-primary bg-surface 17.74 16.15
text-primary bg-canvas 16.98 18.08
text-secondary bg-surface 10.31 11.46
text-secondary bg-muted 9.37 9.75
text-muted bg-surface 7.56 6.65
text-muted bg-muted 6.87 5.66
bg-surface text-primary 17.74 16.15
danger-text bg-surface 6.47 8.89
success-text bg-surface 5.02 12.02
accent bg-surface 5.47 9.07
text-primary bg-hover 16.12 13.75
text-primary bg-selected 17.01 11.86
text-secondary bg-selected 9.88 8.41
text-primary bg-surface-raised 17.74 14.51
text-secondary bg-canvas 9.86 12.82
border-control bg-surface 4.83 6.65
--bo-state-error-color bg-surface 4.83 6.1
border-control bg-hover 4.39 5.66
border-control bg-muted 4.39 5.66
focus-ring bg-canvas 3.58 5.05
accent bg-muted 4.97 7.72
warning-strong bg-muted 4.56 6.69
warning-strong bg-surface 5.02 7.86
danger bg-muted 4.39 5.19
text-primary bg-muted 16.12 13.75
success-text bg-muted 4.56 10.23
danger-text bg-muted 5.88 7.57

Maturity generated

Introduced
v0.3.0
CSS floor
Chrome/Edge 99 · Firefox 97 · Safari 15.4
Alignment scored
2026-08-21
Assistive-tech evidence
None recorded

Pin this component's first release with npm i @busy-office/ui@0.3.0. The floor is derived from this component's stylesheet alone — set by @layer — so it sits at or below the whole framework's Chrome/Edge 119 · Firefox 129 · Safari 17.5. No assistive-technology runtime result is recorded for any component: it needs a human listening to VoiceOver and NVDA on owner hardware; no gate in this repo can simulate announcement behaviour. What automation covers instead is test:axe across every page at 1440 and 390, and check:forced-colors — see accessibility.

Design-system alignment generated

100% (18/18) — how closely this component follows the design language. Each rule is scored 0-3 against a checkable citation; rules that don't apply leave the denominator. Scored 2026-08-21.

Rule Score Evidence
Typography 3 / 3 no raw font-size
Colour 3 / 3 zero raw hex; forced-colors handled with a real 1px CanvasText border
Spacing 3 / 3 chip hug, 1rem remove box and 6rem field floor all carry their reason, incl. the 2.5.8 spacing-exception route (94.2)
Interaction 3 / 3 bubbling intent events (bo:tag-add) leave chip DATA with the consumer — framework does visuals
Content 3 / 3 the opener names two wrong-choice contexts and both alternatives: checkboxes for a small fixed set, Combobox when only one value is allowed (94.8)
Fit 3 / 3 multi-value entry; distinct from combobox's single-select, and the pages cross-name each other
↑↓ move Enter open Esc close