From 5ad11269d2a3e5a9a76daac9673804efc6f3d4c8 Mon Sep 17 00:00:00 2001 From: xidedix Date: Sat, 10 Oct 2026 01:18:56 +0200 Subject: [PATCH] feat(ChipInput): add Chip Input form control c-chip-input: chips from [(value)], typed or pasted text split on the separator, Enter and blur (createOnBlur) create chips, maxChips, trim and dedup, chipClassName, selectable/filter with [(selected)], removable, disabled, readonly (blocks adding, removing and toggling), sizes. Works with formControlName, ngModel and [formField] without a value accessor; invalid shows once the control is touched, validationState wins, aria-required/aria-invalid on the field. Sync and async validator directives next to ngModel are attached to the control by the component until @angular/forms does it for custom controls (angular/angular#70723), and the initial ngModel write is not announced. No hidden input and no generated name; the id, aria-label, aria-labelledby and aria-describedby go to the text field, and label renders an inline label inside the container. Keyboard on the chip set engine: two tab stops (last chip and field), Backspace or the arrow toward the chips moves to the last chip, the arrow away from the last chip returns to the field, mirrored in RTL, printable keys on a chip go to the field. Focus after a removal moves to the neighbour, or to the field after the last chip. A chip placed inside c-chip-input is ignored with a warning. c-chip size and c-chip-set selectionMode spell out their values in the input types, so the API tables show 'sm' | 'lg' and 'single' | 'multiple'. --- .../lib/chip-input/chip-input.component.html | 24 + .../chip-input/chip-input.component.spec.ts | 510 ++++++++++++++++ .../lib/chip-input/chip-input.component.ts | 549 ++++++++++++++++++ .../lib/chip-input/chip-input.forms.spec.ts | 203 +++++++ .../chip-input/chip-input.keyboard.spec.ts | 287 +++++++++ .../src/lib/chip-input/chip-input.module.ts | 8 + .../chip-input/chip-input.validators.spec.ts | 138 +++++ .../src/lib/chip-input/index.ts | 1 + .../src/lib/chip-input/public_api.ts | 2 + .../src/lib/chip-set/chip-set.component.ts | 4 +- .../src/lib/chip-set/chip-set.service.ts | 57 +- .../src/lib/chip/chip-set-ref.ts | 1 + .../src/lib/chip/chip.component.ts | 13 +- projects/coreui-angular/src/public-api.ts | 1 + 14 files changed, 1784 insertions(+), 14 deletions(-) create mode 100644 projects/coreui-angular/src/lib/chip-input/chip-input.component.html create mode 100644 projects/coreui-angular/src/lib/chip-input/chip-input.component.spec.ts create mode 100644 projects/coreui-angular/src/lib/chip-input/chip-input.component.ts create mode 100644 projects/coreui-angular/src/lib/chip-input/chip-input.forms.spec.ts create mode 100644 projects/coreui-angular/src/lib/chip-input/chip-input.keyboard.spec.ts create mode 100644 projects/coreui-angular/src/lib/chip-input/chip-input.module.ts create mode 100644 projects/coreui-angular/src/lib/chip-input/chip-input.validators.spec.ts create mode 100644 projects/coreui-angular/src/lib/chip-input/index.ts create mode 100644 projects/coreui-angular/src/lib/chip-input/public_api.ts diff --git a/projects/coreui-angular/src/lib/chip-input/chip-input.component.html b/projects/coreui-angular/src/lib/chip-input/chip-input.component.html new file mode 100644 index 00000000..db9cd599 --- /dev/null +++ b/projects/coreui-angular/src/lib/chip-input/chip-input.component.html @@ -0,0 +1,24 @@ +@if (label(); as label) { + +} +@for (item of items(); track item) { + {{ item }} +} + diff --git a/projects/coreui-angular/src/lib/chip-input/chip-input.component.spec.ts b/projects/coreui-angular/src/lib/chip-input/chip-input.component.spec.ts new file mode 100644 index 00000000..7a6ea73b --- /dev/null +++ b/projects/coreui-angular/src/lib/chip-input/chip-input.component.spec.ts @@ -0,0 +1,510 @@ +import { Component, provideZonelessChangeDetection, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { ChipComponent } from '../chip/chip.component'; +import { ChipSetComponent } from '../chip-set/chip-set.component'; +import { AnnouncerService } from '../services/announcer.service'; +import { ChipInputComponent } from './chip-input.component'; + +const KEY_CODES: Record = { Enter: 13, Delete: 46, Backspace: 8 }; + +const press = (target: Element, key: string) => + target.dispatchEvent(new KeyboardEvent('keydown', { key, keyCode: KEY_CODES[key], bubbles: true, cancelable: true })); + +@Component({ + imports: [ChipInputComponent, ChipSetComponent, ChipComponent], + template: ` + + + + Other + ` +}) +class ChipInputHostComponent { + readonly chipClassName = signal string) | undefined>(undefined); + readonly createOnBlur = signal(true); + readonly disabled = signal(false); + readonly label = signal(undefined); + readonly maxChips = signal(null); + readonly mode = signal<'single' | 'multiple'>('multiple'); + readonly readonly = signal(false); + readonly removeLabel = signal('Remove'); + readonly selectable = signal(false); + readonly selected = signal([]); + readonly value = signal([]); + readonly added: string[] = []; + readonly removed: string[] = []; + readonly texts: string[] = []; +} + +describe('ChipInputComponent', () => { + let fixture: ComponentFixture; + let host: ChipInputHostComponent; + let root: HTMLElement; + let announce: ReturnType; + + const element = () => root.querySelector('c-chip-input')!; + const field = () => element().querySelector('input.chip-input-field')!; + const chips = () => [...element().querySelectorAll('c-chip')]; + const chip = (value: string) => chips().find((item) => item.getAttribute('data-coreui-chip-value') === value)!; + const type = (text: string) => { + field().value = text; + field().dispatchEvent(new Event('input', { bubbles: true })); + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ChipInputHostComponent], + providers: [provideZonelessChangeDetection()] + }).compileComponents(); + announce = vi.spyOn(TestBed.inject(AnnouncerService), 'announce').mockReturnValue(vi.fn()); + fixture = TestBed.createComponent(ChipInputHostComponent); + host = fixture.componentInstance; + root = fixture.nativeElement; + document.body.append(root); + }); + + afterEach(() => { + root.remove(); + vi.restoreAllMocks(); + }); + + it('loads and displays ChipInput component', async () => { + host.value.set(['a', 'b']); + await fixture.whenStable(); + expect(element().classList.contains('chip-input')).toBe(true); + expect(chips().map((item) => item.textContent?.trim())).toEqual(['a', 'b']); + expect(field().placeholder).toBe('Add a tag'); + }); + + it('gives the id to the text field, so an external label names it', async () => { + await fixture.whenStable(); + const withId = root.querySelectorAll('#tags'); + expect(withId.length).toBe(1); + expect(withId[0]).toBe(field()); + expect(field().labels?.[0]?.textContent).toBe('Tags'); + }); + + it('renders an inline label for the text field inside the container', async () => { + host.label.set('Skills:'); + await fixture.whenStable(); + const label = element().querySelector('label.chip-input-label')!; + expect(label.textContent).toBe('Skills:'); + expect(element().firstElementChild).toBe(label); + expect([...(field().labels ?? [])]).toContain(label); + const pressed = new MouseEvent('mousedown', { bubbles: true, cancelable: true }); + label.dispatchEvent(pressed); + expect(pressed.defaultPrevented).toBe(true); + }); + + it('has no role and no aria-disabled on the host', async () => { + host.disabled.set(true); + await fixture.whenStable(); + expect(element().getAttribute('role')).toBeNull(); + expect(element().getAttribute('aria-disabled')).toBeNull(); + }); + + it('adds a chip on Enter', async () => { + await fixture.whenStable(); + type(' Angular '); + await fixture.whenStable(); + expect(field().size).toBe(10); + const enter = new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true, cancelable: true }); + field().dispatchEvent(enter); + expect(enter.defaultPrevented).toBe(true); + await fixture.whenStable(); + expect(host.value()).toEqual(['Angular']); + expect(host.added).toEqual(['Angular']); + expect(field().value).toBe(''); + expect(chips().length).toBe(1); + }); + + it('does not add a duplicate or an empty chip', async () => { + host.value.set(['a']); + await fixture.whenStable(); + type('a'); + press(field(), 'Enter'); + type(' '); + press(field(), 'Enter'); + await fixture.whenStable(); + expect(host.value()).toEqual(['a']); + expect(host.added).toEqual([]); + }); + + it('splits typed text on the separator and keeps the last part in the field', async () => { + await fixture.whenStable(); + type('a,b,c'); + await fixture.whenStable(); + expect(host.value()).toEqual(['a', 'b']); + expect(field().value).toBe('c'); + expect(host.texts.at(-1)).toBe('c'); + }); + + it('adds every part of pasted text', async () => { + await fixture.whenStable(); + const event = new Event('paste', { bubbles: true, cancelable: true }) as ClipboardEvent; + Object.defineProperty(event, 'clipboardData', { value: { getData: () => 'x, y ,z' } }); + field().dispatchEvent(event); + await fixture.whenStable(); + expect(event.defaultPrevented).toBe(true); + expect(host.value()).toEqual(['x', 'y', 'z']); + }); + + it('stops adding at maxChips', async () => { + host.maxChips.set(2); + await fixture.whenStable(); + type('a,b,c,'); + await fixture.whenStable(); + expect(host.value()).toEqual(['a', 'b']); + }); + + it('removes a chip with its remove button', async () => { + host.value.set(['a', 'b']); + await fixture.whenStable(); + chip('a').querySelector('button.chip-remove')!.click(); + await fixture.whenStable(); + expect(host.value()).toEqual(['b']); + expect(host.removed).toEqual(['a']); + }); + + it('names the remove buttons with its label and the chip value', async () => { + host.value.set(['a']); + host.removeLabel.set('Usuń'); + await fixture.whenStable(); + expect(chip('a').querySelector('button.chip-remove')?.getAttribute('aria-label')).toBe('Usuń a'); + }); + + it('selects chips as toggle buttons and deselects siblings in single mode', async () => { + host.value.set(['a', 'b']); + host.selectable.set(true); + host.mode.set('single'); + await fixture.whenStable(); + expect(chip('a').getAttribute('role')).toBe('button'); + expect(chip('a').getAttribute('aria-pressed')).toBe('false'); + chip('a').click(); + chip('b').click(); + await fixture.whenStable(); + expect(host.selected()).toEqual(['b']); + expect(chip('a').getAttribute('aria-pressed')).toBe('false'); + expect(chip('b').getAttribute('aria-pressed')).toBe('true'); + }); + + it('leaves the disabled semantics to the field', async () => { + host.value.set(['a']); + host.disabled.set(true); + await fixture.whenStable(); + expect(field().disabled).toBe(true); + expect(element().classList.contains('disabled')).toBe(true); + expect(chip('a').querySelector('.chip-remove')).toBeNull(); + expect(chip('a').getAttribute('tabindex')).toBeNull(); + }); + + it('leaves the readonly semantics to the field and blocks every change', async () => { + host.value.set(['a']); + host.selectable.set(true); + host.readonly.set(true); + await fixture.whenStable(); + expect(field().readOnly).toBe(true); + expect(chip('a').querySelector('.chip-remove')).toBeNull(); + expect(chip('a').getAttribute('aria-disabled')).toBe('true'); + expect(chip('a').classList.contains('chip-clickable')).toBe(false); + chip('a').click(); + press(chip('a'), 'Delete'); + type('b'); + press(field(), 'Enter'); + await fixture.whenStable(); + expect(host.selected()).toEqual([]); + expect(host.value()).toEqual(['a']); + }); + + it('creates a chip when focus leaves the component, also through one of its chips', async () => { + host.value.set(['a']); + await fixture.whenStable(); + const leave = (from: Element, to: Element | null) => + from.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: to })); + type('b'); + leave(field(), chip('a')); + await fixture.whenStable(); + expect(host.value()).toEqual(['a']); + + leave(chip('a'), root.querySelector('#outside')); + await fixture.whenStable(); + expect(host.value()).toEqual(['a', 'b']); + + type('c'); + leave(field(), root.querySelector('c-chip-set c-chip')); + await fixture.whenStable(); + expect(host.value()).toEqual(['a', 'b', 'c']); + + host.createOnBlur.set(false); + await fixture.whenStable(); + type('d'); + leave(field(), null); + await fixture.whenStable(); + expect(host.value()).toEqual(['a', 'b', 'c']); + }); + + it('drops a removed chip from the selection', async () => { + host.value.set(['a', 'b']); + host.selectable.set(true); + host.selected.set(['a']); + await fixture.whenStable(); + chip('a').querySelector('button.chip-remove')!.click(); + await fixture.whenStable(); + expect(host.value()).toEqual(['b']); + expect(host.selected()).toEqual([]); + }); + + it('keeps the selection of a readonly control when the field gets focus', async () => { + host.value.set(['a']); + host.selectable.set(true); + host.readonly.set(true); + host.selected.set(['a']); + await fixture.whenStable(); + field().focus(); + await fixture.whenStable(); + expect(host.selected()).toEqual(['a']); + }); + + it('keeps text that the chip limit refused', async () => { + host.maxChips.set(1); + await fixture.whenStable(); + type('a,b,c'); + await fixture.whenStable(); + expect(host.value()).toEqual(['a']); + expect(field().value).toBe('b,c'); + + host.value.set([]); + await fixture.whenStable(); + press(field(), 'Enter'); + await fixture.whenStable(); + expect(host.value()).toEqual(['b']); + expect(field().value).toBe('c'); + }); + + it('pastes into the text already in the field', async () => { + await fixture.whenStable(); + type('foobaz'); + field().setSelectionRange(3, 3); + const event = new Event('paste', { bubbles: true, cancelable: true }) as ClipboardEvent; + Object.defineProperty(event, 'clipboardData', { value: { getData: () => ',bar,' } }); + field().dispatchEvent(event); + await fixture.whenStable(); + expect(host.value()).toEqual(['foo', 'bar', 'baz']); + expect(field().value).toBe(''); + }); + + it('ignores Enter while an input method is composing', async () => { + await fixture.whenStable(); + type('とうきょう'); + field().dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', isComposing: true, bubbles: true, cancelable: true }) + ); + field().dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', keyCode: 229, bubbles: true, cancelable: true }) + ); + await fixture.whenStable(); + expect(host.value()).toEqual([]); + expect(field().value).toBe('とうきょう'); + }); + + it('keeps focus in the field when the background is pressed', async () => { + await fixture.whenStable(); + const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true }); + element().dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + host.value.set(['a']); + await fixture.whenStable(); + for (const target of [field(), chip('a')]) { + const pressed = new MouseEvent('mousedown', { bubbles: true, cancelable: true }); + target.dispatchEvent(pressed); + expect(pressed.defaultPrevented).toBe(false); + } + }); + + it('adds the class from chipClassName to each chip', async () => { + host.value.set(['a', 'b']); + host.chipClassName.set((value) => (value === 'a' ? 'chip-primary' : 'chip-secondary')); + await fixture.whenStable(); + expect(chip('a').classList.contains('chip-primary')).toBe(true); + expect(chip('b').classList.contains('chip-secondary')).toBe(true); + expect(chip('a').classList.contains('chip')).toBe(true); + }); + + it('renders a normalized list without writing it back to the value', async () => { + host.value.set([' a', 'a', 'b ']); + await fixture.whenStable(); + expect(chips().map((item) => item.getAttribute('data-coreui-chip-value'))).toEqual(['a', 'b']); + expect(host.value()).toEqual([' a', 'a', 'b ']); + }); + + it('announces added and removed chips', async () => { + host.value.set(['a']); + await fixture.whenStable(); + type('x'); + press(field(), 'Enter'); + await fixture.whenStable(); + expect(announce).toHaveBeenCalledWith('x added', { context: element() }); + + host.value.set(['a']); + await fixture.whenStable(); + expect(announce).toHaveBeenCalledWith('x removed', { context: element() }); + }); + + it('has no hidden form input', async () => { + host.value.set(['a']); + await fixture.whenStable(); + expect(element().querySelector('input[type="hidden"]')).toBeNull(); + expect(element().querySelector('[name]')).toBeNull(); + }); +}); + +@Component({ + imports: [ChipInputComponent, ChipComponent], + template: `Stray` +}) +class StrayChipHostComponent {} + +describe('ChipInputComponent with a projected chip', () => { + beforeEach(() => { + TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()] }); + }); + + it('ignores the chip and warns', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const fixture = TestBed.createComponent(StrayChipHostComponent); + await fixture.whenStable(); + const element = fixture.nativeElement.querySelector('c-chip-input') as HTMLElement; + expect([...element.querySelectorAll('c-chip')].map((chip) => chip.getAttribute('data-coreui-chip-value'))).toEqual([ + 'a' + ]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('c-chip-input')); + warn.mockRestore(); + }); +}); + +@Component({ + imports: [ChipInputComponent], + template: ` + + + + Hint + ` +}) +class OptionsHostComponent { + readonly plain = signal([]); + readonly selected = signal(['a']); + readonly unlimited = signal([]); + readonly value = signal(['a']); +} + +describe('ChipInputComponent options', () => { + let fixture: ComponentFixture; + let announce: ReturnType; + const inputs = () => [...fixture.nativeElement.querySelectorAll('c-chip-input')] as HTMLElement[]; + const typeInto = (element: HTMLElement, text: string) => { + const field = element.querySelector('input')!; + field.value = text; + field.dispatchEvent(new Event('input', { bubbles: true })); + }; + + beforeEach(async () => { + TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()] }); + announce = vi.spyOn(TestBed.inject(AnnouncerService), 'announce').mockReturnValue(vi.fn()); + fixture = TestBed.createComponent(OptionsHostComponent); + await fixture.whenStable(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('passes size, chipClassName and filter to the chips', () => { + const [element] = inputs(); + const chip = element.querySelector('c-chip')!; + expect(element.classList.contains('chip-input-sm')).toBe(true); + expect(chip.classList.contains('chip-primary')).toBe(true); + expect(chip.getAttribute('role')).toBe('button'); + expect(chip.querySelector('.chip-check')).not.toBeNull(); + }); + + it('moves aria-label, aria-labelledby and aria-describedby from the host to the text field', () => { + const plain = inputs()[1]; + const field = plain.querySelector('input')!; + expect(field.getAttribute('aria-label')).toBe('Plain tags'); + expect(field.getAttribute('aria-labelledby')).toBe('plainHint'); + expect(field.getAttribute('aria-describedby')).toBe('plainHint'); + expect(plain.getAttribute('aria-label')).toBeNull(); + expect(plain.getAttribute('aria-labelledby')).toBeNull(); + expect(plain.getAttribute('aria-describedby')).toBeNull(); + }); + + it('splits on its own separator, and not at all without one', async () => { + const [element, plain] = inputs(); + typeInto(element, 'b;c'); + typeInto(plain, 'x,y'); + await fixture.whenStable(); + expect(fixture.componentInstance.value()).toEqual(['a', 'b']); + expect(fixture.componentInstance.plain()).toEqual([]); + expect(plain.querySelector('input')!.value).toBe('x,y'); + }); + + it('reads maxChips from a static attribute, and an empty one as no limit', async () => { + const [element, , unlimited] = inputs(); + const [limited, , open] = fixture.debugElement + .queryAll(By.directive(ChipInputComponent)) + .map((item) => item.componentInstance as ChipInputComponent); + expect(limited.maxChips()).toBe(2); + expect(open.maxChips()).toBeNull(); + typeInto(element, 'b;c;'); + typeInto(unlimited, 'x,y,z,'); + await fixture.whenStable(); + expect(fixture.componentInstance.value()).toEqual(['a', 'b']); + expect(fixture.componentInstance.unlimited()).toEqual(['x', 'y', 'z']); + }); + + it('announces with its own wording', async () => { + const [element] = inputs(); + typeInto(element, 'b;'); + await fixture.whenStable(); + expect(announce).toHaveBeenCalledWith('b dodano', { context: element }); + fixture.componentInstance.value.set(['b']); + await fixture.whenStable(); + expect(announce).toHaveBeenCalledWith('a usunięto', { context: element }); + }); +}); diff --git a/projects/coreui-angular/src/lib/chip-input/chip-input.component.ts b/projects/coreui-angular/src/lib/chip-input/chip-input.component.ts new file mode 100644 index 00000000..e86684fa --- /dev/null +++ b/projects/coreui-angular/src/lib/chip-input/chip-input.component.ts @@ -0,0 +1,549 @@ +import { + AfterContentInit, + booleanAttribute, + Component, + computed, + contentChildren, + ElementRef, + inject, + input, + model, + numberAttribute, + output, + signal, + viewChild +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AsyncValidator, NG_ASYNC_VALIDATORS, NG_VALIDATORS, NgModel, Validator } from '@angular/forms'; +import { take } from 'rxjs'; +import { ChipComponent } from '../chip/chip.component'; +import { ChipSetRef } from '../chip/chip-set-ref'; +import { ChipSetService } from '../chip-set/chip-set.service'; +import { RtlService } from '../services/rtl.service'; +import { UIDService } from '../services/uid.service'; + +@Component({ + selector: 'c-chip-input', + exportAs: 'cChipInput', + templateUrl: './chip-input.component.html', + imports: [ChipComponent], + providers: [ChipSetService, { provide: ChipSetRef, useExisting: ChipSetService }], + host: { + class: 'chip-input', + '[class]': 'hostClasses()', + '[attr.id]': 'null', + '[attr.aria-describedby]': 'null', + '[attr.aria-label]': 'null', + '[attr.aria-labelledby]': 'null', + '(click)': 'onClick($event)', + '(mousedown)': 'onMousedown($event)', + '(keydown)': 'onKeydown($event)', + '(focusin)': 'onFocusin($event)', + '(focusout)': 'onFocusout($event)' + } +}) +export class ChipInputComponent implements AfterContentInit { + readonly #elementRef = inject>(ElementRef); + readonly #ngModel = inject(NgModel, { self: true, optional: true }); + readonly #asyncValidators = inject<(AsyncValidator | unknown)[]>(NG_ASYNC_VALIDATORS, { self: true, optional: true }); + readonly #validators = inject<(Validator | unknown)[]>(NG_VALIDATORS, { self: true, optional: true }); + readonly #rtl = inject(RtlService); + readonly #service = inject(ChipSetService); + + readonly field = viewChild.required>('field'); + readonly projectedChips = contentChildren(ChipComponent); + + /** + * Wording announced to screen readers after a chip is added; the chip value is prepended. + * @returns string + * @default 'added' + */ + readonly ariaAddedAnnouncement = input('added'); + + /** + * Id of the element that describes the text field, e.g. a hint or a `c-form-feedback`. + * @returns string | undefined + * @default undefined + */ + readonly ariaDescribedBy = input(undefined, { alias: 'aria-describedby' }); + + /** + * Accessible name of the text field when there is no visible label. + * @returns string | undefined + * @default undefined + */ + readonly ariaLabel = input(undefined, { alias: 'aria-label' }); + + /** + * Id of the element that names the text field. + * @returns string | undefined + * @default undefined + */ + readonly ariaLabelledBy = input(undefined, { alias: 'aria-labelledby' }); + + /** + * Label of the remove buttons; each chip appends its value, e.g. `'Remove Angular'`. + * @returns string + * @default 'Remove' + */ + readonly ariaRemoveLabel = input('Remove'); + + /** + * Wording announced to screen readers after a chip is removed; the chip value is prepended. + * @returns string + * @default 'removed' + */ + readonly ariaRemovedAnnouncement = input('removed'); + + /** + * Extra class for every chip, or a function that returns the class for a chip value. + * @returns string | ((value: string) => string) | undefined + * @default undefined + */ + readonly chipClassName = input string)>(); + + /** + * Turns the text left in the field into a chip when focus leaves the component. + * @returns boolean + * @default true + */ + readonly createOnBlur = input(true, { transform: booleanAttribute }); + + /** + * Disables the field and every chip. + * @returns boolean + * @default false + */ + readonly disabled = input(false, { transform: booleanAttribute }); + + /** + * Makes the chips filter chips: selectable, with a check icon while selected. + * @returns boolean + * @default false + */ + readonly filter = input(false, { transform: booleanAttribute }); + + /** + * Id of the text field, the target of an external `