Appearance
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/tagsstacks up against react-tag-input, vue3-tags-input, ngx-chips, Tagify, and the per-framework token-input libraries.