Skip to content

Tags — live demo

This is the real @rozie-ui/tags-vue package running on this page (VitePress is itself a Vue app). Type a tag and press Enter or comma, paste a comma-separated list (it bulk-adds), backspace through an empty input to delete the last tag, or click a chip's × to remove it — then watch the two-way bound array update. Everything below is driven by the same Tags.rozie source that compiles to all six frameworks, built on one native <input> plus framework-rendered chips with no engine and no required CSS.

modelValue is two-way bound with v-model:modelValue — the readout updates the instant you add or remove, and a consumer write flows back in. The Skills instance caps at :max="8" (the input disables once full) and its buttons drive the imperative handle (clear(), focus()) grabbed through Vue's ref. The Emails instance passes a :validate function that rejects non-emails and lower-cases accepted ones — a rejected candidate is silently dropped. See the full API for every prop, event, handle verb, the scoped #tag slot, plus theming and accessibility reference.

One source, six outputs

You author the component once as a .rozie file:

html
<!--
  Tags.rozie — a headless, WAI-ARIA accessible tags / token input.

  A pure-Rozie family (NO third-party engine) in the spirit of otp/slider/listbox:
  it fills a real cross-framework need (every form UI re-implements a chips/token
  input) with zero engine dependency. The platform IS the engine: one native
  <input> for typing + a row of removable chips. Rozie owns the author-side API,
  the two-way binding, the commit/dedup/validate logic, paste-to-bulk-add, the
  backspace-deletes-previous behaviour, and the token-themed skin.

  CONTROLLED TOKENS, LOCAL DRAFT: the committed tokens ARE `modelValue` (the sole
  model:true prop → Angular ControlValueAccessor; a tags input IS a form control).
  The only local state is `draft` — the in-progress, not-yet-committed input text
  (a genuine UI buffer, distinct from the committed value, so it lives in <data>).
  Type + a delimiter key (Enter / comma by default) commits the draft as a token;
  paste splits on delimiters and bulk-adds; Backspace in an EMPTY input deletes
  the previous token.

  COMMIT FUNNEL: every mutation of the committed list funnels through ONE
  `commitTokens(next)` so its $emit prop-destructure hoists exactly once on React
  (Phase 46 also dedups, but the single-funnel pattern is kept for clarity — the
  otp/slider precedent). Token additions go through `addToken(raw)` (normalize →
  validate → dedup → cap) before the funnel.

  FOCUS without per-cell refs: focus choreography reads `$refs.root` (ONE
  container ref) and walks `root.querySelector('input')` — read only in $onMount /
  event handlers / $expose verbs (all post-mount → ROZ123-safe), and it works
  inside Lit's shadow root because the container lives there too.

  Authoring notes (collision classes — see the authoring playbook §6):
    - Every write funnels through `commitTokens`, NOT a helper named
      `writeValue`/`registerOnChange`/`registerOnTouched`/`setDisabledState`: a
      plain helper with one of those names would collide with the generated
      Angular ControlValueAccessor (single model:true → CVA) and break the
      ng-packagr build with TS2300. `commitTokens` is collision-safe.
    - No `setModelValue` helper — that would collide with React's generated
      `setModelValue` setter for the `modelValue` model (ROZ524). The funnel is
      `commitTokens`.
    - The expose verbs are `clear` + `focus`. `focus` DELIBERATELY overrides the
      inherited `HTMLElement.focus` on the Lit custom element — the public
      `focus()` handle (focuses the inline text input) is the intended semantics,
      so the warn-only ROZ137 on the Lit leaf is JUSTIFIED and accepted (the
      otp/slider precedent; consistent with NumberField, which also exposes
      `focus`). `clear` is collision-safe.
    - Handler params are LEFT UNTYPED so they neutralize to `any`; reading
      `e.target.value` / `e.key` / `e.clipboardData` then typechecks across all
      six strict leaves (the global-filter idiom). Never annotate them.
    - `tokens()` / `commitKeys()` are PLAIN functions (not $computed): they are
      called from BOTH the template (r-for / bindings) AND from handlers, and a
      $computed is a value on React but an accessor on Solid — aliasing/calling
      it diverges. Keep derived collections that script logic touches as plain
      functions called `()` uniformly.
    - aria booleans bound from a CALL wrap in rozieAttr→string on JSX, so prefix
      `!!` (`:aria-disabled="!!$props.disabled"`). NEVER drop the attr on a false
      value for an a11y attribute.

  Slot: a scoped `#tag` slot (params { tag, index, remove }) lets a consumer fully
  replace the chip rendering; the default fallback renders the built-in chip. The
  slot name `tag` does NOT equal any prop key (ROZ127-safe).

  Consumer example:

    <Tags r-model:modelValue="$data.skills" placeholder="Add a skill…"
          :max="8" ariaLabel="Skills" @add="onAdd" />
-->

<rozie name="Tags">

<props>
{
  // The committed tokens (two-way). As the sole model:true prop it drives the
  // Angular ControlValueAccessor — a tags input IS a form control. Always a
  // de-duplicated (unless `allowDuplicates`) string array.
  modelValue: {
    type: Array, default: () => [], model: true,
    docs: {
      description:
        'The committed tokens — `model: true`, so a commit/remove/paste writes a **fresh** array back through `r-model:modelValue` (uncontrolled fallback `[]`). Because it is the sole model prop, the Angular output is a `ControlValueAccessor` (`[formControl]` / `[(ngModel)]` bind directly).',
      example: '<Tags r-model:modelValue="skills" placeholder="Add a skill…" />',
    },
  },

  // The keys that commit the current draft as a token. Default: comma + Enter.
  // Pasted text is split on the non-`Enter` entries (treated as separators).
  delimiters: {
    type: Array, default: () => [',', 'Enter'],
    docs: {
      description:
        "The keys that commit the current draft as a token (matched against the key event's `key`). Default `[',', 'Enter']`. Non-`'Enter'` entries also act as the split characters when pasting bulk text. Use e.g. `[' ', 'Enter']` for a space-delimited input.",
    },
  },

  // Allow the same token more than once. Default false (dedup on commit).
  allowDuplicates: {
    type: Boolean, default: false,
    docs: {
      description:
        'Allow the same token value to be added more than once. Defaults to `false` — a candidate equal (case-sensitive) to an existing token is silently rejected on commit. Set `true` to permit duplicates.',
    },
  },

  // Maximum number of tokens; no add past the cap. `null` = unlimited.
  max: {
    type: Number, default: null,
    docs: {
      description:
        'Maximum number of tokens. Once the list reaches `max`, the input is disabled and further adds (type, paste, programmatic) are rejected. `null` (the default) means unlimited.',
    },
  },

  // Disable the whole control (input + remove buttons).
  disabled: {
    type: Boolean, default: false,
    docs: {
      description:
        'Disable the whole control — the text input is disabled, every remove button is disabled, and no token can be added or removed. Also sets the Angular CVA disabled state.',
    },
  },

  // Read-only: tokens are visible but cannot be added or removed; no input.
  readonly: {
    type: Boolean, default: false,
    docs: {
      description:
        'Render the tokens read-only — they remain visible but cannot be added or removed, and the text input is hidden. Unlike `disabled` it carries no disabled styling, so it reads as a display of committed values.',
    },
  },

  // Optional per-token validator/normalizer. Receives the candidate string and
  // the current tokens; return a string to ACCEPT (optionally normalized), or a
  // falsy value (false / '' / null / undefined) to REJECT the candidate.
  validate: {
    type: Function, default: null,
    docs: {
      description:
        'Optional per-token validator / normalizer. Called with `(candidate, tokens)` for each commit; return a (possibly normalized) **string** to accept it, or a falsy value (`false` / `null` / `""`) to reject the candidate. Runs before the dedup + `max` checks. Example: `v => /^\\S+@\\S+$/.test(v) ? v.toLowerCase() : false` for emails.',
      example: 'validate: (v) => (v.length >= 2 ? v.trim() : false)',
    },
  },

  // Placeholder for the text input.
  placeholder: {
    type: String, default: '',
    docs: {
      description: 'Placeholder text for the inline text input (e.g. `"Add a tag…"`).',
    },
  },

  // Accessible name for the whole group (role="group"). The input gets the same
  // label so a screen reader announces what is being added.
  ariaLabel: {
    type: String, default: null,
    docs: {
      description:
        'Accessible name for the whole control (`role="group"`). The inline text input is labelled with the same name so assistive tech announces what is being entered. A visually-hidden live region announces the current token count on change.',
    },
  },
}
</props>

<data>
{
  // The in-progress, not-yet-committed input text. The ONE genuine local UI
  // buffer (distinct from the committed `modelValue`), so it lives here.
  draft: '',
}
</data>

<script lang="ts">
// ---- derived view (plain functions, uniform ×6) ------------------------
// The committed tokens, normalized to a string[].
const tokens = () => (Array.isArray($props.modelValue) ? $props.modelValue : [])

// The configured commit keys, normalized to a string[].
const commitKeys = () => (Array.isArray($props.delimiters) ? $props.delimiters : [',', 'Enter'])

// The non-Enter delimiters act as split characters for paste.
const splitChars = () => commitKeys().filter((k) => k !== 'Enter')

// Whether the control has reached its token cap.
const atMax = () => typeof $props.max === 'number' && tokens().length >= $props.max

// Whether new input is accepted at all.
const canEdit = () => !$props.disabled && !$props.readonly

// ---- write funnel (single $emit site) ----------------------------------
// Write the model and emit change. Every committed-list mutation funnels here.
const commitTokens = (next) => {
  $model.modelValue = next
  $emit('change', { value: next })
}

// ---- add / remove ------------------------------------------------------
// Normalize → validate → dedup → cap a candidate, then commit + emit add.
// Returns true if it was added (so the caller can clear the draft).
const addToken = (raw) => {
  if (!canEdit()) return false
  let candidate = String(raw == null ? '' : raw).trim()
  if (!candidate) return false
  if (typeof $props.validate === 'function') {
    const result = $props.validate(candidate, tokens())
    if (!result) return false
    candidate = String(result)
    if (!candidate) return false
  }
  const cur = tokens()
  if (!$props.allowDuplicates && cur.indexOf(candidate) !== -1) return false
  if (typeof $props.max === 'number' && cur.length >= $props.max) return false
  const next = cur.concat([candidate])
  commitTokens(next)
  $emit('add', { value: candidate, tokens: next })
  return true
}

// Remove the token at `idx`, commit, and emit remove.
const removeAt = (idx) => {
  if (!canEdit()) return
  const cur = tokens()
  if (idx < 0 || idx >= cur.length) return
  const removed = cur[idx]
  const next = cur.slice(0, idx).concat(cur.slice(idx + 1))
  commitTokens(next)
  $emit('remove', { value: removed, index: idx, tokens: next })
}

// ---- focus (container ref, post-mount only) ----------------------------
// Read $refs.root only here / in $onMount / in $expose verbs (post-mount →
// ROZ123-safe). querySelector reaches the input inside Lit's shadow root too.
const focusTheInput = () => {
  const root = $refs.root
  if (!root) return
  const el = root.querySelector('input')
  if (el) el.focus()
}

// ---- input handlers ----------------------------------------------------
// Mirror the typed text into the draft buffer. Capture the fresh local value
// (do NOT re-read $data.draft in the same handler — React setState is async and
// would read the pre-write value).
const onInput = (e) => {
  $data.draft = e && e.target ? e.target.value : ''
}

// A delimiter key commits the current draft; Backspace in an empty input
// deletes the previous token.
const onKeydown = (e) => {
  if (!canEdit()) return
  const key = e ? e.key : ''
  const value = e && e.target ? e.target.value : ''
  if (commitKeys().indexOf(key) !== -1) {
    if (e) e.preventDefault()
    if (addToken(value)) $data.draft = ''
    return
  }
  if (key === 'Backspace' && value === '') {
    const cur = tokens()
    if (cur.length > 0) {
      if (e) e.preventDefault()
      removeAt(cur.length - 1)
    }
  }
}

// Commit any leftover draft when the input loses focus (a common chips UX).
const onBlur = (e) => {
  if (!canEdit()) return
  const value = e && e.target ? e.target.value : ''
  if (value && addToken(value)) $data.draft = ''
}

// Paste: split on the configured delimiter characters and bulk-add.
const onPaste = (e) => {
  if (!canEdit()) return
  const text = (e && e.clipboardData && e.clipboardData.getData('text')) || ''
  const seps = splitChars()
  let parts = [text]
  if (seps.length) {
    // Split on every separator char in turn.
    for (let s = 0; s < seps.length; s++) {
      const sep = seps[s]
      const out = []
      for (let p = 0; p < parts.length; p++) {
        const pieces = String(parts[p]).split(sep)
        for (let q = 0; q < pieces.length; q++) out.push(pieces[q])
      }
      parts = out
    }
  }
  const trimmed = parts.map((p) => String(p).trim()).filter((p) => p.length > 0)
  if (trimmed.length <= 1 && seps.length === 0) return // let the input handle a plain paste
  if (e) e.preventDefault()
  let addedAny = false
  for (let i = 0; i < trimmed.length; i++) {
    if (addToken(trimmed[i])) addedAny = true
  }
  if (addedAny) $data.draft = ''
}

// ---- per-element attribute helpers -------------------------------------
const removeLabel = (t) => 'Remove ' + String(t)
const countLabel = () => {
  const n = tokens().length
  return n === 1 ? '1 tag' : n + ' tags'
}

// ---- lifecycle + imperative handle -------------------------------------
// clear() — remove every token (emits change with []) and focus the input.
const clear = () => {
  commitTokens([])
  $data.draft = ''
  focusTheInput()
}
// focus() — move DOM focus to the text input. DELIBERATELY overrides the
// inherited HTMLElement.focus on the Lit custom element (warn-only ROZ137,
// accepted — the public focus() handle is the intended semantics; otp/slider
// precedent, consistent with NumberField which also exposes `focus`).
const focus = () => focusTheInput()

$expose({ clear, focus })
</script>

<template>
<div
  class="rozie-tags"
  ref="root"
  role="group"
  :aria-label="$props.ariaLabel"
  :class="{ 'rozie-tags--disabled': $props.disabled, 'rozie-tags--readonly': $props.readonly }"
>
  <ul class="rozie-tags-list">
    <li r-for="t in tokens()" :key="t + ':' + tokens().indexOf(t)" class="rozie-tags-chip">
      <slot name="tag" :tag="t" :index="tokens().indexOf(t)" :remove="() => removeAt(tokens().indexOf(t))">
        <span class="rozie-tags-chip__label">{{ t }}</span>
        <button
          r-if="!$props.readonly"
          type="button"
          class="rozie-tags-chip__remove"
          :disabled="!!$props.disabled"
          :aria-label="removeLabel(t)"
          @click="removeAt(tokens().indexOf(t))"
        >×</button>
      </slot>
    </li>
  </ul>

  <input
    r-if="!$props.readonly"
    class="rozie-tags-input"
    type="text"
    autocomplete="off"
    autocapitalize="off"
    :value="$data.draft"
    :placeholder="$props.placeholder"
    :disabled="!!$props.disabled || !!atMax()"
    :aria-label="$props.ariaLabel"
    :aria-disabled="!!$props.disabled"
    @input="onInput($event)"
    @keydown="onKeydown($event)"
    @paste="onPaste($event)"
    @blur="onBlur($event)"
  />

  <span class="rozie-tags-count" aria-live="polite">{{ countLabel() }}</span>
</div>
</template>

<style>
/*
  Fully token-driven (mirrors otp/slider themes): EVERY visual value is a
  `var(--rozie-tags-*, <fallback>)`, so the component renders with zero config
  yet is completely re-skinnable by setting tokens at any ancestor scope. The
  shipped themes/*.css presets map these tokens onto shadcn/Radix, Material 3,
  Bootstrap 5.
*/
.rozie-tags {
  display: inline-flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--rozie-tags-gap, 0.4rem);
  padding: var(--rozie-tags-padding, 0.35rem 0.45rem);
  font: var(--rozie-tags-font, inherit);
  background: var(--rozie-tags-bg, #fff);
  border: var(--rozie-tags-border-width, 1px) solid var(--rozie-tags-border-color, rgba(0, 0, 0, 0.25));
  border-radius: var(--rozie-tags-radius, 0.5rem);
  min-width: var(--rozie-tags-min-width, 12rem);
}
.rozie-tags:focus-within {
  border-color: var(--rozie-tags-accent, #0066cc);
  box-shadow: 0 0 0 var(--rozie-tags-focus-ring-width, 3px) var(--rozie-tags-focus-ring-color, rgba(0, 102, 204, 0.25));
}
.rozie-tags-list {
  display: contents;
  margin: 0;
  padding: 0;
  list-style: none;
}
.rozie-tags-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--rozie-tags-chip-gap, 0.3rem);
  padding: var(--rozie-tags-chip-padding, 0.15rem 0.5rem);
  font-size: var(--rozie-tags-chip-font-size, 0.85rem);
  color: var(--rozie-tags-chip-color, inherit);
  background: var(--rozie-tags-chip-bg, rgba(0, 102, 204, 0.12));
  border-radius: var(--rozie-tags-chip-radius, 0.375rem);
  white-space: nowrap;
}
.rozie-tags-chip__remove {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--rozie-tags-remove-size, 1.1rem);
  height: var(--rozie-tags-remove-size, 1.1rem);
  padding: 0;
  font: inherit;
  line-height: 1;
  color: var(--rozie-tags-remove-color, currentColor);
  background: transparent;
  border: none;
  border-radius: 50%;
  cursor: pointer;
  opacity: var(--rozie-tags-remove-opacity, 0.65);
  transition: opacity 0.15s, background 0.15s;
}
.rozie-tags-chip__remove:hover:not(:disabled) {
  opacity: 1;
  background: var(--rozie-tags-remove-hover-bg, rgba(0, 0, 0, 0.1));
}
.rozie-tags-chip__remove:disabled {
  cursor: not-allowed;
  opacity: 0.4;
}
.rozie-tags-input {
  flex: 1 1 var(--rozie-tags-input-min, 4rem);
  min-width: var(--rozie-tags-input-min, 4rem);
  padding: var(--rozie-tags-input-padding, 0.15rem 0.1rem);
  font: inherit;
  color: var(--rozie-tags-color, inherit);
  background: transparent;
  border: none;
  outline: none;
}
.rozie-tags-input::placeholder {
  color: var(--rozie-tags-placeholder-color, rgba(0, 0, 0, 0.4));
}
.rozie-tags-input:disabled {
  cursor: not-allowed;
}
.rozie-tags-count {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.rozie-tags--disabled {
  cursor: not-allowed;
  opacity: var(--rozie-tags-disabled-opacity, 0.6);
  background: var(--rozie-tags-disabled-bg, rgba(0, 0, 0, 0.04));
}
</style>

</rozie>

…and Rozie compiles it to six idiomatic, framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/tags-{react,vue,svelte,angular,solid,lit}):

tsx
import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { clsx, rozieAttr, rozieDisplay, useControllableState } from '@rozie/runtime-react';
import './Tags.css';

interface TagCtx { tag: any; index: any; remove: any; }

interface TagsProps {
  /**
   * The committed tokens — `model: true`, so a commit/remove/paste writes a **fresh** array back through `r-model:modelValue` (uncontrolled fallback `[]`). Because it is the sole model prop, the Angular output is a `ControlValueAccessor` (`[formControl]` / `[(ngModel)]` bind directly).
   * @example
   * <Tags r-model:modelValue="skills" placeholder="Add a skill…" />
   */
  modelValue?: any[];
  defaultModelValue?: any[];
  onModelValueChange?: (modelValue: any[]) => void;
  /**
   * The keys that commit the current draft as a token (matched against the key event's `key`). Default `[',', 'Enter']`. Non-`'Enter'` entries also act as the split characters when pasting bulk text. Use e.g. `[' ', 'Enter']` for a space-delimited input.
   */
  delimiters?: any[];
  /**
   * Allow the same token value to be added more than once. Defaults to `false` — a candidate equal (case-sensitive) to an existing token is silently rejected on commit. Set `true` to permit duplicates.
   */
  allowDuplicates?: boolean;
  /**
   * Maximum number of tokens. Once the list reaches `max`, the input is disabled and further adds (type, paste, programmatic) are rejected. `null` (the default) means unlimited.
   */
  max?: (number) | null;
  /**
   * Disable the whole control — the text input is disabled, every remove button is disabled, and no token can be added or removed. Also sets the Angular CVA disabled state.
   */
  disabled?: boolean;
  /**
   * Render the tokens read-only — they remain visible but cannot be added or removed, and the text input is hidden. Unlike `disabled` it carries no disabled styling, so it reads as a display of committed values.
   */
  readonly?: boolean;
  /**
   * Optional per-token validator / normalizer. Called with `(candidate, tokens)` for each commit; return a (possibly normalized) **string** to accept it, or a falsy value (`false` / `null` / `""`) to reject the candidate. Runs before the dedup + `max` checks. Example: `v => /^\S+@\S+$/.test(v) ? v.toLowerCase() : false` for emails.
   * @example
   * validate: (v) => (v.length >= 2 ? v.trim() : false)
   */
  validate?: ((...args: any[]) => any) | null;
  /**
   * Placeholder text for the inline text input (e.g. `"Add a tag…"`).
   */
  placeholder?: string;
  /**
   * Accessible name for the whole control (`role="group"`). The inline text input is labelled with the same name so assistive tech announces what is being entered. A visually-hidden live region announces the current token count on change.
   */
  ariaLabel?: (string) | null;
  onChange?: (...args: any[]) => void;
  onAdd?: (...args: any[]) => void;
  onRemove?: (...args: any[]) => void;
  renderTag?: (ctx: TagCtx) => ReactNode;
  slots?: Record<string, () => import('react').ReactNode>;
}

export interface TagsHandle {
  clear: (...args: any[]) => any;
  focus: (...args: any[]) => any;
}

const Tags = forwardRef<TagsHandle, TagsProps>(function Tags(_props: TagsProps, ref): JSX.Element {
  const __defaultDelimiters = useState(() => (() => [',', 'Enter'])())[0];
  const props: Omit<TagsProps, 'delimiters' | 'allowDuplicates' | 'max' | 'disabled' | 'readonly' | 'validate' | 'placeholder' | 'ariaLabel'> & { delimiters: any[]; allowDuplicates: boolean; max: (number) | null; disabled: boolean; readonly: boolean; validate: ((...args: any[]) => any) | null; placeholder: string; ariaLabel: (string) | null } = {
    ..._props,
    delimiters: _props.delimiters ?? __defaultDelimiters,
    allowDuplicates: _props.allowDuplicates ?? false,
    max: _props.max ?? null,
    disabled: _props.disabled ?? false,
    readonly: _props.readonly ?? false,
    validate: _props.validate ?? null,
    placeholder: _props.placeholder ?? '',
    ariaLabel: _props.ariaLabel ?? null,
  };
  const attrs: Record<string, unknown> = (() => {
    const { modelValue, delimiters, allowDuplicates, max, disabled, readonly, validate, placeholder, ariaLabel, defaultValue, onModelValueChange, defaultModelValue, ...rest } = _props as TagsProps & Record<string, unknown>;
    void modelValue; void delimiters; void allowDuplicates; void max; void disabled; void readonly; void validate; void placeholder; void ariaLabel; void defaultValue; void onModelValueChange; void defaultModelValue;
    return rest;
  })();
  const [modelValue, setModelValue] = useControllableState({
    value: props.modelValue,
    defaultValue: props.defaultModelValue ?? (() => [])(),
    onValueChange: props.onModelValueChange,
  });
  const [draft, setDraft] = useState('');
  const root = useRef<HTMLDivElement | null>(null);

  const tokens = useCallback(() => Array.isArray(modelValue) ? modelValue : [], [modelValue]);
  function commitKeys() {
    return Array.isArray(props.delimiters) ? props.delimiters : [',', 'Enter'];
  }
  function splitChars() {
    return commitKeys().filter((k: any) => k !== 'Enter');
  }
  function atMax() {
    return typeof props.max === 'number' && tokens().length >= props.max;
  }
  function canEdit() {
    return !props.disabled && !props.readonly;
  }
  function commitTokens(next: any) {
    setModelValue(next);
    props.onChange && props.onChange({
      value: next
    });
  }
  function addToken(raw: any) {
    if (!canEdit()) return false;
    let candidate = String(raw == null ? '' : raw).trim();
    if (!candidate) return false;
    if (typeof props.validate === 'function') {
      const result = props.validate(candidate, tokens());
      if (!result) return false;
      candidate = String(result);
      if (!candidate) return false;
    }
    const cur = tokens();
    if (!props.allowDuplicates && cur.indexOf(candidate) !== -1) return false;
    if (typeof props.max === 'number' && cur.length >= props.max) return false;
    const next = cur.concat([candidate]);
    commitTokens(next);
    props.onAdd && props.onAdd({
      value: candidate,
      tokens: next
    });
    return true;
  }
  const { onRemove: _rozieProp_onRemove } = props;
    const removeAt = useCallback((idx: any) => {
    if (!canEdit()) return;
    const cur = tokens();
    if (idx < 0 || idx >= cur.length) return;
    const removed = cur[idx];
    const next = cur.slice(0, idx).concat(cur.slice(idx + 1));
    commitTokens(next);
    _rozieProp_onRemove && _rozieProp_onRemove({
      value: removed,
      index: idx,
      tokens: next
    });
  }, [_rozieProp_onRemove, canEdit, commitTokens, tokens]);
  function focusTheInput() {
    const root$local = root.current;
    if (!root$local) return;
    const el = root$local.querySelector('input');
    if (el) el.focus();
  }
  const onInput = useCallback((e: any) => {
    setDraft(e && e.target ? e.target.value : '');
  }, []);
  const onKeydown = useCallback((e: any) => {
    if (!canEdit()) return;
    const key = e ? e.key : '';
    const value = e && e.target ? e.target.value : '';
    if (commitKeys().indexOf(key) !== -1) {
      if (e) e.preventDefault();
      if (addToken(value)) setDraft('');
      return;
    }
    if (key === 'Backspace' && value === '') {
      const cur = tokens();
      if (cur.length > 0) {
        if (e) e.preventDefault();
        removeAt(cur.length - 1);
      }
    }
  }, [addToken, canEdit, commitKeys, removeAt, tokens]);
  const onBlur = useCallback((e: any) => {
    if (!canEdit()) return;
    const value = e && e.target ? e.target.value : '';
    if (value && addToken(value)) setDraft('');
  }, [addToken, canEdit]);
  const onPaste = useCallback((e: any) => {
    if (!canEdit()) return;
    const text = e && e.clipboardData && e.clipboardData.getData('text') || '';
    const seps = splitChars();
    let parts = [text];
    if (seps.length) {
      // Split on every separator char in turn.
      for (let s = 0; s < seps.length; s++) {
        const sep = seps[s];
        const out = [];
        for (let p = 0; p < parts.length; p++) {
          const pieces = String(parts[p]).split(sep);
          for (let q = 0; q < pieces.length; q++) out.push(pieces[q]);
        }
        parts = out;
      }
    }
    const trimmed = parts.map((p: any) => String(p).trim()).filter((p: any) => p.length > 0);
    if (trimmed.length <= 1 && seps.length === 0) return; // let the input handle a plain paste
    if (e) e.preventDefault();
    let addedAny = false;
    for (let i = 0; i < trimmed.length; i++) {
      if (addToken(trimmed[i])) addedAny = true;
    }
    if (addedAny) setDraft('');
  }, [addToken, canEdit, splitChars]);
  function removeLabel(t: any) {
    return 'Remove ' + String(t);
  }
  function countLabel() {
    const n = tokens().length;
    return n === 1 ? '1 tag' : n + ' tags';
  }
  function clear() {
    commitTokens([]);
    setDraft('');
    focusTheInput();
  }
  function focus() {
    return focusTheInput();
  }

  const _rozieExposeRef = useRef({ clear, focus });
  _rozieExposeRef.current = { clear, focus };
  useImperativeHandle(ref, () => ({ clear: (...args: Parameters<typeof clear>): ReturnType<typeof clear> => _rozieExposeRef.current.clear(...args), focus: (...args: Parameters<typeof focus>): ReturnType<typeof focus> => _rozieExposeRef.current.focus(...args) }), []);

  return (
    <>
    <div ref={root} role="group" aria-label={rozieAttr(props.ariaLabel)} {...attrs} className={clsx(clsx("rozie-tags", { "rozie-tags--disabled": props.disabled, "rozie-tags--readonly": props.readonly }), (attrs.className as string | undefined))} data-rozie-s-64848f8e="">
      <ul className={"rozie-tags-list"} data-rozie-s-64848f8e="">
        {tokens().map((t) => <li key={t + ':' + tokens().indexOf(t)} className={"rozie-tags-chip"} data-rozie-s-64848f8e="">
          {(props.renderTag ?? props.slots?.['tag']) ? ((props.renderTag ?? props.slots?.['tag']) as Function)({ tag: t, index: tokens().indexOf(t), remove: () => removeAt(tokens().indexOf(t)) }) : <><span className={"rozie-tags-chip__label"} data-rozie-s-64848f8e="">{rozieDisplay(t)}</span>{(!props.readonly) && <button type="button" className={"rozie-tags-chip__remove"} disabled={!!props.disabled} aria-label={rozieAttr(removeLabel(t))} onClick={($event) => { removeAt(tokens().indexOf(t)); }} data-rozie-s-64848f8e="">×</button>}</>}
        </li>)}
      </ul>

      {(!props.readonly) && <input className={"rozie-tags-input"} type="text" autoComplete="off" autoCapitalize="off" value={draft} placeholder={props.placeholder} disabled={!!props.disabled || !!atMax()} aria-label={rozieAttr(props.ariaLabel)} aria-disabled={!!props.disabled} onInput={($event) => { onInput($event); }} onKeyDown={($event) => { onKeydown($event); }} onPaste={($event) => { onPaste($event); }} onBlur={($event) => { onBlur($event); }} data-rozie-s-64848f8e="" />}<span className={"rozie-tags-count"} aria-live="polite" data-rozie-s-64848f8e="">{rozieDisplay(countLabel())}</span>
    </div>
    </>
  );
});
export default Tags;
vue
<template>

<div :class="['rozie-tags', { 'rozie-tags--disabled': props.disabled, 'rozie-tags--readonly': props.readonly }]" ref="rootRef" role="group" :aria-label="props.ariaLabel" v-bind="$attrs">
  <ul class="rozie-tags-list">
    <li v-for="t in tokens()" :key="t + ':' + tokens().indexOf(t)" class="rozie-tags-chip">
      <slot name="tag" :tag="t" :index="tokens().indexOf(t)" :remove="() => removeAt(tokens().indexOf(t))">
        <span class="rozie-tags-chip__label">{{ t }}</span>
        <button v-if="!props.readonly" type="button" class="rozie-tags-chip__remove" :disabled="!!props.disabled" :aria-label="removeLabel(t)" @click="removeAt(tokens().indexOf(t))">×</button></slot>
    </li>
  </ul>

  <input v-if="!props.readonly" class="rozie-tags-input" type="text" autocomplete="off" autocapitalize="off" :value="draft" :placeholder="props.placeholder" :disabled="!!props.disabled || !!atMax()" :aria-label="props.ariaLabel" :aria-disabled="!!props.disabled" @input="onInput($event)" @keydown="onKeydown($event)" @paste="onPaste($event)" @blur="onBlur($event)" /><span class="rozie-tags-count" aria-live="polite">{{ countLabel() }}</span>
</div>

</template>

<script setup lang="ts">
import { ref } from 'vue';

const props = withDefaults(
  defineProps<{
    /**
     * The keys that commit the current draft as a token (matched against the key event's `key`). Default `[',', 'Enter']`. Non-`'Enter'` entries also act as the split characters when pasting bulk text. Use e.g. `[' ', 'Enter']` for a space-delimited input.
     */
    delimiters?: any[];
    /**
     * Allow the same token value to be added more than once. Defaults to `false` — a candidate equal (case-sensitive) to an existing token is silently rejected on commit. Set `true` to permit duplicates.
     */
    allowDuplicates?: boolean;
    /**
     * Maximum number of tokens. Once the list reaches `max`, the input is disabled and further adds (type, paste, programmatic) are rejected. `null` (the default) means unlimited.
     */
    max?: number | null;
    /**
     * Disable the whole control — the text input is disabled, every remove button is disabled, and no token can be added or removed. Also sets the Angular CVA disabled state.
     */
    disabled?: boolean;
    /**
     * Render the tokens read-only — they remain visible but cannot be added or removed, and the text input is hidden. Unlike `disabled` it carries no disabled styling, so it reads as a display of committed values.
     */
    readonly?: boolean;
    /**
     * Optional per-token validator / normalizer. Called with `(candidate, tokens)` for each commit; return a (possibly normalized) **string** to accept it, or a falsy value (`false` / `null` / `""`) to reject the candidate. Runs before the dedup + `max` checks. Example: `v => /^\S+@\S+$/.test(v) ? v.toLowerCase() : false` for emails.
     * @example
     * validate: (v) => (v.length >= 2 ? v.trim() : false)
     */
    validate?: ((...args: any[]) => any) | null;
    /**
     * Placeholder text for the inline text input (e.g. `"Add a tag…"`).
     */
    placeholder?: string;
    /**
     * Accessible name for the whole control (`role="group"`). The inline text input is labelled with the same name so assistive tech announces what is being entered. A visually-hidden live region announces the current token count on change.
     */
    ariaLabel?: string | null;
  }>(),
  { delimiters: () => [',', 'Enter'], allowDuplicates: false, max: null, disabled: false, readonly: false, validate: null, placeholder: '', ariaLabel: null }
);

/**
 * The committed tokens — `model: true`, so a commit/remove/paste writes a **fresh** array back through `r-model:modelValue` (uncontrolled fallback `[]`). Because it is the sole model prop, the Angular output is a `ControlValueAccessor` (`[formControl]` / `[(ngModel)]` bind directly).
 * @example
 * <Tags r-model:modelValue="skills" placeholder="Add a skill…" />
 */
const modelValue = defineModel<any[]>('modelValue', { default: () => [] });

const emit = defineEmits<{
  change: [...args: any[]];
  add: [...args: any[]];
  remove: [...args: any[]];
}>();

defineSlots<{
  tag(props: { tag: any; index: any; remove: any }): any;
}>();

const draft = ref('');

const rootRef = ref<HTMLElement>();

// ---- derived view (plain functions, uniform ×6) ------------------------
// The committed tokens, normalized to a string[].
const tokens = () => Array.isArray(modelValue.value) ? modelValue.value : [];

// The configured commit keys, normalized to a string[].
// The configured commit keys, normalized to a string[].
const commitKeys = () => Array.isArray(props.delimiters) ? props.delimiters : [',', 'Enter'];

// The non-Enter delimiters act as split characters for paste.
// The non-Enter delimiters act as split characters for paste.
const splitChars = () => commitKeys().filter((k: any) => k !== 'Enter');

// Whether the control has reached its token cap.
// Whether the control has reached its token cap.
const atMax = () => typeof props.max === 'number' && tokens().length >= props.max;

// Whether new input is accepted at all.
// Whether new input is accepted at all.
const canEdit = () => !props.disabled && !props.readonly;

// ---- write funnel (single $emit site) ----------------------------------
// Write the model and emit change. Every committed-list mutation funnels here.
// ---- write funnel (single $emit site) ----------------------------------
// Write the model and emit change. Every committed-list mutation funnels here.
const commitTokens = (next: any) => {
  modelValue.value = next;
  emit('change', {
    value: next
  });
};

// ---- add / remove ------------------------------------------------------
// Normalize → validate → dedup → cap a candidate, then commit + emit add.
// Returns true if it was added (so the caller can clear the draft).
// ---- add / remove ------------------------------------------------------
// Normalize → validate → dedup → cap a candidate, then commit + emit add.
// Returns true if it was added (so the caller can clear the draft).
const addToken = (raw: any) => {
  if (!canEdit()) return false;
  let candidate = String(raw == null ? '' : raw).trim();
  if (!candidate) return false;
  if (typeof props.validate === 'function') {
    const result = props.validate(candidate, tokens());
    if (!result) return false;
    candidate = String(result);
    if (!candidate) return false;
  }
  const cur = tokens();
  if (!props.allowDuplicates && cur.indexOf(candidate) !== -1) return false;
  if (typeof props.max === 'number' && cur.length >= props.max) return false;
  const next = cur.concat([candidate]);
  commitTokens(next);
  emit('add', {
    value: candidate,
    tokens: next
  });
  return true;
};

// Remove the token at `idx`, commit, and emit remove.
// Remove the token at `idx`, commit, and emit remove.
const removeAt = (idx: any) => {
  if (!canEdit()) return;
  const cur = tokens();
  if (idx < 0 || idx >= cur.length) return;
  const removed = cur[idx];
  const next = cur.slice(0, idx).concat(cur.slice(idx + 1));
  commitTokens(next);
  emit('remove', {
    value: removed,
    index: idx,
    tokens: next
  });
};

// ---- focus (container ref, post-mount only) ----------------------------
// Read $refs.root only here / in $onMount / in $expose verbs (post-mount →
// ROZ123-safe). querySelector reaches the input inside Lit's shadow root too.
// ---- focus (container ref, post-mount only) ----------------------------
// Read $refs.root only here / in $onMount / in $expose verbs (post-mount →
// ROZ123-safe). querySelector reaches the input inside Lit's shadow root too.
const focusTheInput = () => {
  const root = rootRef.value;
  if (!root) return;
  const el = root.querySelector('input');
  if (el) el.focus();
};

// ---- input handlers ----------------------------------------------------
// Mirror the typed text into the draft buffer. Capture the fresh local value
// (do NOT re-read $data.draft in the same handler — React setState is async and
// would read the pre-write value).
// ---- input handlers ----------------------------------------------------
// Mirror the typed text into the draft buffer. Capture the fresh local value
// (do NOT re-read $data.draft in the same handler — React setState is async and
// would read the pre-write value).
const onInput = (e: any) => {
  draft.value = e && e.target ? e.target.value : '';
};

// A delimiter key commits the current draft; Backspace in an empty input
// deletes the previous token.
// A delimiter key commits the current draft; Backspace in an empty input
// deletes the previous token.
const onKeydown = (e: any) => {
  if (!canEdit()) return;
  const key = e ? e.key : '';
  const value = e && e.target ? e.target.value : '';
  if (commitKeys().indexOf(key) !== -1) {
    if (e) e.preventDefault();
    if (addToken(value)) draft.value = '';
    return;
  }
  if (key === 'Backspace' && value === '') {
    const cur = tokens();
    if (cur.length > 0) {
      if (e) e.preventDefault();
      removeAt(cur.length - 1);
    }
  }
};

// Commit any leftover draft when the input loses focus (a common chips UX).
// Commit any leftover draft when the input loses focus (a common chips UX).
const onBlur = (e: any) => {
  if (!canEdit()) return;
  const value = e && e.target ? e.target.value : '';
  if (value && addToken(value)) draft.value = '';
};

// Paste: split on the configured delimiter characters and bulk-add.
// Paste: split on the configured delimiter characters and bulk-add.
const onPaste = (e: any) => {
  if (!canEdit()) return;
  const text = e && e.clipboardData && e.clipboardData.getData('text') || '';
  const seps = splitChars();
  let parts = [text];
  if (seps.length) {
    // Split on every separator char in turn.
    for (let s = 0; s < seps.length; s++) {
      const sep = seps[s];
      const out = [];
      for (let p = 0; p < parts.length; p++) {
        const pieces = String(parts[p]).split(sep);
        for (let q = 0; q < pieces.length; q++) out.push(pieces[q]);
      }
      parts = out;
    }
  }
  const trimmed = parts.map((p: any) => String(p).trim()).filter((p: any) => p.length > 0);
  if (trimmed.length <= 1 && seps.length === 0) return; // let the input handle a plain paste
  if (e) e.preventDefault();
  let addedAny = false;
  for (let i = 0; i < trimmed.length; i++) {
    if (addToken(trimmed[i])) addedAny = true;
  }
  if (addedAny) draft.value = '';
};

// ---- per-element attribute helpers -------------------------------------
// ---- per-element attribute helpers -------------------------------------
const removeLabel = (t: any) => 'Remove ' + String(t);
const countLabel = () => {
  const n = tokens().length;
  return n === 1 ? '1 tag' : n + ' tags';
};

// ---- lifecycle + imperative handle -------------------------------------
// clear() — remove every token (emits change with []) and focus the input.
// ---- lifecycle + imperative handle -------------------------------------
// clear() — remove every token (emits change with []) and focus the input.
const clear = () => {
  commitTokens([]);
  draft.value = '';
  focusTheInput();
};
// focus() — move DOM focus to the text input. DELIBERATELY overrides the
// inherited HTMLElement.focus on the Lit custom element (warn-only ROZ137,
// accepted — the public focus() handle is the intended semantics; otp/slider
// precedent, consistent with NumberField which also exposes `focus`).
// focus() — move DOM focus to the text input. DELIBERATELY overrides the
// inherited HTMLElement.focus on the Lit custom element (warn-only ROZ137,
// accepted — the public focus() handle is the intended semantics; otp/slider
// precedent, consistent with NumberField which also exposes `focus`).
const focus = () => focusTheInput();

defineExpose({ clear, focus });
</script>

<style scoped>
.rozie-tags {
  display: inline-flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--rozie-tags-gap, 0.4rem);
  padding: var(--rozie-tags-padding, 0.35rem 0.45rem);
  font: var(--rozie-tags-font, inherit);
  background: var(--rozie-tags-bg, #fff);
  border: var(--rozie-tags-border-width, 1px) solid var(--rozie-tags-border-color, rgba(0, 0, 0, 0.25));
  border-radius: var(--rozie-tags-radius, 0.5rem);
  min-width: var(--rozie-tags-min-width, 12rem);
}
.rozie-tags:focus-within {
  border-color: var(--rozie-tags-accent, #0066cc);
  box-shadow: 0 0 0 var(--rozie-tags-focus-ring-width, 3px) var(--rozie-tags-focus-ring-color, rgba(0, 102, 204, 0.25));
}
.rozie-tags-list {
  display: contents;
  margin: 0;
  padding: 0;
  list-style: none;
}
.rozie-tags-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--rozie-tags-chip-gap, 0.3rem);
  padding: var(--rozie-tags-chip-padding, 0.15rem 0.5rem);
  font-size: var(--rozie-tags-chip-font-size, 0.85rem);
  color: var(--rozie-tags-chip-color, inherit);
  background: var(--rozie-tags-chip-bg, rgba(0, 102, 204, 0.12));
  border-radius: var(--rozie-tags-chip-radius, 0.375rem);
  white-space: nowrap;
}
.rozie-tags-chip__remove {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--rozie-tags-remove-size, 1.1rem);
  height: var(--rozie-tags-remove-size, 1.1rem);
  padding: 0;
  font: inherit;
  line-height: 1;
  color: var(--rozie-tags-remove-color, currentColor);
  background: transparent;
  border: none;
  border-radius: 50%;
  cursor: pointer;
  opacity: var(--rozie-tags-remove-opacity, 0.65);
  transition: opacity 0.15s, background 0.15s;
}
.rozie-tags-chip__remove:hover:not(:disabled) {
  opacity: 1;
  background: var(--rozie-tags-remove-hover-bg, rgba(0, 0, 0, 0.1));
}
.rozie-tags-chip__remove:disabled {
  cursor: not-allowed;
  opacity: 0.4;
}
.rozie-tags-input {
  flex: 1 1 var(--rozie-tags-input-min, 4rem);
  min-width: var(--rozie-tags-input-min, 4rem);
  padding: var(--rozie-tags-input-padding, 0.15rem 0.1rem);
  font: inherit;
  color: var(--rozie-tags-color, inherit);
  background: transparent;
  border: none;
  outline: none;
}
.rozie-tags-input::placeholder {
  color: var(--rozie-tags-placeholder-color, rgba(0, 0, 0, 0.4));
}
.rozie-tags-input:disabled {
  cursor: not-allowed;
}
.rozie-tags-count {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.rozie-tags--disabled {
  cursor: not-allowed;
  opacity: var(--rozie-tags-disabled-opacity, 0.6);
  background: var(--rozie-tags-disabled-bg, rgba(0, 0, 0, 0.04));
}
</style>
svelte
<script lang="ts">
import { applyListeners, rozieAttr, rozieDisplay } from '@rozie/runtime-svelte';

import type { Snippet } from 'svelte';

interface Props {
  /**
   * The committed tokens — `model: true`, so a commit/remove/paste writes a **fresh** array back through `r-model:modelValue` (uncontrolled fallback `[]`). Because it is the sole model prop, the Angular output is a `ControlValueAccessor` (`[formControl]` / `[(ngModel)]` bind directly).
   * @example
   * <Tags r-model:modelValue="skills" placeholder="Add a skill…" />
   */
  modelValue?: any[];
  /**
   * The keys that commit the current draft as a token (matched against the key event's `key`). Default `[',', 'Enter']`. Non-`'Enter'` entries also act as the split characters when pasting bulk text. Use e.g. `[' ', 'Enter']` for a space-delimited input.
   */
  delimiters?: any[];
  /**
   * Allow the same token value to be added more than once. Defaults to `false` — a candidate equal (case-sensitive) to an existing token is silently rejected on commit. Set `true` to permit duplicates.
   */
  allowDuplicates?: boolean;
  /**
   * Maximum number of tokens. Once the list reaches `max`, the input is disabled and further adds (type, paste, programmatic) are rejected. `null` (the default) means unlimited.
   */
  max?: (number) | null;
  /**
   * Disable the whole control — the text input is disabled, every remove button is disabled, and no token can be added or removed. Also sets the Angular CVA disabled state.
   */
  disabled?: boolean;
  /**
   * Render the tokens read-only — they remain visible but cannot be added or removed, and the text input is hidden. Unlike `disabled` it carries no disabled styling, so it reads as a display of committed values.
   */
  readonly?: boolean;
  /**
   * Optional per-token validator / normalizer. Called with `(candidate, tokens)` for each commit; return a (possibly normalized) **string** to accept it, or a falsy value (`false` / `null` / `""`) to reject the candidate. Runs before the dedup + `max` checks. Example: `v => /^\S+@\S+$/.test(v) ? v.toLowerCase() : false` for emails.
   * @example
   * validate: (v) => (v.length >= 2 ? v.trim() : false)
   */
  validate?: ((...args: any[]) => any) | null;
  /**
   * Placeholder text for the inline text input (e.g. `"Add a tag…"`).
   */
  placeholder?: string;
  /**
   * Accessible name for the whole control (`role="group"`). The inline text input is labelled with the same name so assistive tech announces what is being entered. A visually-hidden live region announces the current token count on change.
   */
  ariaLabel?: (string) | null;
  tag?: Snippet<[{ tag: any; index: any; remove: any }]>;
  snippets?: Record<string, any>;
  onchange?: (...args: unknown[]) => void;
  onadd?: (...args: unknown[]) => void;
  onremove?: (...args: unknown[]) => void;
  [key: string]: unknown;
}

let __defaultDelimiters = (() => [',', 'Enter'])();

let {
  modelValue = $bindable((() => [])()),
  delimiters = __defaultDelimiters,
  allowDuplicates = false,
  max = null,
  disabled = false,
  readonly = false,
  validate = null,
  placeholder = '',
  ariaLabel = null,
  tag: __tagProp,
  snippets,
  onchange,
  onadd,
  onremove,
  ...__rozieAttrs
}: Props = $props();

const tag = $derived(__tagProp ?? snippets?.tag);

let draft = $state('');

let root = $state<HTMLElement | undefined>(undefined);

// ---- derived view (plain functions, uniform ×6) ------------------------
// The committed tokens, normalized to a string[].
const tokens = () => Array.isArray(modelValue) ? modelValue : [];

// The configured commit keys, normalized to a string[].
// The configured commit keys, normalized to a string[].
const commitKeys = () => Array.isArray(delimiters) ? delimiters : [',', 'Enter'];

// The non-Enter delimiters act as split characters for paste.
// The non-Enter delimiters act as split characters for paste.
const splitChars = () => commitKeys().filter((k: any) => k !== 'Enter');

// Whether the control has reached its token cap.
// Whether the control has reached its token cap.
const atMax = () => typeof max === 'number' && tokens().length >= max;

// Whether new input is accepted at all.
// Whether new input is accepted at all.
const canEdit = () => !disabled && !readonly;

// ---- write funnel (single $emit site) ----------------------------------
// Write the model and emit change. Every committed-list mutation funnels here.
// ---- write funnel (single $emit site) ----------------------------------
// Write the model and emit change. Every committed-list mutation funnels here.
const commitTokens = (next: any) => {
  modelValue = next;
  onchange?.({
    value: next
  });
};

// ---- add / remove ------------------------------------------------------
// Normalize → validate → dedup → cap a candidate, then commit + emit add.
// Returns true if it was added (so the caller can clear the draft).
// ---- add / remove ------------------------------------------------------
// Normalize → validate → dedup → cap a candidate, then commit + emit add.
// Returns true if it was added (so the caller can clear the draft).
const addToken = (raw: any) => {
  if (!canEdit()) return false;
  let candidate = String(raw == null ? '' : raw).trim();
  if (!candidate) return false;
  if (typeof validate === 'function') {
    const result = validate(candidate, tokens());
    if (!result) return false;
    candidate = String(result);
    if (!candidate) return false;
  }
  const cur = tokens();
  if (!allowDuplicates && cur.indexOf(candidate) !== -1) return false;
  if (typeof max === 'number' && cur.length >= max) return false;
  const next = cur.concat([candidate]);
  commitTokens(next);
  onadd?.({
    value: candidate,
    tokens: next
  });
  return true;
};

// Remove the token at `idx`, commit, and emit remove.
// Remove the token at `idx`, commit, and emit remove.
const removeAt = (idx: any) => {
  if (!canEdit()) return;
  const cur = tokens();
  if (idx < 0 || idx >= cur.length) return;
  const removed = cur[idx];
  const next = cur.slice(0, idx).concat(cur.slice(idx + 1));
  commitTokens(next);
  onremove?.({
    value: removed,
    index: idx,
    tokens: next
  });
};

// ---- focus (container ref, post-mount only) ----------------------------
// Read $refs.root only here / in $onMount / in $expose verbs (post-mount →
// ROZ123-safe). querySelector reaches the input inside Lit's shadow root too.
// ---- focus (container ref, post-mount only) ----------------------------
// Read $refs.root only here / in $onMount / in $expose verbs (post-mount →
// ROZ123-safe). querySelector reaches the input inside Lit's shadow root too.
const focusTheInput = () => {
  const root$local = root;
  if (!root$local) return;
  const el = root$local.querySelector('input');
  if (el) el.focus();
};

// ---- input handlers ----------------------------------------------------
// Mirror the typed text into the draft buffer. Capture the fresh local value
// (do NOT re-read $data.draft in the same handler — React setState is async and
// would read the pre-write value).
// ---- input handlers ----------------------------------------------------
// Mirror the typed text into the draft buffer. Capture the fresh local value
// (do NOT re-read $data.draft in the same handler — React setState is async and
// would read the pre-write value).
const onInput = (e: any) => {
  draft = e && e.target ? e.target.value : '';
};

// A delimiter key commits the current draft; Backspace in an empty input
// deletes the previous token.
// A delimiter key commits the current draft; Backspace in an empty input
// deletes the previous token.
const onKeydown = (e: any) => {
  if (!canEdit()) return;
  const key = e ? e.key : '';
  const value = e && e.target ? e.target.value : '';
  if (commitKeys().indexOf(key) !== -1) {
    if (e) e.preventDefault();
    if (addToken(value)) draft = '';
    return;
  }
  if (key === 'Backspace' && value === '') {
    const cur = tokens();
    if (cur.length > 0) {
      if (e) e.preventDefault();
      removeAt(cur.length - 1);
    }
  }
};

// Commit any leftover draft when the input loses focus (a common chips UX).
// Commit any leftover draft when the input loses focus (a common chips UX).
const onBlur = (e: any) => {
  if (!canEdit()) return;
  const value = e && e.target ? e.target.value : '';
  if (value && addToken(value)) draft = '';
};

// Paste: split on the configured delimiter characters and bulk-add.
// Paste: split on the configured delimiter characters and bulk-add.
const onPaste = (e: any) => {
  if (!canEdit()) return;
  const text = e && e.clipboardData && e.clipboardData.getData('text') || '';
  const seps = splitChars();
  let parts = [text];
  if (seps.length) {
    // Split on every separator char in turn.
    for (let s = 0; s < seps.length; s++) {
      const sep = seps[s];
      const out = [];
      for (let p = 0; p < parts.length; p++) {
        const pieces = String(parts[p]).split(sep);
        for (let q = 0; q < pieces.length; q++) out.push(pieces[q]);
      }
      parts = out;
    }
  }
  const trimmed = parts.map((p: any) => String(p).trim()).filter((p: any) => p.length > 0);
  if (trimmed.length <= 1 && seps.length === 0) return; // let the input handle a plain paste
  if (e) e.preventDefault();
  let addedAny = false;
  for (let i = 0; i < trimmed.length; i++) {
    if (addToken(trimmed[i])) addedAny = true;
  }
  if (addedAny) draft = '';
};

// ---- per-element attribute helpers -------------------------------------
// ---- per-element attribute helpers -------------------------------------
const removeLabel = (t: any) => 'Remove ' + String(t);
const countLabel = () => {
  const n = tokens().length;
  return n === 1 ? '1 tag' : n + ' tags';
};

// ---- lifecycle + imperative handle -------------------------------------
// clear() — remove every token (emits change with []) and focus the input.
// ---- lifecycle + imperative handle -------------------------------------
// clear() — remove every token (emits change with []) and focus the input.
export const clear = () => {
  commitTokens([]);
  draft = '';
  focusTheInput();
};
// focus() — move DOM focus to the text input. DELIBERATELY overrides the
// inherited HTMLElement.focus on the Lit custom element (warn-only ROZ137,
// accepted — the public focus() handle is the intended semantics; otp/slider
// precedent, consistent with NumberField which also exposes `focus`).
// focus() — move DOM focus to the text input. DELIBERATELY overrides the
// inherited HTMLElement.focus on the Lit custom element (warn-only ROZ137,
// accepted — the public focus() handle is the intended semantics; otp/slider
// precedent, consistent with NumberField which also exposes `focus`).
export const focus = () => focusTheInput();
</script>

<div bind:this={root} role="group" aria-label={ariaLabel} {...__rozieAttrs} class={["rozie-tags", { 'rozie-tags--disabled': disabled, 'rozie-tags--readonly': readonly }, (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-64848f8e><ul class="rozie-tags-list" data-rozie-s-64848f8e>{#each tokens() as t (t + ':' + tokens().indexOf(t))}<li class="rozie-tags-chip" data-rozie-s-64848f8e>{#if tag}{@render tag({ tag: t, index: tokens().indexOf(t), remove: () => removeAt(tokens().indexOf(t)) })}{:else}<span class="rozie-tags-chip__label" data-rozie-s-64848f8e>{rozieDisplay(t)}</span>{#if !readonly}<button type="button" class="rozie-tags-chip__remove" disabled={!!disabled} aria-label={rozieAttr(removeLabel(t))} onclick={($event) => { removeAt(tokens().indexOf(t)); }} data-rozie-s-64848f8e>×</button>{/if}{/if}</li>{/each}</ul>{#if !readonly}<input class="rozie-tags-input" type="text" autocomplete="off" autocapitalize="off" value={draft} placeholder={placeholder} disabled={!!disabled || !!atMax()} aria-label={ariaLabel} aria-disabled={!!disabled} oninput={($event) => { onInput($event); }} onkeydown={($event) => { onKeydown($event); }} onpaste={($event) => { onPaste($event); }} onblur={($event) => { onBlur($event); }} data-rozie-s-64848f8e />{/if}<span class="rozie-tags-count" aria-live="polite" data-rozie-s-64848f8e>{rozieDisplay(countLabel())}</span></div>

<style>
:global {
  .rozie-tags[data-rozie-s-64848f8e] {
    display: inline-flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--rozie-tags-gap, 0.4rem);
    padding: var(--rozie-tags-padding, 0.35rem 0.45rem);
    font: var(--rozie-tags-font, inherit);
    background: var(--rozie-tags-bg, #fff);
    border: var(--rozie-tags-border-width, 1px) solid var(--rozie-tags-border-color, rgba(0, 0, 0, 0.25));
    border-radius: var(--rozie-tags-radius, 0.5rem);
    min-width: var(--rozie-tags-min-width, 12rem);
  }
  .rozie-tags[data-rozie-s-64848f8e]:focus-within {
    border-color: var(--rozie-tags-accent, #0066cc);
    box-shadow: 0 0 0 var(--rozie-tags-focus-ring-width, 3px) var(--rozie-tags-focus-ring-color, rgba(0, 102, 204, 0.25));
  }
  .rozie-tags-list[data-rozie-s-64848f8e] {
    display: contents;
    margin: 0;
    padding: 0;
    list-style: none;
  }
  .rozie-tags-chip[data-rozie-s-64848f8e] {
    display: inline-flex;
    align-items: center;
    gap: var(--rozie-tags-chip-gap, 0.3rem);
    padding: var(--rozie-tags-chip-padding, 0.15rem 0.5rem);
    font-size: var(--rozie-tags-chip-font-size, 0.85rem);
    color: var(--rozie-tags-chip-color, inherit);
    background: var(--rozie-tags-chip-bg, rgba(0, 102, 204, 0.12));
    border-radius: var(--rozie-tags-chip-radius, 0.375rem);
    white-space: nowrap;
  }
  .rozie-tags-chip__remove[data-rozie-s-64848f8e] {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: var(--rozie-tags-remove-size, 1.1rem);
    height: var(--rozie-tags-remove-size, 1.1rem);
    padding: 0;
    font: inherit;
    line-height: 1;
    color: var(--rozie-tags-remove-color, currentColor);
    background: transparent;
    border: none;
    border-radius: 50%;
    cursor: pointer;
    opacity: var(--rozie-tags-remove-opacity, 0.65);
    transition: opacity 0.15s, background 0.15s;
  }
  .rozie-tags-chip__remove[data-rozie-s-64848f8e]:hover:not([data-rozie-s-64848f8e]:disabled) {
    opacity: 1;
    background: var(--rozie-tags-remove-hover-bg, rgba(0, 0, 0, 0.1));
  }
  .rozie-tags-chip__remove[data-rozie-s-64848f8e]:disabled {
    cursor: not-allowed;
    opacity: 0.4;
  }
  .rozie-tags-input[data-rozie-s-64848f8e] {
    flex: 1 1 var(--rozie-tags-input-min, 4rem);
    min-width: var(--rozie-tags-input-min, 4rem);
    padding: var(--rozie-tags-input-padding, 0.15rem 0.1rem);
    font: inherit;
    color: var(--rozie-tags-color, inherit);
    background: transparent;
    border: none;
    outline: none;
  }
  .rozie-tags-input[data-rozie-s-64848f8e]::placeholder {
    color: var(--rozie-tags-placeholder-color, rgba(0, 0, 0, 0.4));
  }
  .rozie-tags-input[data-rozie-s-64848f8e]:disabled {
    cursor: not-allowed;
  }
  .rozie-tags-count[data-rozie-s-64848f8e] {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  }
  .rozie-tags--disabled[data-rozie-s-64848f8e] {
    cursor: not-allowed;
    opacity: var(--rozie-tags-disabled-opacity, 0.6);
    background: var(--rozie-tags-disabled-bg, rgba(0, 0, 0, 0.04));
  }
}
</style>
ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, effect, forwardRef, inject, input, model, output, signal, viewChild } from '@angular/core';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import { NG_VALUE_ACCESSOR } from '@angular/forms';

interface TagCtx {
  $implicit: { tag: any; index: any; remove: any };
  tag: any;
  index: any;
  remove: any;
}

function __rozieDisplay(v: unknown): string {
  if (v == null) return '';
  if (typeof v === 'string') return v;
  if (typeof v === 'object') {
    try {
      return JSON.stringify(v, null, 2);
    } catch {
      // Circular structure or a non-serialisable value (BigInt nested in an
      // object). Degrade to a non-throwing form so the wrap never crashes the
      // render — that is the entire point of "safe" interpolation (SPEC-1).
      return String(v);
    }
  }
  return String(v);
}

function __rozieAttr(v: unknown): string | null {
  return v == null ? null : __rozieDisplay(v);
}

@Component({
  selector: 'rozie-tags',
  standalone: true,
  imports: [NgTemplateOutlet, NgClass],
  template: `

    <div class="rozie-tags" [ngClass]="{ 'rozie-tags--disabled': (disabled() || this.__rozieCvaDisabled()), 'rozie-tags--readonly': readonly() }" #root role="group" [attr.aria-label]="ariaLabel()" #rozieSpread_0 #rozieListenersTarget_1>
      <ul class="rozie-tags-list">
        @for (t of tokens(); track t + ':' + tokens().indexOf(t)) {
    <li class="rozie-tags-chip">
          @if ((tagTpl ?? templates()?.['tag'])) {
    <ng-container *ngTemplateOutlet="(tagTpl ?? templates()?.['tag']); context: _tag_ctx_2(t)" />
    } @else {

            <span class="rozie-tags-chip__label">{{ rozieDisplay(t) }}</span>
            @if (!readonly()) {
    <button type="button" class="rozie-tags-chip__remove" [disabled]="!!(disabled() || this.__rozieCvaDisabled())" [attr.aria-label]="rozieAttr(removeLabel(t))" (click)="removeAt(tokens().indexOf(t))">×</button>
    }
    }
        </li>
    }
      </ul>

      @if (!readonly()) {
    <input class="rozie-tags-input" type="text" autocomplete="off" autocapitalize="off" [value]="draft()" [placeholder]="placeholder()" [disabled]="!!(disabled() || this.__rozieCvaDisabled()) || !!atMax()" [attr.aria-label]="ariaLabel()" [attr.aria-disabled]="!!(disabled() || this.__rozieCvaDisabled())" (input)="onInput($event)" (keydown)="onKeydown($event)" (paste)="onPaste($event)" (blur)="onBlur($event)" />
    }<span class="rozie-tags-count" aria-live="polite">{{ rozieDisplay(countLabel()) }}</span>
    </div>

  `,
  styles: [`
    .rozie-tags {
      display: inline-flex;
      flex-wrap: wrap;
      align-items: center;
      gap: var(--rozie-tags-gap, 0.4rem);
      padding: var(--rozie-tags-padding, 0.35rem 0.45rem);
      font: var(--rozie-tags-font, inherit);
      background: var(--rozie-tags-bg, #fff);
      border: var(--rozie-tags-border-width, 1px) solid var(--rozie-tags-border-color, rgba(0, 0, 0, 0.25));
      border-radius: var(--rozie-tags-radius, 0.5rem);
      min-width: var(--rozie-tags-min-width, 12rem);
    }
    .rozie-tags:focus-within {
      border-color: var(--rozie-tags-accent, #0066cc);
      box-shadow: 0 0 0 var(--rozie-tags-focus-ring-width, 3px) var(--rozie-tags-focus-ring-color, rgba(0, 102, 204, 0.25));
    }
    .rozie-tags-list {
      display: contents;
      margin: 0;
      padding: 0;
      list-style: none;
    }
    .rozie-tags-chip {
      display: inline-flex;
      align-items: center;
      gap: var(--rozie-tags-chip-gap, 0.3rem);
      padding: var(--rozie-tags-chip-padding, 0.15rem 0.5rem);
      font-size: var(--rozie-tags-chip-font-size, 0.85rem);
      color: var(--rozie-tags-chip-color, inherit);
      background: var(--rozie-tags-chip-bg, rgba(0, 102, 204, 0.12));
      border-radius: var(--rozie-tags-chip-radius, 0.375rem);
      white-space: nowrap;
    }
    .rozie-tags-chip__remove {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: var(--rozie-tags-remove-size, 1.1rem);
      height: var(--rozie-tags-remove-size, 1.1rem);
      padding: 0;
      font: inherit;
      line-height: 1;
      color: var(--rozie-tags-remove-color, currentColor);
      background: transparent;
      border: none;
      border-radius: 50%;
      cursor: pointer;
      opacity: var(--rozie-tags-remove-opacity, 0.65);
      transition: opacity 0.15s, background 0.15s;
    }
    .rozie-tags-chip__remove:hover:not(:disabled) {
      opacity: 1;
      background: var(--rozie-tags-remove-hover-bg, rgba(0, 0, 0, 0.1));
    }
    .rozie-tags-chip__remove:disabled {
      cursor: not-allowed;
      opacity: 0.4;
    }
    .rozie-tags-input {
      flex: 1 1 var(--rozie-tags-input-min, 4rem);
      min-width: var(--rozie-tags-input-min, 4rem);
      padding: var(--rozie-tags-input-padding, 0.15rem 0.1rem);
      font: inherit;
      color: var(--rozie-tags-color, inherit);
      background: transparent;
      border: none;
      outline: none;
    }
    .rozie-tags-input::placeholder {
      color: var(--rozie-tags-placeholder-color, rgba(0, 0, 0, 0.4));
    }
    .rozie-tags-input:disabled {
      cursor: not-allowed;
    }
    .rozie-tags-count {
      position: absolute;
      width: 1px;
      height: 1px;
      padding: 0;
      margin: -1px;
      overflow: hidden;
      clip: rect(0, 0, 0, 0);
      white-space: nowrap;
      border: 0;
    }
    .rozie-tags--disabled {
      cursor: not-allowed;
      opacity: var(--rozie-tags-disabled-opacity, 0.6);
      background: var(--rozie-tags-disabled-bg, rgba(0, 0, 0, 0.04));
    }
  `],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => Tags),
      multi: true,
    },
  ],
  host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class Tags {
  /**
   * The committed tokens — `model: true`, so a commit/remove/paste writes a **fresh** array back through `r-model:modelValue` (uncontrolled fallback `[]`). Because it is the sole model prop, the Angular output is a `ControlValueAccessor` (`[formControl]` / `[(ngModel)]` bind directly).
   * @example
   * <Tags r-model:modelValue="skills" placeholder="Add a skill…" />
   */
  modelValue = model<any[]>((() => [])());
  /**
   * The keys that commit the current draft as a token (matched against the key event's `key`). Default `[',', 'Enter']`. Non-`'Enter'` entries also act as the split characters when pasting bulk text. Use e.g. `[' ', 'Enter']` for a space-delimited input.
   */
  delimiters = input<any[]>((() => [',', 'Enter'])());
  /**
   * Allow the same token value to be added more than once. Defaults to `false` — a candidate equal (case-sensitive) to an existing token is silently rejected on commit. Set `true` to permit duplicates.
   */
  allowDuplicates = input<boolean>(false);
  /**
   * Maximum number of tokens. Once the list reaches `max`, the input is disabled and further adds (type, paste, programmatic) are rejected. `null` (the default) means unlimited.
   */
  max = input<(number) | null>(null);
  /**
   * Disable the whole control — the text input is disabled, every remove button is disabled, and no token can be added or removed. Also sets the Angular CVA disabled state.
   */
  disabled = input<boolean>(false);
  /**
   * Render the tokens read-only — they remain visible but cannot be added or removed, and the text input is hidden. Unlike `disabled` it carries no disabled styling, so it reads as a display of committed values.
   */
  readonly = input<boolean>(false);
  /**
   * Optional per-token validator / normalizer. Called with `(candidate, tokens)` for each commit; return a (possibly normalized) **string** to accept it, or a falsy value (`false` / `null` / `""`) to reject the candidate. Runs before the dedup + `max` checks. Example: `v => /^\S+@\S+$/.test(v) ? v.toLowerCase() : false` for emails.
   * @example
   * validate: (v) => (v.length >= 2 ? v.trim() : false)
   */
  validate = input<((...args: unknown[]) => unknown) | null>(null);
  /**
   * Placeholder text for the inline text input (e.g. `"Add a tag…"`).
   */
  placeholder = input<string>('');
  /**
   * Accessible name for the whole control (`role="group"`). The inline text input is labelled with the same name so assistive tech announces what is being entered. A visually-hidden live region announces the current token count on change.
   */
  ariaLabel = input<(string) | null>(null);
  draft = signal('');
  root = viewChild<ElementRef<HTMLDivElement>>('root');
  change = output<unknown>();
  add = output<unknown>();
  remove = output<unknown>();
  @ContentChild('tag', { read: TemplateRef }) tagTpl?: TemplateRef<TagCtx>;
  templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);

  tokens = () => Array.isArray(this.modelValue()) ? this.modelValue() : [];
  commitKeys = () => Array.isArray(this.delimiters()) ? this.delimiters() : [',', 'Enter'];
  splitChars = () => this.commitKeys().filter((k: any) => k !== 'Enter');
  atMax = () => typeof this.max() === 'number' && this.tokens().length >= this.max();
  canEdit = () => !(this.disabled() || this.__rozieCvaDisabled()) && !this.readonly();
  commitTokens = (next: any) => {
    this.modelValue.set(next), this.__rozieCvaOnChange(next);
    this.change.emit({
      value: next
    });
  };
  addToken = (raw: any) => {
    const __validate = this.validate();
    const __max = this.max();
    if (!this.canEdit()) return false;
    let candidate = String(raw == null ? '' : raw).trim();
    if (!candidate) return false;
    if (typeof __validate === 'function') {
      const result = __validate(candidate, this.tokens());
      if (!result) return false;
      candidate = String(result);
      if (!candidate) return false;
    }
    const cur = this.tokens();
    if (!this.allowDuplicates() && cur.indexOf(candidate) !== -1) return false;
    if (typeof __max === 'number' && cur.length >= __max) return false;
    const next = cur.concat([candidate]);
    this.commitTokens(next);
    this.add.emit({
      value: candidate,
      tokens: next
    });
    return true;
  };
  removeAt = (idx: any) => {
    if (!this.canEdit()) return;
    const cur = this.tokens();
    if (idx < 0 || idx >= cur.length) return;
    const removed = cur[idx];
    const next = cur.slice(0, idx).concat(cur.slice(idx + 1));
    this.commitTokens(next);
    this.remove.emit({
      value: removed,
      index: idx,
      tokens: next
    });
  };
  focusTheInput = () => {
    const root = this.root()?.nativeElement;
    if (!root) return;
    const el = root.querySelector('input');
    if (el) el.focus();
  };
  onInput = (e: any) => {
    this.draft.set(e && e.target ? e.target.value : '');
  };
  onKeydown = (e: any) => {
    if (!this.canEdit()) return;
    const key = e ? e.key : '';
    const value = e && e.target ? e.target.value : '';
    if (this.commitKeys().indexOf(key) !== -1) {
      if (e) e.preventDefault();
      if (this.addToken(value)) this.draft.set('');
      return;
    }
    if (key === 'Backspace' && value === '') {
      const cur = this.tokens();
      if (cur.length > 0) {
        if (e) e.preventDefault();
        this.removeAt(cur.length - 1);
      }
    }
  };
  onBlur = (e: any) => {
    if (!this.canEdit()) return;
    const value = e && e.target ? e.target.value : '';
    if (value && this.addToken(value)) this.draft.set('');
  };
  onPaste = (e: any) => {
    if (!this.canEdit()) return;
    const text = e && e.clipboardData && e.clipboardData.getData('text') || '';
    const seps = this.splitChars();
    let parts = [text];
    if (seps.length) {
      // Split on every separator char in turn.
      for (let s = 0; s < seps.length; s++) {
        const sep = seps[s];
        const out = [];
        for (let p = 0; p < parts.length; p++) {
          const pieces = String(parts[p]).split(sep);
          for (let q = 0; q < pieces.length; q++) out.push(pieces[q]);
        }
        parts = out;
      }
    }
    const trimmed = parts.map((p: any) => String(p).trim()).filter((p: any) => p.length > 0);
    if (trimmed.length <= 1 && seps.length === 0) return; // let the input handle a plain paste
    if (e) e.preventDefault();
    let addedAny = false;
    for (let i = 0; i < trimmed.length; i++) {
      if (this.addToken(trimmed[i])) addedAny = true;
    }
    if (addedAny) this.draft.set('');
  };
  removeLabel = (t: any) => 'Remove ' + String(t);
  countLabel = () => {
    const n = this.tokens().length;
    return n === 1 ? '1 tag' : n + ' tags';
  };
  clear = () => {
    this.commitTokens([]);
    this.draft.set('');
    this.focusTheInput();
  };
  focus = () => this.focusTheInput();

  private __rozieCvaOnChange: (v: any[]) => void = () => {};
  private __rozieCvaOnTouchedFn: () => void = () => {};
  protected __rozieCvaDisabled = signal(false);

  writeValue(v: any[] | null): void {
    this.modelValue.set(v ?? (() => [])());
  }
  registerOnChange(fn: (v: any[]) => void): void {
    this.__rozieCvaOnChange = fn;
  }
  registerOnTouched(fn: () => void): void {
    this.__rozieCvaOnTouchedFn = fn;
  }
  setDisabledState(isDisabled: boolean): void {
    this.__rozieCvaDisabled.set(isDisabled);
  }
  __rozieCvaOnTouched(): void {
    this.__rozieCvaOnTouchedFn();
  }

  static ngTemplateContextGuard(
    _dir: Tags,
    _ctx: unknown,
  ): _ctx is TagCtx {
    return true;
  }

  private __rozieDestroyRef = inject(DestroyRef);

  private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');

  private __rozieApplyAttrs = (() => {
    const renderer = inject(Renderer2);
    const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
    const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
    const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
    const parseClassTokens = (value: unknown): string[] => {
      if (typeof value !== 'string') return [];
      const out: string[] = [];
      for (const tok of value.split(/\s+/)) {
        if (tok.length > 0) out.push(tok);
      }
      return out;
    };
    const parseStyleDecls = (value: unknown): Array<[string, string]> => {
      if (typeof value !== 'string') return [];
      const out: Array<[string, string]> = [];
      for (const decl of value.split(';')) {
        const colon = decl.indexOf(':');
        if (colon < 0) continue;
        const prop = decl.slice(0, colon).trim();
        const val = decl.slice(colon + 1).trim();
        if (prop.length > 0) out.push([prop, val]);
      }
      return out;
    };
    const applyClassMerge = (el: HTMLElement, value: unknown) => {
      const next = parseClassTokens(value);
      const prev = prevClassTokensByElement.get(el) ?? [];
      const nextSet = new Set(next);
      for (const tok of prev) {
        if (!nextSet.has(tok)) el.classList.remove(tok);
      }
      for (const tok of next) el.classList.add(tok);
      prevClassTokensByElement.set(el, next);
    };
    const applyStyleMerge = (el: HTMLElement, value: unknown) => {
      const next = parseStyleDecls(value);
      const prev = prevStylePropsByElement.get(el) ?? [];
      const nextProps = next.map(([p]) => p);
      const nextSet = new Set(nextProps);
      for (const prop of prev) {
        if (!nextSet.has(prop)) el.style.removeProperty(prop);
      }
      for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
      prevStylePropsByElement.set(el, nextProps);
    };
    return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
      const safeObj: Record<string, unknown> = obj ?? {};
      const prevKeys = prevKeysByElement.get(el) ?? [];
      for (const k of prevKeys) {
        if (k === 'class' || k === 'style') continue;
        if (!(k in safeObj)) renderer.removeAttribute(el, k);
      }
      if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
        applyClassMerge(el, '');
      }
      if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
        applyStyleMerge(el, '');
      }
      for (const [k, v] of Object.entries(safeObj)) {
        if (k === 'class') {
          applyClassMerge(el, v);
        } else if (k === 'style') {
          applyStyleMerge(el, v);
        } else if (v === null || v === false) {
          renderer.removeAttribute(el, k);
        } else {
          renderer.setAttribute(el, k, String(v));
        }
      }
      prevKeysByElement.set(el, Object.keys(safeObj));
    };
  })();

  private __rozieGetHostAttrs = (() => {
    const host = inject(ElementRef);
    return () => {
      const el = host.nativeElement as HTMLElement;
      const out: Record<string, unknown> = {};
      for (const a of Array.from(el.attributes)) out[a.name] = a.value;
      return out;
    };
  })();

  private __rozieSpread_0_effect = afterRenderEffect(() => {
    const el = this.rozieSpread_0()?.nativeElement;
    if (!el) return;
    this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
  });

  private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');

  private __rozieListenersRenderer = inject(Renderer2);

  private __rozieListenersDisposers_1: Array<() => void> = [];

  private __rozieListenersDestroyRegistered_1 = false;

  private __rozieListenersEffect_1 = effect(() => {
    const el = this.rozieListenersTarget_1()?.nativeElement;
    if (!el) return;
    for (const off of this.__rozieListenersDisposers_1) off();
    this.__rozieListenersDisposers_1 = [];
    const obj: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(obj)) {
      if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
      if (typeof v !== 'function') continue;
      const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
      const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
      this.__rozieListenersDisposers_1.push(dispose);
    }
    if (!this.__rozieListenersDestroyRegistered_1) {
      this.__rozieListenersDestroyRegistered_1 = true;
      this.__rozieDestroyRef.onDestroy(() => {
        for (const off of this.__rozieListenersDisposers_1) off();
        this.__rozieListenersDisposers_1 = [];
      });
    }
  });

  private _tag_ctx_2 = (t: any) => ({ $implicit: { tag: t, index: this.tokens().indexOf(t), remove: () => this.removeAt(this.tokens().indexOf(t)) }, tag: t, index: this.tokens().indexOf(t), remove: () => this.removeAt(this.tokens().indexOf(t)) });

  rozieDisplay(v: unknown): string { return __rozieDisplay(v); }

  rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}

export default Tags;
tsx
import type { JSX } from 'solid-js';
import { For, Show, createSignal, mergeProps, onMount, splitProps } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal, rozieAttr, rozieClass, rozieDisplay } from '@rozie/runtime-solid';

__rozieInjectStyle('Tags-64848f8e', `.rozie-tags[data-rozie-s-64848f8e] {
  display: inline-flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--rozie-tags-gap, 0.4rem);
  padding: var(--rozie-tags-padding, 0.35rem 0.45rem);
  font: var(--rozie-tags-font, inherit);
  background: var(--rozie-tags-bg, #fff);
  border: var(--rozie-tags-border-width, 1px) solid var(--rozie-tags-border-color, rgba(0, 0, 0, 0.25));
  border-radius: var(--rozie-tags-radius, 0.5rem);
  min-width: var(--rozie-tags-min-width, 12rem);
}
.rozie-tags[data-rozie-s-64848f8e]:focus-within {
  border-color: var(--rozie-tags-accent, #0066cc);
  box-shadow: 0 0 0 var(--rozie-tags-focus-ring-width, 3px) var(--rozie-tags-focus-ring-color, rgba(0, 102, 204, 0.25));
}
.rozie-tags-list[data-rozie-s-64848f8e] {
  display: contents;
  margin: 0;
  padding: 0;
  list-style: none;
}
.rozie-tags-chip[data-rozie-s-64848f8e] {
  display: inline-flex;
  align-items: center;
  gap: var(--rozie-tags-chip-gap, 0.3rem);
  padding: var(--rozie-tags-chip-padding, 0.15rem 0.5rem);
  font-size: var(--rozie-tags-chip-font-size, 0.85rem);
  color: var(--rozie-tags-chip-color, inherit);
  background: var(--rozie-tags-chip-bg, rgba(0, 102, 204, 0.12));
  border-radius: var(--rozie-tags-chip-radius, 0.375rem);
  white-space: nowrap;
}
.rozie-tags-chip__remove[data-rozie-s-64848f8e] {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--rozie-tags-remove-size, 1.1rem);
  height: var(--rozie-tags-remove-size, 1.1rem);
  padding: 0;
  font: inherit;
  line-height: 1;
  color: var(--rozie-tags-remove-color, currentColor);
  background: transparent;
  border: none;
  border-radius: 50%;
  cursor: pointer;
  opacity: var(--rozie-tags-remove-opacity, 0.65);
  transition: opacity 0.15s, background 0.15s;
}
.rozie-tags-chip__remove[data-rozie-s-64848f8e]:hover:not([data-rozie-s-64848f8e]:disabled) {
  opacity: 1;
  background: var(--rozie-tags-remove-hover-bg, rgba(0, 0, 0, 0.1));
}
.rozie-tags-chip__remove[data-rozie-s-64848f8e]:disabled {
  cursor: not-allowed;
  opacity: 0.4;
}
.rozie-tags-input[data-rozie-s-64848f8e] {
  flex: 1 1 var(--rozie-tags-input-min, 4rem);
  min-width: var(--rozie-tags-input-min, 4rem);
  padding: var(--rozie-tags-input-padding, 0.15rem 0.1rem);
  font: inherit;
  color: var(--rozie-tags-color, inherit);
  background: transparent;
  border: none;
  outline: none;
}
.rozie-tags-input[data-rozie-s-64848f8e]::placeholder {
  color: var(--rozie-tags-placeholder-color, rgba(0, 0, 0, 0.4));
}
.rozie-tags-input[data-rozie-s-64848f8e]:disabled {
  cursor: not-allowed;
}
.rozie-tags-count[data-rozie-s-64848f8e] {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.rozie-tags--disabled[data-rozie-s-64848f8e] {
  cursor: not-allowed;
  opacity: var(--rozie-tags-disabled-opacity, 0.6);
  background: var(--rozie-tags-disabled-bg, rgba(0, 0, 0, 0.04));
}`);

interface TagSlotCtx { tag: any; index: any; remove: any; }

interface TagsProps {
  /**
   * The committed tokens — `model: true`, so a commit/remove/paste writes a **fresh** array back through `r-model:modelValue` (uncontrolled fallback `[]`). Because it is the sole model prop, the Angular output is a `ControlValueAccessor` (`[formControl]` / `[(ngModel)]` bind directly).
   * @example
   * <Tags r-model:modelValue="skills" placeholder="Add a skill…" />
   */
  modelValue?: any[];
  defaultModelValue?: any[];
  onModelValueChange?: (modelValue: any[]) => void;
  /**
   * The keys that commit the current draft as a token (matched against the key event's `key`). Default `[',', 'Enter']`. Non-`'Enter'` entries also act as the split characters when pasting bulk text. Use e.g. `[' ', 'Enter']` for a space-delimited input.
   */
  delimiters?: any[];
  /**
   * Allow the same token value to be added more than once. Defaults to `false` — a candidate equal (case-sensitive) to an existing token is silently rejected on commit. Set `true` to permit duplicates.
   */
  allowDuplicates?: boolean;
  /**
   * Maximum number of tokens. Once the list reaches `max`, the input is disabled and further adds (type, paste, programmatic) are rejected. `null` (the default) means unlimited.
   */
  max?: (number) | null;
  /**
   * Disable the whole control — the text input is disabled, every remove button is disabled, and no token can be added or removed. Also sets the Angular CVA disabled state.
   */
  disabled?: boolean;
  /**
   * Render the tokens read-only — they remain visible but cannot be added or removed, and the text input is hidden. Unlike `disabled` it carries no disabled styling, so it reads as a display of committed values.
   */
  readonly?: boolean;
  /**
   * Optional per-token validator / normalizer. Called with `(candidate, tokens)` for each commit; return a (possibly normalized) **string** to accept it, or a falsy value (`false` / `null` / `""`) to reject the candidate. Runs before the dedup + `max` checks. Example: `v => /^\S+@\S+$/.test(v) ? v.toLowerCase() : false` for emails.
   * @example
   * validate: (v) => (v.length >= 2 ? v.trim() : false)
   */
  validate?: ((...args: unknown[]) => unknown) | null;
  /**
   * Placeholder text for the inline text input (e.g. `"Add a tag…"`).
   */
  placeholder?: string;
  /**
   * Accessible name for the whole control (`role="group"`). The inline text input is labelled with the same name so assistive tech announces what is being entered. A visually-hidden live region announces the current token count on change.
   */
  ariaLabel?: (string) | null;
  onChange?: (...args: unknown[]) => void;
  onAdd?: (...args: unknown[]) => void;
  onRemove?: (...args: unknown[]) => void;
  tagSlot?: (ctx: TagSlotCtx) => JSX.Element;
  slots?: Record<string, (ctx: any) => JSX.Element>;
  ref?: (h: TagsHandle) => void;
}

export interface TagsHandle {
  clear: (...args: any[]) => any;
  focus: (...args: any[]) => any;
}

export default function Tags(_props: TagsProps): JSX.Element {
  const _merged = mergeProps({ delimiters: (() => [',', 'Enter'])(), allowDuplicates: false, max: null, disabled: false, readonly: false, validate: null, placeholder: '', ariaLabel: null }, _props);
  const [local, attrs] = splitProps(_merged, ['modelValue', 'delimiters', 'allowDuplicates', 'max', 'disabled', 'readonly', 'validate', 'placeholder', 'ariaLabel', 'ref']);
  onMount(() => { local.ref?.({ clear, focus }); });

  const [modelValue, setModelValue] = createControllableSignal<any[]>(_props as unknown as Record<string, unknown>, 'modelValue', (() => [])());
  const [draft, setDraft] = createSignal('');
  let rootRef: HTMLElement | null = null;

  // ---- derived view (plain functions, uniform ×6) ------------------------
  // The committed tokens, normalized to a string[].
  function tokens() {
    return Array.isArray(modelValue()) ? modelValue() : [];
  }

  // The configured commit keys, normalized to a string[].
  function commitKeys() {
    return Array.isArray(local.delimiters) ? local.delimiters : [',', 'Enter'];
  }

  // The non-Enter delimiters act as split characters for paste.
  function splitChars() {
    return commitKeys().filter((k: any) => k !== 'Enter');
  }

  // Whether the control has reached its token cap.
  function atMax() {
    return typeof local.max === 'number' && tokens().length >= local.max;
  }

  // Whether new input is accepted at all.
  function canEdit() {
    return !local.disabled && !local.readonly;
  }

  // ---- write funnel (single $emit site) ----------------------------------
  // Write the model and emit change. Every committed-list mutation funnels here.
  function commitTokens(next: any) {
    setModelValue(next);
    _props.onChange?.({
      value: next
    });
  }

  // ---- add / remove ------------------------------------------------------
  // Normalize → validate → dedup → cap a candidate, then commit + emit add.
  // Returns true if it was added (so the caller can clear the draft).
  function addToken(raw: any) {
    if (!canEdit()) return false;
    let candidate = String(raw == null ? '' : raw).trim();
    if (!candidate) return false;
    if (typeof local.validate === 'function') {
      const result = local.validate(candidate, tokens());
      if (!result) return false;
      candidate = String(result);
      if (!candidate) return false;
    }
    const cur = tokens();
    if (!local.allowDuplicates && cur.indexOf(candidate) !== -1) return false;
    if (typeof local.max === 'number' && cur.length >= local.max) return false;
    const next = cur.concat([candidate]);
    commitTokens(next);
    _props.onAdd?.({
      value: candidate,
      tokens: next
    });
    return true;
  }

  // Remove the token at `idx`, commit, and emit remove.
  function removeAt(idx: any) {
    if (!canEdit()) return;
    const cur = tokens();
    if (idx < 0 || idx >= cur.length) return;
    const removed = cur[idx];
    const next = cur.slice(0, idx).concat(cur.slice(idx + 1));
    commitTokens(next);
    _props.onRemove?.({
      value: removed,
      index: idx,
      tokens: next
    });
  }

  // ---- focus (container ref, post-mount only) ----------------------------
  // Read $refs.root only here / in $onMount / in $expose verbs (post-mount →
  // ROZ123-safe). querySelector reaches the input inside Lit's shadow root too.
  function focusTheInput() {
    const root = rootRef;
    if (!root) return;
    const el = root.querySelector('input');
    if (el) el.focus();
  }

  // ---- input handlers ----------------------------------------------------
  // Mirror the typed text into the draft buffer. Capture the fresh local value
  // (do NOT re-read $data.draft in the same handler — React setState is async and
  // would read the pre-write value).
  function onInput(e: any) {
    setDraft(e && e.target ? e.target.value : '');
  }

  // A delimiter key commits the current draft; Backspace in an empty input
  // deletes the previous token.
  function onKeydown(e: any) {
    if (!canEdit()) return;
    const key = e ? e.key : '';
    const value = e && e.target ? e.target.value : '';
    if (commitKeys().indexOf(key) !== -1) {
      if (e) e.preventDefault();
      if (addToken(value)) setDraft('');
      return;
    }
    if (key === 'Backspace' && value === '') {
      const cur = tokens();
      if (cur.length > 0) {
        if (e) e.preventDefault();
        removeAt(cur.length - 1);
      }
    }
  }

  // Commit any leftover draft when the input loses focus (a common chips UX).
  function onBlur(e: any) {
    if (!canEdit()) return;
    const value = e && e.target ? e.target.value : '';
    if (value && addToken(value)) setDraft('');
  }

  // Paste: split on the configured delimiter characters and bulk-add.
  function onPaste(e: any) {
    if (!canEdit()) return;
    const text = e && e.clipboardData && e.clipboardData.getData('text') || '';
    const seps = splitChars();
    let parts = [text];
    if (seps.length) {
      // Split on every separator char in turn.
      for (let s = 0; s < seps.length; s++) {
        const sep = seps[s];
        const out = [];
        for (let p = 0; p < parts.length; p++) {
          const pieces = String(parts[p]).split(sep);
          for (let q = 0; q < pieces.length; q++) out.push(pieces[q]);
        }
        parts = out;
      }
    }
    const trimmed = parts.map((p: any) => String(p).trim()).filter((p: any) => p.length > 0);
    if (trimmed.length <= 1 && seps.length === 0) return; // let the input handle a plain paste
    if (e) e.preventDefault();
    let addedAny = false;
    for (let i = 0; i < trimmed.length; i++) {
      if (addToken(trimmed[i])) addedAny = true;
    }
    if (addedAny) setDraft('');
  }

  // ---- per-element attribute helpers -------------------------------------
  function removeLabel(t: any) {
    return 'Remove ' + String(t);
  }
  function countLabel() {
    const n = tokens().length;
    return n === 1 ? '1 tag' : n + ' tags';
  }

  // ---- lifecycle + imperative handle -------------------------------------
  // clear() — remove every token (emits change with []) and focus the input.
  function clear() {
    commitTokens([]);
    setDraft('');
    focusTheInput();
  }
  // focus() — move DOM focus to the text input. DELIBERATELY overrides the
  // inherited HTMLElement.focus on the Lit custom element (warn-only ROZ137,
  // accepted — the public focus() handle is the intended semantics; otp/slider
  // precedent, consistent with NumberField which also exposes `focus`).
  function focus() {
    return focusTheInput();
  }

  return (
    <>
    <div ref={(el) => { rootRef = el as HTMLElement; }} role="group" aria-label={rozieAttr(local.ariaLabel)} {...attrs} class={"rozie-tags" + " " + rozieClass({ 'rozie-tags--disabled': local.disabled, 'rozie-tags--readonly': local.readonly }) + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-64848f8e="">
      <ul class={"rozie-tags-list"} data-rozie-s-64848f8e="">
        <For each={tokens()}>{(t) => <li class={"rozie-tags-chip"} data-rozie-s-64848f8e="">
          {(_props.tagSlot ?? _props.slots?.['tag'])?.({ tag: t, index: tokens().indexOf(t), remove: () => removeAt(tokens().indexOf(t)) }) ?? <><span class={"rozie-tags-chip__label"} data-rozie-s-64848f8e="">{rozieDisplay(t)}</span>{<Show when={!local.readonly}><button type="button" aria-label={rozieAttr(removeLabel(t))} class={"rozie-tags-chip__remove"} disabled={!!local.disabled} onClick={($event) => { removeAt(tokens().indexOf(t)); }} data-rozie-s-64848f8e="">×</button></Show>}</>}
        </li>}</For>
      </ul>

      {<Show when={!local.readonly}><input type="text" autocomplete="off" autocapitalize="off" aria-label={rozieAttr(local.ariaLabel)} aria-disabled={!!local.disabled} class={"rozie-tags-input"} value={draft()} placeholder={local.placeholder} disabled={!!local.disabled || !!atMax()} onInput={($event) => { onInput($event); }} onKeyDown={($event) => { onKeydown($event); }} onPaste={($event) => { onPaste($event); }} onBlur={($event) => { onBlur($event); }} data-rozie-s-64848f8e="" /></Show>}<span class={"rozie-tags-count"} aria-live="polite" data-rozie-s-64848f8e="">{rozieDisplay(countLabel())}</span>
    </div>
    </>
  );
}
ts
import { LitElement, css, html, nothing } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher, signal } from '@lit-labs/preact-signals';
import { createLitControllableProperty, rozieAttr, rozieDisplay, rozieListeners, rozieSpread } from '@rozie/runtime-lit';
import { repeat } from 'lit/directives/repeat.js';

interface RozieTagSlotCtx {
  tag: unknown;
  index: unknown;
  remove: unknown;
}

@customElement('rozie-tags')
export default class Tags extends SignalWatcher(LitElement) {
  static styles = css`
.rozie-tags[data-rozie-s-64848f8e] {
  display: inline-flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--rozie-tags-gap, 0.4rem);
  padding: var(--rozie-tags-padding, 0.35rem 0.45rem);
  font: var(--rozie-tags-font, inherit);
  background: var(--rozie-tags-bg, #fff);
  border: var(--rozie-tags-border-width, 1px) solid var(--rozie-tags-border-color, rgba(0, 0, 0, 0.25));
  border-radius: var(--rozie-tags-radius, 0.5rem);
  min-width: var(--rozie-tags-min-width, 12rem);
}
.rozie-tags[data-rozie-s-64848f8e]:focus-within {
  border-color: var(--rozie-tags-accent, #0066cc);
  box-shadow: 0 0 0 var(--rozie-tags-focus-ring-width, 3px) var(--rozie-tags-focus-ring-color, rgba(0, 102, 204, 0.25));
}
.rozie-tags-list[data-rozie-s-64848f8e] {
  display: contents;
  margin: 0;
  padding: 0;
  list-style: none;
}
.rozie-tags-chip[data-rozie-s-64848f8e] {
  display: inline-flex;
  align-items: center;
  gap: var(--rozie-tags-chip-gap, 0.3rem);
  padding: var(--rozie-tags-chip-padding, 0.15rem 0.5rem);
  font-size: var(--rozie-tags-chip-font-size, 0.85rem);
  color: var(--rozie-tags-chip-color, inherit);
  background: var(--rozie-tags-chip-bg, rgba(0, 102, 204, 0.12));
  border-radius: var(--rozie-tags-chip-radius, 0.375rem);
  white-space: nowrap;
}
.rozie-tags-chip__remove[data-rozie-s-64848f8e] {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--rozie-tags-remove-size, 1.1rem);
  height: var(--rozie-tags-remove-size, 1.1rem);
  padding: 0;
  font: inherit;
  line-height: 1;
  color: var(--rozie-tags-remove-color, currentColor);
  background: transparent;
  border: none;
  border-radius: 50%;
  cursor: pointer;
  opacity: var(--rozie-tags-remove-opacity, 0.65);
  transition: opacity 0.15s, background 0.15s;
}
.rozie-tags-chip__remove[data-rozie-s-64848f8e]:hover:not([data-rozie-s-64848f8e]:disabled) {
  opacity: 1;
  background: var(--rozie-tags-remove-hover-bg, rgba(0, 0, 0, 0.1));
}
.rozie-tags-chip__remove[data-rozie-s-64848f8e]:disabled {
  cursor: not-allowed;
  opacity: 0.4;
}
.rozie-tags-input[data-rozie-s-64848f8e] {
  flex: 1 1 var(--rozie-tags-input-min, 4rem);
  min-width: var(--rozie-tags-input-min, 4rem);
  padding: var(--rozie-tags-input-padding, 0.15rem 0.1rem);
  font: inherit;
  color: var(--rozie-tags-color, inherit);
  background: transparent;
  border: none;
  outline: none;
}
.rozie-tags-input[data-rozie-s-64848f8e]::placeholder {
  color: var(--rozie-tags-placeholder-color, rgba(0, 0, 0, 0.4));
}
.rozie-tags-input[data-rozie-s-64848f8e]:disabled {
  cursor: not-allowed;
}
.rozie-tags-count[data-rozie-s-64848f8e] {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.rozie-tags--disabled[data-rozie-s-64848f8e] {
  cursor: not-allowed;
  opacity: var(--rozie-tags-disabled-opacity, 0.6);
  background: var(--rozie-tags-disabled-bg, rgba(0, 0, 0, 0.04));
}
`;

  /**
   * The committed tokens — `model: true`, so a commit/remove/paste writes a **fresh** array back through `r-model:modelValue` (uncontrolled fallback `[]`). Because it is the sole model prop, the Angular output is a `ControlValueAccessor` (`[formControl]` / `[(ngModel)]` bind directly).
   * @example
   * <Tags r-model:modelValue="skills" placeholder="Add a skill…" />
   */
  @property({ type: Array, attribute: 'model-value' }) _modelValue_attr: any[] = [];
  private _modelValueControllable = createLitControllableProperty<any[]>({ host: this, eventName: 'model-value-change', defaultValue: [], initialControlledValue: undefined });
  /**
   * The keys that commit the current draft as a token (matched against the key event's `key`). Default `[',', 'Enter']`. Non-`'Enter'` entries also act as the split characters when pasting bulk text. Use e.g. `[' ', 'Enter']` for a space-delimited input.
   */
  @property({ type: Array }) delimiters: any[] = [',', 'Enter'];
  /**
   * Allow the same token value to be added more than once. Defaults to `false` — a candidate equal (case-sensitive) to an existing token is silently rejected on commit. Set `true` to permit duplicates.
   */
  @property({ type: Boolean, reflect: true }) allowDuplicates: boolean = false;
  /**
   * Maximum number of tokens. Once the list reaches `max`, the input is disabled and further adds (type, paste, programmatic) are rejected. `null` (the default) means unlimited.
   */
  @property({ type: Number, reflect: true }) max: number | null = null;
  /**
   * Disable the whole control — the text input is disabled, every remove button is disabled, and no token can be added or removed. Also sets the Angular CVA disabled state.
   */
  @property({ type: Boolean, reflect: true }) disabled: boolean = false;
  /**
   * Render the tokens read-only — they remain visible but cannot be added or removed, and the text input is hidden. Unlike `disabled` it carries no disabled styling, so it reads as a display of committed values.
   */
  @property({ type: Boolean, reflect: true }) readonly: boolean = false;
  /**
   * Optional per-token validator / normalizer. Called with `(candidate, tokens)` for each commit; return a (possibly normalized) **string** to accept it, or a falsy value (`false` / `null` / `""`) to reject the candidate. Runs before the dedup + `max` checks. Example: `v => /^\S+@\S+$/.test(v) ? v.toLowerCase() : false` for emails.
   * @example
   * validate: (v) => (v.length >= 2 ? v.trim() : false)
   */
  @property({ type: Function }) validate: ((...args: unknown[]) => unknown) | null = null;
  /**
   * Placeholder text for the inline text input (e.g. `"Add a tag…"`).
   */
  @property({ type: String, reflect: true }) placeholder: string = '';
  /**
   * Accessible name for the whole control (`role="group"`). The inline text input is labelled with the same name so assistive tech announces what is being entered. A visually-hidden live region announces the current token count on change.
   */
  @property({ type: String, reflect: true }) ariaLabel: string | null = null;
  private _draft = signal('');
  @query('[data-rozie-ref="root"]') private _refRoot!: HTMLElement;

  @state() private _hasSlotTag = false;
  @queryAssignedElements({ slot: 'tag', flatten: true }) private _slotTagElements!: Element[];
  @property({ attribute: false }) tag?: (scope: { tag: unknown; index: unknown; remove: unknown }) => unknown;

  private _disconnectCleanups: Array<() => void> = [];
  // Re-parenting guard: set true once the deferred teardown has actually
  // run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
  private _rozieTornDown = false;

  private _armListeners(): void {
    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="tag"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotTag = this._slotTagElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }
  }

  connectedCallback(): void {
    // Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
    this._hasSlotTag = Array.from(this.children).some((el) => el.getAttribute('slot') === 'tag');
    super.connectedCallback();
    if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
  }

  firstUpdated(): void {
    this._armListeners();
  }

  disconnectedCallback(): void {
    super.disconnectedCallback();
    queueMicrotask(() => {
      if (this.isConnected || this._rozieTornDown) return;
      this._rozieTornDown = true;
      for (const fn of this._disconnectCleanups) fn();
      this._disconnectCleanups = [];
    });
  }

  attributeChangedCallback(name: string, old: string | null, value: string | null): void {
    super.attributeChangedCallback(name, old, value);
    if (name === 'model-value') this._modelValueControllable.notifyAttributeChange(value as unknown as any[]);
  }

  render() {
    return html`
<div class="${Object.entries({ "rozie-tags": true, 'rozie-tags--disabled': this.disabled, 'rozie-tags--readonly': this.readonly }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role="group" aria-label=${this.ariaLabel} ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-ref="root" data-rozie-s-64848f8e>
  <ul class="rozie-tags-list" data-rozie-s-64848f8e>
    ${repeat<any>(this.tokens(), (t, _idx) => t + ':' + this.tokens().indexOf(t), (t, _idx) => html`<li class="rozie-tags-chip" key=${rozieAttr(t + ':' + this.tokens().indexOf(t))} data-rozie-s-64848f8e>
      ${this.tag !== undefined ? this.tag({tag: t, index: this.tokens().indexOf(t), remove: () => this.removeAt(this.tokens().indexOf(t))}) : html`<slot name="tag" data-rozie-params=${(() => { try { return JSON.stringify({tag: t, index: this.tokens().indexOf(t)}); } catch { return '{}'; } })()} @rozie-tag-remove=${($event: CustomEvent) => ((() => this.removeAt(this.tokens().indexOf(t))) as (...args: any[]) => any)($event.detail)}>
        <span class="rozie-tags-chip__label" data-rozie-s-64848f8e>${rozieDisplay(t)}</span>
        ${!this.readonly ? html`<button class="rozie-tags-chip__remove" type="button" ?disabled=${!!this.disabled} aria-label=${rozieAttr(this.removeLabel(t))} @click=${($event: Event) => { this.removeAt(this.tokens().indexOf(t)); }} data-rozie-s-64848f8e>×</button>` : nothing}</slot>`}
    </li>`)}
  </ul>

  ${!this.readonly ? html`<input class="rozie-tags-input" type="text" autocomplete="off" autocapitalize="off" .value=${this._draft.value} placeholder=${this.placeholder} ?disabled=${!!this.disabled || !!this.atMax()} aria-label=${this.ariaLabel} aria-disabled=${!!this.disabled} @input=${($event: Event) => { this.onInput($event); }} @keydown=${($event: Event) => { this.onKeydown($event); }} @paste=${($event: Event) => { this.onPaste($event); }} @blur=${($event: Event) => { this.onBlur($event); }} data-rozie-s-64848f8e />` : nothing}<span class="rozie-tags-count" aria-live="polite" data-rozie-s-64848f8e>${rozieDisplay(this.countLabel())}</span>
</div>
`;
  }

  tokens = () => Array.isArray(this.modelValue) ? this.modelValue : [];

  commitKeys = () => Array.isArray(this.delimiters) ? this.delimiters : [',', 'Enter'];

  splitChars = () => this.commitKeys().filter((k: any) => k !== 'Enter');

  atMax = () => typeof this.max === 'number' && this.tokens().length >= this.max;

  canEdit = () => !this.disabled && !this.readonly;

  commitTokens = (next: any) => {
  this._modelValueControllable.write(next);
  this.dispatchEvent(new CustomEvent("change", {
    detail: {
      value: next
    },
    bubbles: true,
    composed: true
  }));
};

  addToken = (raw: any) => {
  if (!this.canEdit()) return false;
  let candidate = String(raw == null ? '' : raw).trim();
  if (!candidate) return false;
  if (typeof this.validate === 'function') {
    const result = this.validate(candidate, this.tokens());
    if (!result) return false;
    candidate = String(result);
    if (!candidate) return false;
  }
  const cur = this.tokens();
  if (!this.allowDuplicates && cur.indexOf(candidate) !== -1) return false;
  if (typeof this.max === 'number' && cur.length >= this.max) return false;
  const next = cur.concat([candidate]);
  this.commitTokens(next);
  this.dispatchEvent(new CustomEvent("add", {
    detail: {
      value: candidate,
      tokens: next
    },
    bubbles: true,
    composed: true
  }));
  return true;
};

  removeAt = (idx: any) => {
  if (!this.canEdit()) return;
  const cur = this.tokens();
  if (idx < 0 || idx >= cur.length) return;
  const removed = cur[idx];
  const next = cur.slice(0, idx).concat(cur.slice(idx + 1));
  this.commitTokens(next);
  this.dispatchEvent(new CustomEvent("remove", {
    detail: {
      value: removed,
      index: idx,
      tokens: next
    },
    bubbles: true,
    composed: true
  }));
};

  focusTheInput = () => {
  const root = this._refRoot;
  if (!root) return;
  const el = root.querySelector('input');
  if (el) el.focus();
};

  onInput = (e: any) => {
  this._draft.value = e && e.target ? e.target.value : '';
};

  onKeydown = (e: any) => {
  if (!this.canEdit()) return;
  const key = e ? e.key : '';
  const value = e && e.target ? e.target.value : '';
  if (this.commitKeys().indexOf(key) !== -1) {
    if (e) e.preventDefault();
    if (this.addToken(value)) this._draft.value = '';
    return;
  }
  if (key === 'Backspace' && value === '') {
    const cur = this.tokens();
    if (cur.length > 0) {
      if (e) e.preventDefault();
      this.removeAt(cur.length - 1);
    }
  }
};

  onBlur = (e: any) => {
  if (!this.canEdit()) return;
  const value = e && e.target ? e.target.value : '';
  if (value && this.addToken(value)) this._draft.value = '';
};

  onPaste = (e: any) => {
  if (!this.canEdit()) return;
  const text = e && e.clipboardData && e.clipboardData.getData('text') || '';
  const seps = this.splitChars();
  let parts = [text];
  if (seps.length) {
    // Split on every separator char in turn.
    for (let s = 0; s < seps.length; s++) {
      const sep = seps[s];
      const out = [];
      for (let p = 0; p < parts.length; p++) {
        const pieces = String(parts[p]).split(sep);
        for (let q = 0; q < pieces.length; q++) out.push(pieces[q]);
      }
      parts = out;
    }
  }
  const trimmed = parts.map((p: any) => String(p).trim()).filter((p: any) => p.length > 0);
  if (trimmed.length <= 1 && seps.length === 0) return; // let the input handle a plain paste
  if (e) e.preventDefault();
  let addedAny = false;
  for (let i = 0; i < trimmed.length; i++) {
    if (this.addToken(trimmed[i])) addedAny = true;
  }
  if (addedAny) this._draft.value = '';
};

  removeLabel = (t: any) => 'Remove ' + String(t);

  countLabel = () => {
  const n = this.tokens().length;
  return n === 1 ? '1 tag' : n + ' tags';
};

  clear = () => {
  this.commitTokens([]);
  this._draft.value = '';
  this.focusTheInput();
};

  focus = () => this.focusTheInput();

  get modelValue(): any[] { return this._modelValueControllable.read(); }
  set modelValue(v: any[]) { this._modelValueControllable.notifyPropertyWrite(v); }

  /**
   * Plan 14-05 — cross-framework attribute fallthrough source. Reads the
   * host custom element's attributes on each call so a consumer-side bound
   * attribute flows through on every render. The `rozieSpread` directive
   * (D-02) does the cross-render diff downstream.
   *
   * Phase 15 follow-up Bug A — declared-prop attribute names are filtered
   * out so `$attrs` returns "rest after declared props" (semantic parity
   * with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
   * forms are folded into the skip set: kebab-case for model props
   * (explicit `attribute:`) AND lowercased property name (Lit's default).
   */
  private get $attrs(): Record<string, string> {
    const __skip = new Set<string>(['model-value', 'modelvalue', 'delimiters', 'allow-duplicates', 'allowduplicates', 'max', 'disabled', 'readonly', 'validate', 'placeholder', 'aria-label', 'arialabel']);
    const out: Record<string, string> = {};
    for (const a of Array.from(this.attributes)) {
      if (__skip.has(a.name)) continue;
      out[a.name] = a.value;
    }
    return out;
  }

  /**
   * Phase 15 D-19 — consumer-passed listener cluster placeholder.
   * Lit attaches event listeners directly on the host element via
   * `addEventListener` (no per-instance prop rest binding), so the
   * runtime value is undefined; the `rozieListeners` directive's
   * nullish coercion (`obj ?? {}`) handles the no-op cleanly.
   * The declaration exists to satisfy `tsc --noEmit` on consumer
   * projects with strict mode — bare `$listeners` in `render()`
   * would otherwise raise TS2304 (Cannot find name).
   */
  private get $listeners(): Record<string, EventListener> | undefined {
    return undefined;
  }
}

Each is a real, idiomatic component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component (with ControlValueAccessor), a Solid component, and a Lit custom element. Same props, same add / remove / change events, same two-way modelValue, same scoped #tag slot, same imperative handle — all from the one source above, with no third-party engine behind it.

See also

  • Tags — showcase — overview, quick start, theming, and the full reference.
  • Headless tags input comparison — how @rozie-ui/tags stacks up against react-tag-input, vue3-tags-input, ngx-chips, Tagify, and the per-framework token-input libraries.

Pre-v1.0 — internal monorepo.