Tags Input
tags-inputuiMulti-tag input — type a value, hit Enter, get a chip. Backspace removes the last tag. Use for email recipient lists, tag sets, and free-form keyword inputs.
Installation
$pnpm dlx uipkge-ng@latest add tags-input$npx uipkge-ng@latest add tags-input$yarn dlx uipkge-ng@latest add tags-input$bunx uipkge-ng@latest add tags-inputManifest:
https://uipkge.dev/r/angular/tags-input.jsonInstalls to:src/app/components/ui/tags-input/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
valueControlled list of tags (pair with `valueChange`). Leave unset for uncontrolled use. | string[] | — | optional |
defaultValueUncontrolled initial list. | string[] | — | optional |
placeholder | string | — | optional |
disabled | boolean | false | optional |
addOnKeysKeys that commit the typed value into a tag. Defaults to Enter + comma. | string[] | — | optional |
addOnPasteSplit pasted text on whitespace and add each token as a tag. | boolean | false | optional |
delimiterCharacter that commits the typed value into a tag (alias for addOnKeys). | string | — | optional |
uniqueReject duplicate tags (case-sensitive). | boolean | true | optional |
maxMaximum number of tags allowed. | number | — | optional |
class | string | — | optional |
valueChangeFires with the next list whenever a tag is added or removed (React `onValueChange`). | EventEmitter<string[]> | — | optional |
Files installed (2)
src/app/components/ui/tags-input/tags-input.component.ts6.5 kB
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild, booleanAttribute, signal, ChangeDetectionStrategy, } from '@angular/core' import { cn } from '@/lib/utils' /** * Angular port of UIPKGE TagsInput, 1:1 with the React component: type a value and press a * commit key (Enter / comma by default, or `addOnKeys` / `delimiter`) to add a chip; * Backspace on an empty draft removes the last chip; blur commits the draft; `addOnPaste` * splits pasted text on whitespace; `unique` rejects duplicates and `max` caps the list. * Clicking anywhere in the box focuses the input. `value` / `defaultValue` / `valueChange` * work controlled or uncontrolled like React. Class strings copied verbatim from React. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-tags-input, [ui-tags-input]', standalone: true, host: { 'data-uipkge': '', 'data-slot': 'tags-input', '[class]': 'hostClass', '(click)': 'focusInput()', }, template: ` @for (tag of tags; track $index; let i = $index) { <span data-uipkge="" data-slot="tags-input-item" class="bg-secondary data-[state=active]:ring-ring ring-offset-background flex h-5 items-center rounded-md data-[state=active]:ring-2 data-[state=active]:ring-offset-2" > <span data-slot="tags-input-item-text" class="rounded bg-transparent px-2 py-0.5 text-sm">{{ tag }}</span> <button type="button" [attr.aria-label]="'Remove ' + tag" [disabled]="disabled" data-slot="tags-input-item-delete" class="hover:text-foreground focus-visible:ring-ring mr-1 flex rounded bg-transparent focus-visible:ring-2 focus-visible:outline-none disabled:pointer-events-none" (mousedown)="$event.preventDefault()" (click)="$event.stopPropagation(); removeAt(i)" > <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x h-4 w-4" aria-hidden="true" > <path d="M18 6 6 18" /> <path d="m6 6 12 12" /> </svg> </button> </span> } @if (!disabled) { <input #input data-slot="tags-input-input" [value]="draft()" [attr.placeholder]="atMax ? null : (placeholder ?? null)" [disabled]="atMax" class="min-h-5 flex-1 bg-transparent px-1 text-sm focus:outline-none disabled:cursor-default" (input)="draft.set($any($event.target).value)" (keydown)="handleKeyDown($event)" (paste)="handlePaste($event)" (blur)="addTag(draft())" /> } `, }) export class UiTagsInputComponent { /** Controlled list of tags (pair with `valueChange`). Leave unset for uncontrolled use. */ @Input() value?: string[] /** Uncontrolled initial list. */ @Input() defaultValue?: string[] @Input() placeholder?: string @Input({ transform: booleanAttribute }) disabled = false /** Keys that commit the typed value into a tag. Defaults to Enter + comma. */ @Input() addOnKeys?: string[] /** Split pasted text on whitespace and add each token as a tag. */ @Input({ transform: booleanAttribute }) addOnPaste = false /** Character that commits the typed value into a tag (alias for addOnKeys). */ @Input() delimiter?: string /** Reject duplicate tags (case-sensitive). */ @Input({ transform: booleanAttribute }) unique = true /** Maximum number of tags allowed. */ @Input() max?: number @Input('class') className?: string /** Fires with the next list whenever a tag is added or removed (React `onValueChange`). */ @Output() valueChange = new EventEmitter<string[]>() @ViewChild('input') private inputRef?: ElementRef<HTMLInputElement> readonly draft = signal('') private readonly internal = signal<string[] | null>(null) get tags(): string[] { if (this.value !== undefined) return this.value return this.internal() ?? this.defaultValue ?? [] } get commitKeys(): string[] { return this.addOnKeys ?? (this.delimiter ? [this.delimiter] : ['Enter', ',']) } get atMax(): boolean { return this.max !== undefined && this.tags.length >= this.max } get hostClass(): string { return cn( 'border-input bg-background flex flex-wrap items-center gap-2 rounded-md border px-2 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none', 'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]', 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', this.disabled && 'pointer-events-none opacity-50', this.className, ) } focusInput(): void { if (!this.disabled) this.inputRef?.nativeElement.focus() } private commit(next: string[]): void { const capped = this.max !== undefined ? next.slice(0, this.max) : next if (this.value === undefined) this.internal.set(capped) this.valueChange.emit(capped) } addTag(raw: string): void { const trimmed = raw.trim() if (!trimmed) return if (this.atMax || (this.unique && this.tags.includes(trimmed))) { this.setDraft('') return } this.commit([...this.tags, trimmed]) this.setDraft('') } removeAt(index: number): void { this.commit(this.tags.filter((_, i) => i !== index)) } handleKeyDown(e: KeyboardEvent): void { if (this.commitKeys.includes(e.key)) { e.preventDefault() this.addTag(this.draft()) } else if (e.key === 'Backspace' && this.draft() === '' && this.tags.length > 0) { this.removeAt(this.tags.length - 1) } } handlePaste(e: ClipboardEvent): void { if (!this.addOnPaste) return const text = e.clipboardData?.getData('text') ?? '' if (!text.trim()) return e.preventDefault() const tokens = text .split(/\s+/) .map((t) => t.trim()) .filter(Boolean) let next = [...this.tags] for (const token of tokens) { if (this.max !== undefined && next.length >= this.max) break if (this.unique && next.includes(token)) continue next = [...next, token] } this.commit(next) this.setDraft('') } /** The input is bound to `draft`, but also reset its DOM value (a signal set to '' twice is a no-op). */ private setDraft(v: string): void { this.draft.set(v) if (this.inputRef) this.inputRef.nativeElement.value = v } }src/app/components/ui/tags-input/index.ts0.1 kB
export { UiTagsInputComponent } from './tags-input.component'
Raw manifest:https://uipkge.dev/r/angular/tags-input.json