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 `