= {
+ ArrowLeft: 37,
+ ArrowRight: 39,
+ Backspace: 8,
+ Delete: 46,
+ End: 35,
+ Enter: 13,
+ Home: 36
+};
+
+const press = (target: Element, key: string) =>
+ target.dispatchEvent(new KeyboardEvent('keydown', { key, keyCode: KEY_CODES[key], bubbles: true, cancelable: true }));
+
+@Component({
+ imports: [ChipSetComponent, ChipComponent],
+ template: `
+
+
+ @for (chip of declared(); track chip.key) {
+ {{ chip.label ?? chip.value }}
+ }
+
+
+
+ ✓
+ ×
+ `
+})
+class ChipSetHostComponent {
+ readonly added = signal('added');
+ readonly chips = signal<(string | ChipItem)[] | undefined>(undefined);
+ readonly customIcons = signal(false);
+ readonly declared = signal<
+ { key: unknown; value?: string; label?: string; disabled?: boolean; removable?: boolean; selectable?: boolean }[]
+ >([]);
+ readonly dir = signal(null);
+ readonly disabled = signal(false);
+ readonly filter = signal(false);
+ readonly mode = signal('multiple');
+ readonly removable = signal(false);
+ readonly removedText = signal('removed');
+ readonly removeLabel = signal('Remove');
+ readonly selectable = signal(false);
+ readonly selected = signal([]);
+ readonly chipsChanges: (string | ChipItem)[][] = [];
+ readonly removed: string[] = [];
+
+ declare(...values: string[]) {
+ this.declared.set(values.map((value) => ({ key: {}, value })));
+ }
+}
+
+describe('ChipSetComponent', () => {
+ let fixture: ComponentFixture;
+ let host: ChipSetHostComponent;
+ let set: HTMLElement;
+ let announce: ReturnType;
+
+ const chips = () => [...set.querySelectorAll('c-chip')];
+ const chip = (text: string) => chips().find((element) => element.textContent?.includes(text))!;
+ const stops = () => chips().filter((element) => element.getAttribute('tabindex') === '0');
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ChipSetHostComponent],
+ providers: [provideZonelessChangeDetection()]
+ }).compileComponents();
+ announce = vi.spyOn(TestBed.inject(AnnouncerService), 'announce').mockReturnValue(vi.fn());
+ fixture = TestBed.createComponent(ChipSetHostComponent);
+ host = fixture.componentInstance;
+ set = fixture.nativeElement.querySelector('c-chip-set');
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('loads and displays ChipSet component', async () => {
+ host.declare('a', 'b');
+ await fixture.whenStable();
+ expect(set.classList.contains('chip-set')).toBe(true);
+ expect(set.getAttribute('role')).toBe('group');
+ expect(chips().length).toBe(2);
+ });
+
+ it('ChipSet passes selectable down to its chips', async () => {
+ host.selectable.set(true);
+ host.declare('a', 'b');
+ await fixture.whenStable();
+ expect(set.getAttribute('role')).toBe('listbox');
+ expect(set.getAttribute('aria-orientation')).toBe('horizontal');
+ expect(set.getAttribute('aria-multiselectable')).toBe('true');
+ chips().forEach((element) => {
+ expect(element.getAttribute('role')).toBe('option');
+ expect(element.getAttribute('aria-selected')).toBe('false');
+ });
+ });
+
+ it('ChipSet allows multiple selected chips by default', async () => {
+ host.selectable.set(true);
+ host.declare('a', 'b', 'c');
+ await fixture.whenStable();
+ chip('a').click();
+ chip('c').click();
+ await fixture.whenStable();
+ expect(host.selected()).toEqual(['a', 'c']);
+ expect(chip('a').getAttribute('aria-selected')).toBe('true');
+ expect(chip('b').getAttribute('aria-selected')).toBe('false');
+ });
+
+ it('ChipSet deselects siblings in single selection mode', async () => {
+ host.selectable.set(true);
+ host.mode.set('single');
+ host.declare('a', 'b');
+ await fixture.whenStable();
+ expect(set.getAttribute('aria-multiselectable')).toBeNull();
+ chip('a').click();
+ chip('b').click();
+ await fixture.whenStable();
+ expect(host.selected()).toEqual(['b']);
+ expect(chip('a').getAttribute('aria-selected')).toBe('false');
+ expect(chip('b').getAttribute('aria-selected')).toBe('true');
+ });
+
+ it('ChipSet honors a controlled selected', async () => {
+ host.selectable.set(true);
+ host.selected.set(['b']);
+ host.declare('a', 'b');
+ await fixture.whenStable();
+ expect(chip('b').classList.contains('active')).toBe(true);
+ expect(chip('a').classList.contains('active')).toBe(false);
+ });
+
+ it('ChipSet forwards filter so a selected chip shows a check icon', async () => {
+ host.filter.set(true);
+ host.selected.set(['a']);
+ host.declare('a', 'b');
+ await fixture.whenStable();
+ expect(chip('a').querySelector('.chip-check')).not.toBeNull();
+ expect(chip('b').querySelector('.chip-check')).toBeNull();
+ });
+
+ it('ChipSet moves focus between chips with the keyboard', async () => {
+ host.selectable.set(true);
+ host.declare('a', 'b', 'c');
+ await fixture.whenStable();
+ chip('a').focus();
+ press(chip('a'), 'ArrowRight');
+ expect(document.activeElement).toBe(chip('b'));
+ press(chip('b'), 'End');
+ expect(document.activeElement).toBe(chip('c'));
+ press(chip('c'), 'ArrowRight');
+ expect(document.activeElement).toBe(chip('c'));
+ press(chip('c'), 'Home');
+ expect(document.activeElement).toBe(chip('a'));
+ press(chip('a'), 'ArrowLeft');
+ expect(document.activeElement).toBe(chip('a'));
+ });
+
+ it('ChipSet mirrors arrow keys in RTL', async () => {
+ host.selectable.set(true);
+ host.dir.set('rtl');
+ host.declare('a', 'b');
+ await fixture.whenStable();
+ chip('a').focus();
+ press(chip('a'), 'ArrowLeft');
+ expect(document.activeElement).toBe(chip('b'));
+ press(chip('b'), 'ArrowRight');
+ expect(document.activeElement).toBe(chip('a'));
+ });
+
+ it('ChipSet fires remove so the parent can drop the chip', async () => {
+ host.removable.set(true);
+ host.chips.set(['a', 'b']);
+ await fixture.whenStable();
+ chip('a').querySelector('.chip-remove')!.click();
+ await fixture.whenStable();
+ expect(host.removed).toEqual(['a']);
+ expect(host.chipsChanges).toEqual([['b']]);
+ expect(chips().length).toBe(2);
+ });
+
+ it('ChipSet renders chips from the chips input (strings and objects)', async () => {
+ host.chips.set(['a', { value: 'ng', label: 'Angular', color: 'primary' }, 'a']);
+ await fixture.whenStable();
+ expect(chips().map((element) => element.textContent?.trim())).toEqual(['a', 'Angular']);
+ expect(chip('Angular').getAttribute('data-coreui-chip-value')).toBe('ng');
+ expect(chip('Angular').classList.contains('chip-primary')).toBe(true);
+ });
+
+ it('ChipSet disables every chip', async () => {
+ host.selectable.set(true);
+ host.removable.set(true);
+ host.disabled.set(true);
+ host.declare('a', 'b');
+ await fixture.whenStable();
+ expect(set.classList.contains('disabled')).toBe(true);
+ expect(set.getAttribute('aria-disabled')).toBe('true');
+ chips().forEach((element) => {
+ expect(element.classList.contains('disabled')).toBe(true);
+ expect(element.getAttribute('aria-disabled')).toBe('true');
+ expect(element.querySelector('.chip-remove')).toBeNull();
+ });
+ expect(stops().length).toBe(0);
+ });
+
+ it('lets a chip opt out of the settings of the set', async () => {
+ host.removable.set(true);
+ host.disabled.set(true);
+ host.declared.set([
+ { key: 1, value: 'a', disabled: false },
+ { key: 2, value: 'b' }
+ ]);
+ await fixture.whenStable();
+ expect(chip('a').classList.contains('disabled')).toBe(false);
+ expect(chip('a').querySelector('.chip-remove')).not.toBeNull();
+ expect(chip('b').classList.contains('disabled')).toBe(true);
+
+ host.disabled.set(false);
+ host.declared.set([{ key: 4, value: 'd', removable: false }]);
+ await fixture.whenStable();
+ expect(chip('d').querySelector('.chip-remove')).toBeNull();
+
+ host.declared.set([{ key: 3, value: 'c', selectable: false }]);
+ host.selectable.set(true);
+ await fixture.whenStable();
+ chip('c').click();
+ await fixture.whenStable();
+ expect(host.selected()).toEqual([]);
+ });
+
+ it('forwards custom remove and check icons to its chips', async () => {
+ host.filter.set(true);
+ host.removable.set(true);
+ host.customIcons.set(true);
+ host.selected.set(['a']);
+ host.declare('a');
+ await fixture.whenStable();
+ expect(chip('a').querySelector('.chip-check .custom-check')).not.toBeNull();
+ expect(chip('a').querySelector('.chip-remove .custom-remove')).not.toBeNull();
+ });
+
+ it('keeps one tab stop that follows the keyboard and the mouse', async () => {
+ host.removable.set(true);
+ host.declare('a', 'b', 'c');
+ await fixture.whenStable();
+ expect(stops()).toEqual([chip('a')]);
+ expect(chip('b').getAttribute('tabindex')).toBe('-1');
+
+ chip('a').focus();
+ press(chip('a'), 'ArrowRight');
+ await fixture.whenStable();
+ expect(stops()).toEqual([chip('b')]);
+
+ chip('c').focus();
+ await fixture.whenStable();
+ expect(stops()).toEqual([chip('c')]);
+ });
+
+ it('moves the tab stop as soon as focus moves, before the next render', async () => {
+ host.removable.set(true);
+ host.declare('a', 'b', 'c');
+ await fixture.whenStable();
+ chip('a').focus();
+ press(chip('a'), 'ArrowRight');
+ expect(chip('b').getAttribute('tabindex')).toBe('0');
+ expect(chip('a').getAttribute('tabindex')).toBe('-1');
+ });
+
+ it('rewrites only the old and the new tab stop when focus moves', async () => {
+ host.removable.set(true);
+ host.declare('a', 'b', 'c', 'd', 'e');
+ await fixture.whenStable();
+ chip('a').focus();
+ const setAttribute = vi.spyOn(Element.prototype, 'setAttribute');
+ press(chip('a'), 'ArrowRight');
+ const writes = setAttribute.mock.calls.filter(([name]) => name === 'tabindex').length;
+ setAttribute.mockRestore();
+ expect(writes).toBeLessThanOrEqual(2);
+ expect(stops()).toEqual([chip('b')]);
+ });
+
+ it('keeps the tab stop when focus lands inside a chip that is not a stop', async () => {
+ host.declared.set([
+ { key: 1, value: 'a', selectable: true },
+ { key: 2, value: 'b' },
+ { key: 3, value: 'c', selectable: true }
+ ]);
+ await fixture.whenStable();
+ expect(stops()).toEqual([chip('a')]);
+ chip('b').dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
+ await fixture.whenStable();
+ expect(stops()).toEqual([chip('a')]);
+ });
+
+ it('renders one tab stop on the server, where no refresh runs', async () => {
+ const scope = globalThis as { ngServerMode?: boolean };
+ scope.ngServerMode = true;
+ try {
+ const server = TestBed.createComponent(ChipSetHostComponent);
+ server.componentInstance.removable.set(true);
+ server.componentInstance.declare('a', 'b');
+ server.detectChanges();
+ const tabindexes = [...server.nativeElement.querySelectorAll('c-chip')].map((element: Element) =>
+ element.getAttribute('tabindex')
+ );
+ expect(tabindexes).toEqual(['0', '-1']);
+ } finally {
+ delete scope.ngServerMode;
+ }
+ });
+
+ it('renders one tab stop on the server when data and projected chips mix', async () => {
+ const scope = globalThis as { ngServerMode?: boolean };
+ scope.ngServerMode = true;
+ try {
+ const server = TestBed.createComponent(ChipSetHostComponent);
+ server.componentInstance.selectable.set(true);
+ server.componentInstance.selected.set(['b']);
+ server.componentInstance.chips.set(['a', 'b']);
+ server.componentInstance.declare('c');
+ server.detectChanges();
+ expect(server.nativeElement.querySelectorAll('c-chip[tabindex="0"]').length).toBe(1);
+ } finally {
+ delete scope.ngServerMode;
+ }
+ });
+
+ it('takes focus that arrives before the first refresh as the tab stop', async () => {
+ const scope = globalThis as { ngServerMode?: boolean };
+ scope.ngServerMode = true;
+ try {
+ const early = TestBed.createComponent(ChipSetHostComponent);
+ early.componentInstance.removable.set(true);
+ early.componentInstance.declare('a', 'b');
+ early.detectChanges();
+ const [a, b] = early.nativeElement.querySelectorAll('c-chip') as NodeListOf;
+ b.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
+ expect(b.getAttribute('tabindex')).toBe('0');
+ expect(a.getAttribute('tabindex')).toBe('-1');
+ } finally {
+ delete scope.ngServerMode;
+ }
+ });
+
+ it('starts the tab stop on the first selected chip', async () => {
+ host.selectable.set(true);
+ host.selected.set(['b']);
+ host.declare('a', 'b');
+ await fixture.whenStable();
+ expect(stops()).toEqual([chip('b')]);
+ });
+
+ it('a group labels remove buttons with the chip text, a listbox hides them', async () => {
+ host.removable.set(true);
+ host.chips.set([{ value: 'ng', label: 'Angular' }]);
+ await fixture.whenStable();
+ expect(chip('Angular').getAttribute('role')).toBeNull();
+ expect(chip('Angular').querySelector('button.chip-remove')?.getAttribute('aria-label')).toBe('Remove Angular');
+
+ host.removeLabel.set('Usuń');
+ await fixture.whenStable();
+ expect(chip('Angular').querySelector('button.chip-remove')?.getAttribute('aria-label')).toBe('Usuń Angular');
+
+ host.selectable.set(true);
+ await fixture.whenStable();
+ expect(chip('Angular').querySelector('button')).toBeNull();
+ expect(chip('Angular').querySelector('span.chip-remove')?.getAttribute('aria-hidden')).toBe('true');
+ });
+
+ it('a chip selectable on its own in a group is a coordinated toggle button', async () => {
+ host.declared.set([
+ { key: 1, value: 'a', selectable: true },
+ { key: 2, value: 'b' }
+ ]);
+ await fixture.whenStable();
+ expect(set.getAttribute('role')).toBe('group');
+ expect(chip('a').getAttribute('role')).toBe('button');
+ expect(chip('a').getAttribute('aria-pressed')).toBe('false');
+ expect(chip('b').getAttribute('role')).toBeNull();
+ chip('a').click();
+ await fixture.whenStable();
+ expect(host.selected()).toEqual(['a']);
+ expect(chip('a').getAttribute('aria-pressed')).toBe('true');
+
+ host.removable.set(true);
+ await fixture.whenStable();
+ expect(chip('a').getAttribute('aria-label')).toBe('a');
+ expect(chip('a').querySelector('button.chip-remove')?.getAttribute('aria-label')).toBe('Remove a');
+ expect(chip('b').getAttribute('aria-label')).toBeNull();
+ expect(chip('b').querySelector('button.chip-remove')?.getAttribute('aria-label')).toBe('Remove b');
+ });
+
+ it('does not touch the chips list when a projected chip is removed', async () => {
+ host.removable.set(true);
+ host.chips.set(['x']);
+ host.declare('a');
+ await fixture.whenStable();
+ press(chip('a'), 'Backspace');
+ await fixture.whenStable();
+ expect(host.removed).toEqual(['a']);
+ expect(host.chipsChanges).toEqual([]);
+ });
+
+ it('moves focus to the neighbor once the parent removes the focused chip', async () => {
+ host.removable.set(true);
+ host.selectable.set(true);
+ host.selected.set(['b']);
+ host.chips.set(['a', 'b', 'c']);
+ await fixture.whenStable();
+ chip('b').focus();
+ press(chip('b'), 'Delete');
+ await fixture.whenStable();
+ expect(document.activeElement).toBe(chip('b'));
+
+ host.chips.set(host.chipsChanges.at(-1)!);
+ await fixture.whenStable();
+ expect(document.activeElement).toBe(chip('c'));
+ expect(stops()).toEqual([chip('c')]);
+ expect(host.selected()).toEqual(['b']);
+ expect(announce).toHaveBeenCalledWith('b removed', { context: set });
+ });
+
+ it('keeps the selection of a chip the parent only hides', async () => {
+ host.selectable.set(true);
+ host.selected.set(['b']);
+ host.chips.set(['a', 'b']);
+ await fixture.whenStable();
+ host.chips.set(['a']);
+ await fixture.whenStable();
+ expect(host.selected()).toEqual(['b']);
+
+ host.chips.set(['a', 'b']);
+ await fixture.whenStable();
+ expect(chip('b').getAttribute('aria-selected')).toBe('true');
+ });
+
+ it('follows a chip label that changes after the first render', async () => {
+ host.removable.set(true);
+ host.chips.set([{ value: 'apple', label: 'Apple' }]);
+ await fixture.whenStable();
+ host.chips.set([{ value: 'apple', label: 'Jabłko' }]);
+ await fixture.whenStable();
+ expect(chip('Jabłko').querySelector('button.chip-remove')?.getAttribute('aria-label')).toBe('Remove Jabłko');
+
+ host.chips.set([]);
+ await fixture.whenStable();
+ expect(announce).toHaveBeenCalledWith('Jabłko removed', { context: set });
+ });
+
+ it('moves focus to the previous chip once the parent removes the last one', async () => {
+ host.removable.set(true);
+ host.chips.set(['a', 'b', 'c']);
+ await fixture.whenStable();
+ chip('c').focus();
+ host.chips.set(['a', 'b']);
+ await fixture.whenStable();
+ expect(document.activeElement).toBe(chip('b'));
+ });
+
+ it('moves focus to the neighbor, not to a chip with the same text further on', async () => {
+ host.removable.set(true);
+ host.declared.set([
+ { key: 1, label: 'x' },
+ { key: 2, label: 'y' },
+ { key: 3, label: 'x' }
+ ]);
+ await fixture.whenStable();
+ chips()[0].focus();
+ host.declared.update((declared) => declared.slice(1));
+ await fixture.whenStable();
+ expect(document.activeElement).toBe(chip('y'));
+ });
+
+ it('moves focus to the neighbor when the chip goes while the window has no focus', async () => {
+ host.removable.set(true);
+ host.chips.set(['a', 'b', 'c']);
+ await fixture.whenStable();
+ chip('b').focus();
+ const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(false);
+ chip('b').dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
+ await Promise.resolve();
+ hasFocus.mockRestore();
+
+ host.chips.set(['a', 'c']);
+ await fixture.whenStable();
+ expect(document.activeElement).toBe(chip('c'));
+ });
+
+ it('focuses the set when its last chip is removed', async () => {
+ host.removable.set(true);
+ host.chips.set(['a']);
+ await fixture.whenStable();
+ chip('a').focus();
+ host.chips.set([]);
+ await fixture.whenStable();
+ expect(document.activeElement).toBe(set);
+ expect(set.getAttribute('tabindex')).toBe('-1');
+
+ const outside = fixture.nativeElement.querySelector('#outside') as HTMLElement;
+ set.dispatchEvent(new FocusEvent('focusout', { relatedTarget: outside, bubbles: true }));
+ outside.focus();
+ await fixture.whenStable();
+ expect(set.getAttribute('tabindex')).toBeNull();
+ });
+
+ it('does not steal focus when a chip without focus is removed', async () => {
+ host.removable.set(true);
+ host.chips.set(['a', 'b']);
+ await fixture.whenStable();
+ const outside = fixture.nativeElement.querySelector('#outside') as HTMLElement;
+ outside.focus();
+ host.chips.set(['b']);
+ await fixture.whenStable();
+ expect(document.activeElement).toBe(outside);
+ });
+
+ it('keeps selection, focus and silence when the parent re-creates the chips', async () => {
+ host.selectable.set(true);
+ host.selected.set(['b']);
+ host.declare('a', 'b');
+ await fixture.whenStable();
+ chip('b').focus();
+ announce.mockClear();
+
+ host.declare('a', 'b');
+ await fixture.whenStable();
+ expect(host.selected()).toEqual(['b']);
+ expect(document.activeElement).toBe(chip('b'));
+ expect(stops()).toEqual([chip('b')]);
+ expect(announce).not.toHaveBeenCalled();
+ });
+
+ it('announces added chips by their text, and stays silent on the first render', async () => {
+ host.chips.set(['a']);
+ await fixture.whenStable();
+ expect(announce).not.toHaveBeenCalled();
+
+ host.chips.set(['a', { value: 'ng', label: 'Angular' }]);
+ await fixture.whenStable();
+ expect(announce).toHaveBeenCalledWith('Angular added', { context: set });
+ });
+
+ it('announces the right chip after another chip changed its text', async () => {
+ host.removable.set(true);
+ host.declared.set([
+ { key: 1, label: 'Apple' },
+ { key: 2, label: 'Banana' }
+ ]);
+ await fixture.whenStable();
+ host.declared.set([
+ { key: 1, label: 'Apfel' },
+ { key: 2, label: 'Banana' }
+ ]);
+ await fixture.whenStable();
+ host.declared.set([{ key: 1, label: 'Apfel' }]);
+ await fixture.whenStable();
+ expect(announce).toHaveBeenCalledWith('Banana removed', { context: set });
+ expect(announce).not.toHaveBeenCalledWith('Apfel added', { context: set });
+ });
+
+ it('announces the first chip added to an empty set', async () => {
+ await fixture.whenStable();
+ host.chips.set(['Angular']);
+ await fixture.whenStable();
+ expect(announce).toHaveBeenCalledWith('Angular added', { context: set });
+ });
+
+ it('announces with the wording set on the set', async () => {
+ host.added.set('dodano');
+ host.removedText.set('usunięto');
+ host.chips.set(['a']);
+ await fixture.whenStable();
+ host.chips.set(['a', 'b']);
+ await fixture.whenStable();
+ expect(announce).toHaveBeenCalledWith('b dodano', { context: set });
+ host.chips.set(['b']);
+ await fixture.whenStable();
+ expect(announce).toHaveBeenCalledWith('a usunięto', { context: set });
+ });
+
+ it('ignores tabindex on a chip inside a set', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(vi.fn());
+ @Component({
+ imports: [ChipSetComponent, ChipComponent],
+ template: `ab`
+ })
+ class TabindexHostComponent {}
+ const tabFixture = TestBed.createComponent(TabindexHostComponent);
+ await tabFixture.whenStable();
+ const tabbable = [...tabFixture.nativeElement.querySelectorAll('c-chip')].filter(
+ (element: HTMLElement) => element.getAttribute('tabindex') === '0'
+ );
+ expect(tabbable.length).toBe(1);
+ expect(warn).toHaveBeenCalled();
+ });
+});
+
+@Component({
+ imports: [ChipSetComponent],
+ template: ``
+})
+class TwoWayChipsHostComponent {
+ readonly list = signal(['a', 'b']);
+}
+
+describe('ChipSetComponent with [(chips)]', () => {
+ beforeEach(() => {
+ TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()] });
+ });
+
+ it('removes the chip through the two-way binding', async () => {
+ const fixture = TestBed.createComponent(TwoWayChipsHostComponent);
+ await fixture.whenStable();
+ const set = fixture.nativeElement.querySelector('c-chip-set') as HTMLElement;
+ set.querySelector('c-chip .chip-remove')!.click();
+ await fixture.whenStable();
+ expect(fixture.componentInstance.list()).toEqual(['b']);
+ expect(set.querySelectorAll('c-chip').length).toBe(1);
+ });
+});
+
+@Component({
+ imports: [ChipSetComponent],
+ template: ``
+})
+class OneWaySelectedHostComponent {
+ readonly selected = signal([]);
+}
+
+describe('ChipSetComponent with one-way [selected]', () => {
+ beforeEach(() => {
+ TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()] });
+ });
+
+ it('applies new values from the parent but does not write back', async () => {
+ const fixture = TestBed.createComponent(OneWaySelectedHostComponent);
+ await fixture.whenStable();
+ const [a, b] = fixture.nativeElement.querySelectorAll('c-chip') as NodeListOf;
+ a.click();
+ await fixture.whenStable();
+ expect(a.getAttribute('aria-selected')).toBe('true');
+ expect(fixture.componentInstance.selected()).toEqual([]);
+
+ fixture.componentInstance.selected.set(['b']);
+ await fixture.whenStable();
+ expect(a.getAttribute('aria-selected')).toBe('false');
+ expect(b.getAttribute('aria-selected')).toBe('true');
+ });
+});
diff --git a/projects/coreui-angular/src/lib/chip-set/chip-set.component.ts b/projects/coreui-angular/src/lib/chip-set/chip-set.component.ts
new file mode 100644
index 00000000..c5e9596d
--- /dev/null
+++ b/projects/coreui-angular/src/lib/chip-set/chip-set.component.ts
@@ -0,0 +1,229 @@
+import {
+ booleanAttribute,
+ Component,
+ computed,
+ ElementRef,
+ inject,
+ input,
+ model,
+ output,
+ signal,
+ TemplateRef
+} from '@angular/core';
+import { ChipComponent } from '../chip/chip.component';
+import { ChipSetRef } from '../chip/chip-set-ref';
+import { optionalNumberAttribute } from '../chip/chip.utils';
+import { ChipSetService } from './chip-set.service';
+import { ChipItem, ChipSelectionMode } from './chip-set.types';
+
+@Component({
+ selector: 'c-chip-set',
+ exportAs: 'cChipSet',
+ templateUrl: './chip-set.component.html',
+ imports: [ChipComponent],
+ providers: [ChipSetService, { provide: ChipSetRef, useExisting: ChipSetService }],
+ host: {
+ class: 'chip-set',
+ '[class.disabled]': 'disabled()',
+ '[attr.role]': 'roleAttr()',
+ '[attr.aria-orientation]': 'isListbox() ? "horizontal" : null',
+ '[attr.aria-multiselectable]': 'isListbox() && selectionMode() === "multiple" ? "true" : null',
+ '[attr.aria-disabled]': 'ariaDisabled()',
+ '[attr.tabindex]': 'tabIndex()',
+ '(keydown)': 'onKeydown($event)',
+ '(focusin)': 'onFocusin($event)',
+ '(focusout)': 'onFocusout($event)'
+ }
+})
+export class ChipSetComponent {
+ readonly #elementRef = inject>(ElementRef);
+ readonly #service = inject(ChipSetService);
+
+ /**
+ * Wording announced to screen readers after a chip is added; the chip text is prepended.
+ * @returns string
+ * @default 'added'
+ */
+ readonly ariaAddedAnnouncement = input('added');
+
+ /**
+ * Label of the remove buttons; each chip appends its text, e.g. `'Remove Angular'`.
+ * Chips in a selectable set have no remove buttons for assistive technology: removal runs from the chip itself.
+ * @returns string
+ * @default 'Remove'
+ */
+ readonly ariaRemoveLabel = input('Remove');
+
+ /**
+ * Wording announced to screen readers after a chip is removed; the chip text is prepended.
+ * @returns string
+ * @default 'removed'
+ */
+ readonly ariaRemovedAnnouncement = input('removed');
+
+ /**
+ * Chips rendered from data, as values or `ChipItem` objects. The set never changes the list itself:
+ * it emits `chipsChange` without the removed chip, so `[(chips)]` removes it and `[chips]` with `(remove)` lets
+ * the parent decide.
+ * @returns (string | ChipItem)[] | undefined
+ * @default undefined
+ */
+ readonly chips = input<(string | ChipItem)[]>();
+
+ /**
+ * Disables the set and every chip that does not set `disabled` itself.
+ * @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 });
+
+ /**
+ * Shows remove buttons on the chips.
+ * @returns boolean
+ * @default false
+ */
+ readonly removable = input(false, { transform: booleanAttribute });
+
+ /**
+ * Replaces the default remove icon of the chips.
+ * @returns TemplateRef | undefined
+ * @default undefined
+ */
+ readonly removeIcon = input>();
+
+ /**
+ * Overrides the role of the set: `listbox` when selectable, otherwise `group`.
+ * A selectable set needs an accessible name, e.g. `aria-label` or `aria-labelledby`.
+ * @returns string | undefined
+ * @default undefined
+ */
+ readonly role = input();
+
+ /**
+ * Makes the chips selectable.
+ * @returns boolean
+ * @default false
+ */
+ readonly selectable = input(false, { transform: booleanAttribute });
+
+ /**
+ * Values of the selected chips, two-way bindable with `[(selected)]`. A one-way `[selected]` applies every new value
+ * from the parent, but the parent does not learn about selection changes made by the user.
+ * @returns string[]
+ * @default []
+ */
+ readonly selected = model([]);
+
+ /**
+ * Replaces the default check icon of the selected filter chips.
+ * @returns TemplateRef | undefined
+ * @default undefined
+ */
+ readonly selectedIcon = input>();
+
+ /**
+ * Sets how many chips can be selected at once.
+ * @returns 'single' | 'multiple'
+ * @default 'multiple'
+ */
+ readonly selectionMode = input('multiple');
+
+ /**
+ * Tab index of the set host. Without it the set takes `-1` only while it holds focus after its last chip is removed.
+ * @returns number | undefined
+ * @default undefined
+ */
+ readonly tabindex = input(undefined, { transform: optionalNumberAttribute });
+
+ /**
+ * Emits the chip list without the chip whose removal was requested, for `[(chips)]`.
+ * @returns (string | ChipItem)[]
+ */
+ readonly chipsChange = output<(string | ChipItem)[]>();
+
+ /**
+ * Emits the value of the chip whose removal was requested.
+ * @returns string
+ */
+ readonly remove = output();
+
+ readonly #holdsFallbackFocus = signal(false);
+
+ readonly items = computed(() => {
+ const seen = new Set();
+ return (this.chips() ?? [])
+ .map((chip) => (typeof chip === 'string' ? { value: chip } : chip))
+ .filter((chip) => !seen.has(chip.value) && !!seen.add(chip.value));
+ });
+
+ readonly isListbox = computed(() => this.roleAttr() === 'listbox');
+
+ readonly roleAttr = computed(() => this.role() ?? (this.selectable() || this.filter() ? 'listbox' : 'group'));
+
+ readonly ariaDisabled = computed(() => {
+ const role = this.roleAttr();
+ return this.disabled() && role !== 'none' && role !== 'presentation' ? 'true' : null;
+ });
+
+ readonly tabIndex = computed(() => this.tabindex() ?? (this.#holdsFallbackFocus() ? -1 : null));
+
+ constructor() {
+ this.#service.attach({
+ ariaAddedAnnouncement: this.ariaAddedAnnouncement,
+ ariaRemoveLabel: this.ariaRemoveLabel,
+ ariaRemovedAnnouncement: this.ariaRemovedAnnouncement,
+ disabled: this.disabled,
+ element: this.#elementRef.nativeElement,
+ filter: this.filter,
+ initialStop: 'first',
+ isListbox: this.isListbox,
+ removable: this.removable,
+ removeIcon: this.removeIcon,
+ selectable: this.selectable,
+ selected: this.selected,
+ selectedIcon: this.selectedIcon,
+ selectionMode: this.selectionMode,
+ focusFallback: () => this.#focusHost(),
+ removeRequested: (identity) => this.#removeRequested(identity)
+ });
+ }
+
+ onKeydown($event: KeyboardEvent): void {
+ this.#service.handleKeydown($event);
+ }
+
+ onFocusin($event: FocusEvent): void {
+ this.#service.handleFocusin($event);
+ }
+
+ onFocusout($event: FocusEvent): void {
+ this.#service.handleFocusout($event);
+ const next = $event.relatedTarget as Node | null;
+ if (!next || !this.#elementRef.nativeElement.contains(next)) {
+ this.#holdsFallbackFocus.set(false);
+ }
+ }
+
+ #focusHost(): void {
+ const element = this.#elementRef.nativeElement;
+ this.#holdsFallbackFocus.set(true);
+ element.setAttribute('tabindex', String(this.tabindex() ?? -1));
+ element.focus();
+ }
+
+ #removeRequested(identity: string): void {
+ this.remove.emit(identity);
+ const chips = this.chips();
+ const next = chips?.filter((chip) => (typeof chip === 'string' ? chip : chip.value) !== identity);
+ if (next && next.length !== chips?.length) {
+ this.chipsChange.emit(next);
+ }
+ }
+}
diff --git a/projects/coreui-angular/src/lib/chip-set/chip-set.module.ts b/projects/coreui-angular/src/lib/chip-set/chip-set.module.ts
new file mode 100644
index 00000000..83b7bc0c
--- /dev/null
+++ b/projects/coreui-angular/src/lib/chip-set/chip-set.module.ts
@@ -0,0 +1,9 @@
+import { NgModule } from '@angular/core';
+import { ChipComponent } from '../chip/chip.component';
+import { ChipSetComponent } from './chip-set.component';
+
+@NgModule({
+ imports: [ChipComponent, ChipSetComponent],
+ exports: [ChipComponent, ChipSetComponent]
+})
+export class ChipSetModule {}
diff --git a/projects/coreui-angular/src/lib/chip-set/chip-set.service.spec.ts b/projects/coreui-angular/src/lib/chip-set/chip-set.service.spec.ts
new file mode 100644
index 00000000..1630a574
--- /dev/null
+++ b/projects/coreui-angular/src/lib/chip-set/chip-set.service.spec.ts
@@ -0,0 +1,66 @@
+import { signal } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import type { ChipComponent } from '../chip/chip.component';
+import { ChipSetService, ChipSetHost } from './chip-set.service';
+import { ChipSelectionMode } from './chip-set.types';
+
+describe('ChipSetService', () => {
+ let service: ChipSetService;
+ const selected = signal([]);
+ const selectionMode = signal('multiple');
+ const chip = (identity: string) => ({ identity: () => identity }) as ChipComponent;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({ providers: [ChipSetService] });
+ service = TestBed.inject(ChipSetService);
+ selected.set([]);
+ selectionMode.set('multiple');
+ service.attach({
+ ariaAddedAnnouncement: signal('added'),
+ ariaRemoveLabel: signal('Remove'),
+ ariaRemovedAnnouncement: signal('removed'),
+ disabled: signal(false),
+ element: document.createElement('div'),
+ filter: signal(false),
+ initialStop: 'first',
+ isListbox: signal(true),
+ removable: signal(false),
+ removeIcon: signal(undefined),
+ selectable: signal(true),
+ selected,
+ selectedIcon: signal(undefined),
+ selectionMode,
+ focusFallback: vi.fn(),
+ removeRequested: vi.fn()
+ } satisfies ChipSetHost);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('exposes the options of the set to its chips', () => {
+ expect(service.isListbox()).toBe(true);
+ expect(service.selectable()).toBe(true);
+ expect(service.ariaRemoveLabel()).toBe('Remove');
+ });
+
+ it('toggles values in multiple mode', () => {
+ service.toggle(chip('a'), true);
+ service.toggle(chip('b'), true);
+ service.toggle(chip('a'), false);
+ expect(selected()).toEqual(['b']);
+ expect(service.isSelected('b')).toBe(true);
+ });
+
+ it('keeps only the newest value in single mode', () => {
+ selectionMode.set('single');
+ service.toggle(chip('a'), true);
+ service.toggle(chip('b'), true);
+ expect(selected()).toEqual(['b']);
+ });
+
+ it('gives no tab index to a chip it does not hold', () => {
+ expect(service.tabIndexFor(chip('a'))).toBeNull();
+ });
+});
diff --git a/projects/coreui-angular/src/lib/chip-set/chip-set.service.ts b/projects/coreui-angular/src/lib/chip-set/chip-set.service.ts
new file mode 100644
index 00000000..b5d6fc3c
--- /dev/null
+++ b/projects/coreui-angular/src/lib/chip-set/chip-set.service.ts
@@ -0,0 +1,372 @@
+import { FocusKeyManager } from '@angular/cdk/a11y';
+import {
+ afterNextRender,
+ computed,
+ DestroyRef,
+ inject,
+ Injectable,
+ Injector,
+ Signal,
+ signal,
+ TemplateRef,
+ untracked,
+ WritableSignal
+} from '@angular/core';
+import type { ChipComponent } from '../chip/chip.component';
+import { ChipSetRef } from '../chip/chip-set-ref';
+import { AnnouncerService } from '../services/announcer.service';
+import { RtlService } from '../services/rtl.service';
+import { ChipSelectionMode } from './chip-set.types';
+
+export interface ChipSetHost {
+ readonly ariaAddedAnnouncement: Signal;
+ readonly ariaRemoveLabel: Signal;
+ readonly ariaRemovedAnnouncement: Signal;
+ readonly disabled: Signal;
+ readonly element: HTMLElement;
+ readonly filter: Signal;
+ readonly initialStop: 'first' | 'last';
+ readonly isListbox: Signal;
+ readonly removable: Signal;
+ readonly removeIcon: Signal | undefined>;
+ readonly selectable: Signal;
+ readonly selected: WritableSignal;
+ readonly selectedIcon: Signal | undefined>;
+ readonly selectionMode: Signal;
+
+ focusFallback(): void;
+
+ removeRequested(identity: string): void;
+}
+
+const NAVIGATION_KEYS = ['ArrowLeft', 'ArrowRight', 'Home', 'End'];
+
+@Injectable()
+export class ChipSetService extends ChipSetRef {
+ readonly #announcer = inject(AnnouncerService);
+ readonly #destroyRef = inject(DestroyRef);
+ readonly #injector = inject(Injector);
+ readonly #rtl = inject(RtlService);
+
+ readonly #host = signal(null);
+ readonly #registry = new Set();
+ readonly #warned = new WeakSet();
+ #focusedItem: ChipComponent | null = null;
+ #scheduled = false;
+ #settled = false;
+ #snapshot: ChipComponent[] = [];
+
+ readonly ariaRemoveLabel = computed(() => this.#host()?.ariaRemoveLabel());
+ readonly disabled = computed(() => this.#host()?.disabled() ?? false);
+ readonly filter = computed(() => this.#host()?.filter() ?? false);
+ readonly isListbox = computed(() => this.#host()?.isListbox() ?? false);
+ readonly removable = computed(() => this.#host()?.removable() ?? false);
+ readonly removeIcon = computed(() => this.#host()?.removeIcon());
+ readonly selectable = computed(() => this.#host()?.selectable() ?? false);
+ readonly selectedIcon = computed(() => this.#host()?.selectedIcon());
+
+ readonly orderedItems = signal([]);
+ readonly activeItem = signal(null);
+
+ readonly #ordered = computed(() => new Set(this.orderedItems()));
+
+ readonly #stop = computed(() => {
+ const items = this.orderedItems();
+ const active = this.activeItem();
+ if (active && items.includes(active)) {
+ return active.focusable() ? active : this.#nearestFocusable(items, items.indexOf(active));
+ }
+ return this.#initialStop(items);
+ });
+
+ readonly #keyManager = new FocusKeyManager(this.orderedItems, this.#injector)
+ .skipPredicate((chip) => !chip.focusable())
+ .withVerticalOrientation(false)
+ .withHorizontalOrientation('ltr')
+ .withHomeAndEnd();
+
+ constructor() {
+ super();
+ this.#keyManager.change.subscribe(() => {
+ const active = this.#keyManager.activeItem;
+ if (active) {
+ this.activeItem.set(active);
+ }
+ });
+ this.#destroyRef.onDestroy(() => this.#keyManager.destroy());
+ }
+
+ attach(host: ChipSetHost): void {
+ this.#host.set(host);
+ this.#scheduleRefresh();
+ }
+
+ register(chip: ChipComponent): void {
+ this.#registry.add(chip);
+ this.#scheduleRefresh();
+ }
+
+ unregister(chip: ChipComponent): void {
+ this.#registry.delete(chip);
+ this.#scheduleRefresh();
+ }
+
+ isSelected(identity: string): boolean {
+ return this.#host()?.selected().includes(identity) ?? false;
+ }
+
+ toggle(chip: ChipComponent, selected: boolean): void {
+ const host = this.#host();
+ if (!host) {
+ return;
+ }
+ const identity = chip.identity();
+ const current = host.selected();
+ if (selected) {
+ if (host.selectionMode() === 'single') {
+ host.selected.set([identity]);
+ } else if (!current.includes(identity)) {
+ host.selected.set([...current, identity]);
+ }
+ return;
+ }
+ if (current.includes(identity)) {
+ host.selected.set(current.filter((value) => value !== identity));
+ }
+ }
+
+ tabIndexFor(chip: ChipComponent): number | null {
+ if (!this.#registry.has(chip) || !chip.focusable()) {
+ return null;
+ }
+ if (this.orderedItems().length === 0) {
+ return chip === this.#firstFocusable() ? 0 : -1;
+ }
+ if (!this.#ordered().has(chip)) {
+ return null;
+ }
+ return chip === this.#stop() ? 0 : -1;
+ }
+
+ requestRemove(chip: ChipComponent): void {
+ this.#host()?.removeRequested(chip.identity());
+ }
+
+ handleKeydown($event: KeyboardEvent): void {
+ const host = this.#host();
+ if (!host || !NAVIGATION_KEYS.includes($event.key)) {
+ return;
+ }
+ this.#updateOrder();
+ const chip = this.#chipContaining($event.target);
+ if (!chip?.focusable()) {
+ return;
+ }
+ this.#keyManager.withHorizontalOrientation(this.#rtl.isRTL(host.element) ? 'rtl' : 'ltr');
+ this.#keyManager.updateActiveItem(chip);
+ this.#keyManager.onKeydown($event);
+ }
+
+ handleFocusin($event: FocusEvent): void {
+ const chip = this.#chipContaining($event.target);
+ this.#focusedItem = chip;
+ if (chip?.focusable() && this.#host()?.element.contains(chip.element)) {
+ [...this.#registry]
+ .filter((item) => item !== chip && item.element.getAttribute('tabindex') === '0')
+ .forEach((item) => item.element.setAttribute('tabindex', '-1'));
+ chip.element.setAttribute('tabindex', '0');
+ this.activeItem.set(chip);
+ this.#keyManager.updateActiveItem(chip);
+ }
+ }
+
+ handleFocusout($event: FocusEvent): void {
+ const chip = this.#focusedItem;
+ if (!chip) {
+ return;
+ }
+ if ($event.relatedTarget) {
+ this.#focusedItem = null;
+ return;
+ }
+ queueMicrotask(() => {
+ if (this.#focusedItem === chip && chip.element.isConnected && chip.element.ownerDocument.hasFocus()) {
+ this.#focusedItem = null;
+ }
+ });
+ }
+
+ #scheduleRefresh(): void {
+ if (this.#scheduled || this.#destroyRef.destroyed) {
+ return;
+ }
+ this.#scheduled = true;
+ afterNextRender(
+ {
+ mixedReadWrite: () => {
+ this.#scheduled = false;
+ this.#refresh();
+ }
+ },
+ { injector: this.#injector }
+ );
+ }
+
+ #refresh(): void {
+ const host = this.#host();
+ if (!host) {
+ return;
+ }
+ const previous = this.#snapshot;
+ const items = this.#updateOrder();
+ this.#snapshot = items;
+
+ if (this.#settled) {
+ this.#announce(host, previous, items);
+ }
+
+ this.#reconcileActive(items, previous);
+ this.#restoreFocus(host, items, previous);
+ this.#warnUnidentified(items);
+ this.#settled = true;
+ }
+
+ #updateOrder(): ChipComponent[] {
+ const element = this.#host()?.element;
+ const items = [...this.#registry]
+ .filter((chip) => !!element?.contains(chip.element))
+ .sort((a, b) => (a.element.compareDocumentPosition(b.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));
+ const current = untracked(this.orderedItems);
+ if (items.length !== current.length || items.some((chip, index) => chip !== current[index])) {
+ this.orderedItems.set(items);
+ }
+ return items;
+ }
+
+ #announce(host: ChipSetHost, previous: ChipComponent[], current: ChipComponent[]): void {
+ const element = host.element;
+ if (!element.isConnected || (typeof element.checkVisibility === 'function' && !element.checkVisibility())) {
+ return;
+ }
+ const added = this.#difference(current, previous)[0];
+ const removed = this.#difference(previous, current)[0];
+ const message = added
+ ? added.text() && `${added.text()} ${host.ariaAddedAnnouncement()}`
+ : removed?.text() && `${removed.text()} ${host.ariaRemovedAnnouncement()}`;
+ if (message) {
+ this.#announcer.announce(message, { context: element });
+ }
+ }
+
+ #difference(from: ChipComponent[], subtract: ChipComponent[]): ChipComponent[] {
+ const counts = new Map();
+ subtract.forEach((chip) => counts.set(chip.identity(), (counts.get(chip.identity()) ?? 0) + 1));
+ return from.filter((chip) => {
+ const count = counts.get(chip.identity()) ?? 0;
+ counts.set(chip.identity(), count - 1);
+ return count <= 0;
+ });
+ }
+
+ #reconcileActive(items: ChipComponent[], previous: ChipComponent[]): void {
+ const active = untracked(this.activeItem);
+ if (active && !items.includes(active)) {
+ this.activeItem.set(this.#replacement(active, items, previous));
+ }
+ const stop = untracked(this.#stop);
+ if (stop) {
+ this.#keyManager.updateActiveItem(stop);
+ }
+ }
+
+ #restoreFocus(host: ChipSetHost, items: ChipComponent[], previous: ChipComponent[]): void {
+ const removed = this.#focusedItem;
+ if (!removed || items.includes(removed)) {
+ return;
+ }
+ this.#focusedItem = null;
+ const active = removed.element.ownerDocument.activeElement;
+ const focusLost = !active || active === removed.element.ownerDocument.body || removed.element.contains(active);
+ if (!focusLost) {
+ return;
+ }
+ const target = this.#replacement(removed, items, previous);
+ if (target) {
+ this.activeItem.set(target);
+ this.#keyManager.updateActiveItem(target);
+ target.element.setAttribute('tabindex', '0');
+ target.focus();
+ return;
+ }
+ host.focusFallback();
+ }
+
+ #replacement(removed: ChipComponent, items: ChipComponent[], previous: ChipComponent[]): ChipComponent | null {
+ const identity = removed.identity();
+ const twin = items.find((chip) => chip.identity() === identity && chip.focusable() && !previous.includes(chip));
+ if (twin) {
+ return twin;
+ }
+ const index = previous.indexOf(removed);
+ for (let i = index + 1; i < previous.length; i++) {
+ if (items.includes(previous[i]) && previous[i].focusable()) {
+ return previous[i];
+ }
+ }
+ for (let i = index - 1; i >= 0; i--) {
+ if (items.includes(previous[i]) && previous[i].focusable()) {
+ return previous[i];
+ }
+ }
+ return null;
+ }
+
+ #nearestFocusable(items: ChipComponent[], index: number): ChipComponent | null {
+ return (
+ items.slice(index + 1).find((chip) => chip.focusable()) ??
+ items
+ .slice(0, index)
+ .reverse()
+ .find((chip) => chip.focusable()) ??
+ null
+ );
+ }
+
+ #initialStop(items: ChipComponent[]): ChipComponent | null {
+ const focusable = items.filter((chip) => chip.focusable());
+ if (this.#host()?.initialStop === 'last') {
+ return focusable.at(-1) ?? null;
+ }
+ return focusable.find((chip) => this.isSelected(chip.identity())) ?? focusable[0] ?? null;
+ }
+
+ #firstFocusable(): ChipComponent | null {
+ for (const chip of this.#registry) {
+ if (chip.focusable()) {
+ return chip;
+ }
+ }
+ return null;
+ }
+
+ #chipContaining(target: EventTarget | null): ChipComponent | null {
+ if (!(target instanceof Node)) {
+ return null;
+ }
+ return [...this.#registry].find((chip) => chip.element.contains(target)) ?? null;
+ }
+
+ #warnUnidentified(items: ChipComponent[]): void {
+ for (const chip of items) {
+ if (
+ !this.#warned.has(chip) &&
+ (chip.isSelectable() || chip.isRemovable()) &&
+ chip.value() === undefined &&
+ !chip.text()
+ ) {
+ this.#warned.add(chip);
+ console.warn('CoreUI [c-chip] without text needs a value to be selected or removed in a chip set');
+ }
+ }
+ }
+}
diff --git a/projects/coreui-angular/src/lib/chip-set/chip-set.types.ts b/projects/coreui-angular/src/lib/chip-set/chip-set.types.ts
new file mode 100644
index 00000000..75a8bbe3
--- /dev/null
+++ b/projects/coreui-angular/src/lib/chip-set/chip-set.types.ts
@@ -0,0 +1,5 @@
+import { ChipOptions } from '../chip/chip.types';
+
+export type ChipSelectionMode = 'single' | 'multiple';
+
+export type ChipItem = { value: string; label?: string } & Partial;
diff --git a/projects/coreui-angular/src/lib/chip-set/index.ts b/projects/coreui-angular/src/lib/chip-set/index.ts
new file mode 100644
index 00000000..4aaf8f92
--- /dev/null
+++ b/projects/coreui-angular/src/lib/chip-set/index.ts
@@ -0,0 +1 @@
+export * from './public_api';
diff --git a/projects/coreui-angular/src/lib/chip-set/public_api.ts b/projects/coreui-angular/src/lib/chip-set/public_api.ts
new file mode 100644
index 00000000..060a5a50
--- /dev/null
+++ b/projects/coreui-angular/src/lib/chip-set/public_api.ts
@@ -0,0 +1,3 @@
+export { ChipSetComponent } from './chip-set.component';
+export { ChipSetModule } from './chip-set.module';
+export type { ChipItem, ChipSelectionMode } from './chip-set.types';
diff --git a/projects/coreui-angular/src/lib/chip/chip-set-ref.ts b/projects/coreui-angular/src/lib/chip/chip-set-ref.ts
new file mode 100644
index 00000000..9858c0ec
--- /dev/null
+++ b/projects/coreui-angular/src/lib/chip/chip-set-ref.ts
@@ -0,0 +1,25 @@
+import { Signal, TemplateRef } from '@angular/core';
+import type { ChipComponent } from './chip.component';
+
+export abstract class ChipSetRef {
+ abstract readonly ariaRemoveLabel: Signal;
+ abstract readonly disabled: Signal;
+ abstract readonly filter: Signal;
+ abstract readonly isListbox: Signal;
+ abstract readonly removable: Signal;
+ abstract readonly removeIcon: Signal | undefined>;
+ abstract readonly selectable: Signal;
+ abstract readonly selectedIcon: Signal | undefined>;
+
+ abstract isSelected(identity: string): boolean;
+
+ abstract register(chip: ChipComponent): void;
+
+ abstract requestRemove(chip: ChipComponent): void;
+
+ abstract tabIndexFor(chip: ChipComponent): number | null;
+
+ abstract toggle(chip: ChipComponent, selected: boolean): void;
+
+ abstract unregister(chip: ChipComponent): void;
+}
diff --git a/projects/coreui-angular/src/lib/chip/chip.component.html b/projects/coreui-angular/src/lib/chip/chip.component.html
new file mode 100644
index 00000000..eb81bfd6
--- /dev/null
+++ b/projects/coreui-angular/src/lib/chip/chip.component.html
@@ -0,0 +1,44 @@
+@if (isFilter() && isSelected()) {
+
+ @if (selectedIconTemplate(); as selectedIconTpl) {
+
+ } @else {
+
+ }
+
+}
+
+@if (isRemovable() && !isDisabled()) {
+ @if (hasRemoveButton()) {
+
+ } @else {
+
+
+
+ }
+}
+
+
+
diff --git a/projects/coreui-angular/src/lib/chip/chip.component.spec.ts b/projects/coreui-angular/src/lib/chip/chip.component.spec.ts
new file mode 100644
index 00000000..f9a07af6
--- /dev/null
+++ b/projects/coreui-angular/src/lib/chip/chip.component.spec.ts
@@ -0,0 +1,310 @@
+import { Component, provideZonelessChangeDetection, signal } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { Colors } from '../coreui.types';
+import { ChipComponent } from './chip.component';
+import { ChipSize } from './chip.types';
+
+const KEY_CODES: Record = { Enter: 13, ' ': 32, Backspace: 8, Delete: 46 };
+
+const press = (target: HTMLElement, key: string) =>
+ target.dispatchEvent(new KeyboardEvent('keydown', { key, keyCode: KEY_CODES[key], bubbles: true, cancelable: true }));
+
+@Component({
+ imports: [ChipComponent],
+ template: `
+
+ Vue
+
+ ✓
+ `
+})
+class ChipHostComponent {
+ readonly active = signal(false);
+ readonly ariaRemoveLabel = signal(undefined);
+ readonly clickable = signal(false);
+ readonly color = signal(undefined);
+ readonly customIcon = signal(false);
+ readonly disabled = signal(undefined);
+ readonly filter = signal(undefined);
+ readonly label = signal(undefined);
+ readonly removable = signal(undefined);
+ readonly role = signal(undefined);
+ readonly selectable = signal(undefined);
+ readonly selected = signal(false);
+ readonly size = signal(undefined);
+ readonly value = signal(undefined);
+ readonly variant = signal<'outline' | undefined>(undefined);
+ readonly changes: boolean[] = [];
+ readonly removed: (MouseEvent | KeyboardEvent)[] = [];
+}
+
+describe('ChipComponent', () => {
+ let fixture: ComponentFixture;
+ let host: ChipHostComponent;
+ let chip: HTMLElement;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ChipHostComponent],
+ providers: [provideZonelessChangeDetection()]
+ }).compileComponents();
+ fixture = TestBed.createComponent(ChipHostComponent);
+ host = fixture.componentInstance;
+ await fixture.whenStable();
+ chip = fixture.nativeElement.querySelector('c-chip');
+ });
+
+ it('loads and displays Chip component', () => {
+ expect(chip.classList.contains('chip')).toBe(true);
+ expect(chip.textContent?.trim()).toBe('Vue');
+ expect(chip.getAttribute('role')).toBeNull();
+ expect(chip.getAttribute('tabindex')).toBeNull();
+ });
+
+ it('Chip customize', async () => {
+ host.active.set(true);
+ host.clickable.set(true);
+ host.color.set('primary');
+ host.size.set('lg');
+ host.variant.set('outline');
+ await fixture.whenStable();
+ expect(chip.classList.contains('active')).toBe(true);
+ expect(chip.classList.contains('chip-clickable')).toBe(true);
+ expect(chip.classList.contains('chip-primary')).toBe(true);
+ expect(chip.classList.contains('chip-lg')).toBe(true);
+ expect(chip.classList.contains('chip-outline')).toBe(true);
+
+ host.disabled.set(true);
+ await fixture.whenStable();
+ expect(chip.classList.contains('disabled')).toBe(true);
+ expect(chip.getAttribute('aria-disabled')).toBeNull();
+ });
+
+ it('Chip removable', async () => {
+ host.removable.set(true);
+ await fixture.whenStable();
+ const button = chip.querySelector('button.chip-remove')!;
+ expect(button.getAttribute('aria-label')).toBe('Remove Vue');
+ expect(button.getAttribute('tabindex')).toBe('-1');
+ expect(chip.getAttribute('tabindex')).toBe('0');
+ expect(chip.getAttribute('role')).toBeNull();
+ expect(chip.getAttribute('aria-pressed')).toBeNull();
+
+ button.click();
+ expect(host.removed.length).toBe(1);
+ expect(host.changes.length).toBe(0);
+ });
+
+ it('Chip selectable', async () => {
+ host.selectable.set(true);
+ await fixture.whenStable();
+ expect(chip.getAttribute('role')).toBe('button');
+ expect(chip.getAttribute('aria-pressed')).toBe('false');
+ expect(chip.getAttribute('aria-selected')).toBeNull();
+ expect(chip.classList.contains('chip-clickable')).toBe(true);
+
+ chip.click();
+ await fixture.whenStable();
+ expect(chip.getAttribute('aria-pressed')).toBe('true');
+ expect(chip.classList.contains('active')).toBe(true);
+ expect(host.selected()).toBe(true);
+
+ press(chip, 'Enter');
+ await fixture.whenStable();
+ expect(chip.getAttribute('aria-pressed')).toBe('false');
+
+ press(chip, ' ');
+ await fixture.whenStable();
+ expect(host.changes).toEqual([true, false, true]);
+ });
+
+ it('a selectable removable chip keeps a named remove button out of its own name', async () => {
+ host.selectable.set(true);
+ host.removable.set(true);
+ await fixture.whenStable();
+ expect(chip.getAttribute('role')).toBe('button');
+ expect(chip.getAttribute('aria-label')).toBe('Vue');
+ const button = chip.querySelector('button.chip-remove')!;
+ expect(button.getAttribute('aria-label')).toBe('Remove Vue');
+
+ button.click();
+ await fixture.whenStable();
+ expect(host.removed.length).toBe(1);
+ expect(host.changes).toEqual([]);
+
+ host.removable.set(false);
+ await fixture.whenStable();
+ expect(chip.getAttribute('aria-label')).toBeNull();
+ });
+
+ it('leaves Enter and Space on the remove button to the button', async () => {
+ host.selectable.set(true);
+ host.removable.set(true);
+ host.role.set('listitem');
+ await fixture.whenStable();
+ const button = chip.querySelector('button.chip-remove')!;
+ for (const key of ['Enter', ' ']) {
+ const event = new KeyboardEvent('keydown', { key, keyCode: KEY_CODES[key], bubbles: true, cancelable: true });
+ button.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(false);
+ }
+ await fixture.whenStable();
+ expect(host.changes).toEqual([]);
+ });
+
+ it('keeps an aria-label bound by the page', async () => {
+ host.label.set('Framework');
+ await fixture.whenStable();
+ expect(chip.getAttribute('aria-label')).toBe('Framework');
+
+ host.selectable.set(true);
+ host.removable.set(true);
+ await fixture.whenStable();
+ expect(chip.getAttribute('aria-label')).toBe('Framework');
+ });
+
+ it('Chip filter shows a check icon while selected and implies selectable', async () => {
+ host.filter.set(true);
+ await fixture.whenStable();
+ expect(chip.getAttribute('role')).toBe('button');
+ expect(chip.querySelector('.chip-check')).toBeNull();
+
+ chip.click();
+ await fixture.whenStable();
+ const check = chip.querySelector('.chip-check')!;
+ expect(check.getAttribute('aria-hidden')).toBe('true');
+ expect(check.querySelector('svg')).not.toBeNull();
+ });
+
+ it('Chip filter renders a custom selectedIcon', async () => {
+ host.filter.set(true);
+ host.customIcon.set(true);
+ host.selected.set(true);
+ await fixture.whenStable();
+ expect(chip.querySelector('.chip-check .custom-check')).not.toBeNull();
+ expect(chip.querySelector('.chip-check svg')).toBeNull();
+ });
+
+ it('Space on a removable chip does not scroll the page', async () => {
+ host.removable.set(true);
+ await fixture.whenStable();
+ const event = new KeyboardEvent('keydown', { key: ' ', keyCode: 32, bubbles: true, cancelable: true });
+ chip.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(true);
+ expect(host.removed.length).toBe(0);
+
+ host.removable.set(false);
+ await fixture.whenStable();
+ const plain = new KeyboardEvent('keydown', { key: ' ', keyCode: 32, bubbles: true, cancelable: true });
+ chip.dispatchEvent(plain);
+ expect(plain.defaultPrevented).toBe(false);
+ });
+
+ it('Chip delete triggers remove callback', async () => {
+ host.removable.set(true);
+ await fixture.whenStable();
+ press(chip, 'Backspace');
+ press(chip, 'Delete');
+ expect(host.removed.length).toBe(2);
+ });
+
+ it('a disabled chip has no remove button, no tab stop and ignores keys', async () => {
+ host.removable.set(true);
+ host.selectable.set(true);
+ host.disabled.set(true);
+ await fixture.whenStable();
+ expect(chip.querySelector('.chip-remove')).toBeNull();
+ expect(chip.getAttribute('tabindex')).toBeNull();
+ expect(chip.getAttribute('aria-disabled')).toBe('true');
+ press(chip, 'Delete');
+ chip.click();
+ expect(host.removed.length).toBe(0);
+ expect(host.changes.length).toBe(0);
+ });
+
+ it('uses its own ariaRemoveLabel verbatim', async () => {
+ host.removable.set(true);
+ host.ariaRemoveLabel.set('Usuń Vue');
+ await fixture.whenStable();
+ expect(chip.querySelector('.chip-remove')?.getAttribute('aria-label')).toBe('Usuń Vue');
+ });
+
+ it('takes the role set by the page', async () => {
+ host.selectable.set(true);
+ host.role.set('switch');
+ await fixture.whenStable();
+ expect(chip.getAttribute('role')).toBe('switch');
+ expect(chip.getAttribute('aria-pressed')).toBeNull();
+ });
+
+ it('identifies itself by value, then by text', async () => {
+ expect(chip.getAttribute('data-coreui-chip-value')).toBe('Vue');
+ host.value.set('vue');
+ await fixture.whenStable();
+ expect(chip.getAttribute('data-coreui-chip-value')).toBe('vue');
+ });
+});
+
+@Component({
+ imports: [ChipComponent],
+ template: `
+ closeAngular
+ Tab
+ Apple
+ Plain
+ Toggle
+ `
+})
+class ChipTextHostComponent {}
+
+describe('ChipComponent text and attributes', () => {
+ beforeEach(() => {
+ TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()] });
+ });
+
+ it('reads its text without icons and hidden content', async () => {
+ const fixture = TestBed.createComponent(ChipTextHostComponent);
+ await fixture.whenStable();
+ const chip = fixture.nativeElement.querySelector('#icon') as HTMLElement;
+ expect(chip.getAttribute('data-coreui-chip-value')).toBe('Angular');
+ expect(chip.querySelector('.chip-remove')?.getAttribute('aria-label')).toBe('Remove Angular');
+ });
+
+ it('a static tabindex on a standalone chip wins', async () => {
+ const fixture = TestBed.createComponent(ChipTextHostComponent);
+ await fixture.whenStable();
+ const chip = fixture.debugElement.children[1].componentInstance as ChipComponent;
+ expect(chip.tabindex()).toBe(2);
+ expect((fixture.nativeElement.querySelector('#tab') as HTMLElement).getAttribute('tabindex')).toBe('2');
+ });
+
+ it('keeps an aria-label set by the page', async () => {
+ const fixture = TestBed.createComponent(ChipTextHostComponent);
+ await fixture.whenStable();
+ expect((fixture.nativeElement.querySelector('#label') as HTMLElement).getAttribute('aria-label')).toBe('Fruit');
+ expect((fixture.nativeElement.querySelector('#bound') as HTMLElement).getAttribute('aria-label')).toBe('Bound');
+ expect((fixture.nativeElement.querySelector('#bound-toggle') as HTMLElement).getAttribute('aria-label')).toBe(
+ 'Bound toggle'
+ );
+ });
+});
diff --git a/projects/coreui-angular/src/lib/chip/chip.component.ts b/projects/coreui-angular/src/lib/chip/chip.component.ts
new file mode 100644
index 00000000..73103b7c
--- /dev/null
+++ b/projects/coreui-angular/src/lib/chip/chip.component.ts
@@ -0,0 +1,374 @@
+import { FocusableOption } from '@angular/cdk/a11y';
+import { NgTemplateOutlet } from '@angular/common';
+import {
+ afterEveryRender,
+ afterRenderEffect,
+ booleanAttribute,
+ Component,
+ computed,
+ DestroyRef,
+ ElementRef,
+ inject,
+ input,
+ linkedSignal,
+ OnInit,
+ output,
+ signal,
+ TemplateRef
+} from '@angular/core';
+import { Colors } from '../coreui.types';
+import { UIDService } from '../services/uid.service';
+import { ChipSetRef } from './chip-set-ref';
+import { ChipSize, ChipVariant } from './chip.types';
+import { getChipText, optionalBooleanAttribute, optionalNumberAttribute } from './chip.utils';
+
+@Component({
+ selector: 'c-chip',
+ exportAs: 'cChip',
+ templateUrl: './chip.component.html',
+ imports: [NgTemplateOutlet],
+ host: {
+ class: 'chip',
+ '[class]': 'hostClasses()',
+ '[attr.role]': 'roleAttr()',
+ '[attr.aria-selected]': 'ariaSelected()',
+ '[attr.aria-pressed]': 'ariaPressed()',
+ '[attr.aria-disabled]': 'ariaDisabled()',
+ '[attr.data-coreui-chip-focusable]': 'focusable() || null',
+ '[attr.data-coreui-chip-value]': 'identity()',
+ '[attr.tabindex]': 'tabIndex()',
+ '(click)': 'onClick($event)',
+ '(keydown)': 'onKeydown($event)'
+ }
+})
+export class ChipComponent implements FocusableOption, OnInit {
+ readonly #elementRef = inject>(ElementRef);
+ readonly #set = inject(ChipSetRef, { optional: true });
+ readonly #uid = inject(UIDService).getUID('chip');
+
+ /**
+ * Toggles the active state of a chip that is not selectable.
+ * @returns boolean
+ * @default false
+ */
+ readonly active = input(false, { transform: booleanAttribute });
+
+ /**
+ * Accessible name of the remove button. When set on the chip it is used as is; otherwise the label of the
+ * chip set (or `'Remove'`) is followed by the chip text, e.g. `'Remove Angular'`.
+ * @returns string | undefined
+ * @default undefined
+ */
+ readonly ariaRemoveLabel = input();
+
+ /**
+ * Adds hover styling and a pointer cursor to the chip.
+ * @returns boolean
+ * @default false
+ */
+ readonly clickable = input(false, { transform: booleanAttribute });
+
+ /**
+ * Sets the color context of the chip to one of CoreUI’s themed colors.
+ * @returns Colors | undefined
+ * @default undefined
+ */
+ readonly color = input();
+
+ /**
+ * Disables the chip. Inside a chip set an unset value takes the value of the set.
+ * @returns boolean | undefined
+ * @default undefined
+ */
+ readonly disabledInput = input(undefined, {
+ alias: 'disabled',
+ transform: optionalBooleanAttribute
+ });
+
+ /**
+ * Makes the chip a filter chip: selectable, with a check icon while selected.
+ * Inside a chip set an unset value takes the value of the set.
+ * @returns boolean | undefined
+ * @default undefined
+ */
+ readonly filter = input(undefined, { transform: optionalBooleanAttribute });
+
+ /**
+ * Shows a remove button; Backspace and Delete request removal too.
+ * Inside a chip set an unset value takes the value of the set.
+ * @returns boolean | undefined
+ * @default undefined
+ */
+ readonly removable = input(undefined, { transform: optionalBooleanAttribute });
+
+ /**
+ * Replaces the default remove icon.
+ * @returns TemplateRef | undefined
+ * @default undefined
+ */
+ readonly removeIcon = input>();
+
+ /**
+ * Overrides the role the chip takes from its context (`option` in a selectable set, `button` when selectable).
+ * @returns string | undefined
+ * @default undefined
+ */
+ readonly role = input();
+
+ /**
+ * Makes the chip selectable with click, Enter and Space.
+ * Inside a chip set an unset value takes the value of the set.
+ * @returns boolean | undefined
+ * @default undefined
+ */
+ readonly selectable = input(undefined, { transform: optionalBooleanAttribute });
+
+ /**
+ * Selected state of a selectable chip, two-way bindable with `[(selected)]`.
+ * Inside a chip set the set keeps the selection in its own `selected`, and this input is ignored.
+ * @returns boolean
+ * @default false
+ */
+ readonly selected = input(false, { transform: booleanAttribute });
+
+ /**
+ * Replaces the default check icon of a selected filter chip.
+ * @returns TemplateRef | undefined
+ * @default undefined
+ */
+ readonly selectedIcon = input>();
+
+ /**
+ * Size the chip small or large.
+ * @returns 'sm' | 'lg' | undefined
+ * @default undefined
+ */
+ readonly size = input();
+
+ /**
+ * Tab index of a standalone chip. Inside a chip set it is ignored: the set keeps one tab stop for all its chips.
+ * @returns number | undefined
+ * @default undefined
+ */
+ readonly tabindex = input(undefined, { transform: optionalNumberAttribute });
+
+ /**
+ * Value that identifies the chip in a chip set. Without it the chip text is used; set it wherever the text
+ * can change, e.g. with translations.
+ * @returns string | undefined
+ * @default undefined
+ */
+ readonly value = input();
+
+ /**
+ * Sets the visual variant of the chip.
+ * @returns 'outline' | undefined
+ * @default undefined
+ */
+ readonly variant = input();
+
+ /**
+ * Emits when removal is requested with the remove button, Backspace or Delete.
+ * @returns MouseEvent | KeyboardEvent
+ */
+ readonly remove = output();
+
+ /**
+ * Emits the new selected state when the chip is toggled by the user.
+ * @returns boolean
+ */
+ readonly selectedChange = output();
+
+ readonly #selected = linkedSignal(this.selected);
+ #content: string | null = null;
+ #writtenLabel: string | null = null;
+
+ readonly text = signal('');
+
+ readonly identity = computed(() => this.value() ?? (this.text() || this.#uid));
+
+ readonly isDisabled = computed(() => this.disabledInput() ?? this.#set?.disabled() ?? false);
+
+ readonly isFilter = computed(() => this.filter() ?? this.#set?.filter() ?? false);
+
+ readonly isSelectable = computed(() => (this.selectable() ?? this.#set?.selectable() ?? false) || this.isFilter());
+
+ readonly isRemovable = computed(() => this.removable() ?? this.#set?.removable() ?? false);
+
+ readonly isCoordinated = computed(() => !!this.#set && this.isSelectable());
+
+ readonly isSelected = computed(() =>
+ this.isCoordinated() ? this.#set!.isSelected(this.identity()) : this.#selected()
+ );
+
+ readonly focusable = computed(() => !this.isDisabled() && (this.isSelectable() || this.isRemovable()));
+
+ readonly roleAttr = computed(() => {
+ return this.role() ?? (this.#set?.isListbox() ? 'option' : this.isSelectable() ? 'button' : null);
+ });
+
+ readonly ariaSelected = computed(() =>
+ this.roleAttr() === 'option' && this.isSelectable() ? String(this.isSelected()) : null
+ );
+
+ readonly ariaPressed = computed(() =>
+ this.roleAttr() === 'button' && this.isSelectable() ? String(this.isSelected()) : null
+ );
+
+ readonly ariaDisabled = computed(() => (this.roleAttr() && this.isDisabled() ? 'true' : null));
+
+ readonly hasRemoveButton = computed(() => this.isRemovable() && !this.isDisabled() && this.roleAttr() !== 'option');
+
+ readonly #label = computed(() =>
+ this.roleAttr() === 'button' && this.hasRemoveButton() ? this.text() || null : null
+ );
+
+ readonly tabIndex = computed(() => {
+ if (this.#set) {
+ return this.#set.tabIndexFor(this);
+ }
+ return this.tabindex() ?? (this.focusable() ? 0 : null);
+ });
+
+ readonly removeLabel = computed(() => {
+ const own = this.ariaRemoveLabel();
+ if (own !== undefined) {
+ return own;
+ }
+ return [this.#set?.ariaRemoveLabel() ?? 'Remove', this.text()].filter(Boolean).join(' ');
+ });
+
+ readonly removeIconTemplate = computed(() => this.removeIcon() ?? this.#set?.removeIcon());
+
+ readonly selectedIconTemplate = computed(() => this.selectedIcon() ?? this.#set?.selectedIcon());
+
+ readonly hostClasses = computed(() => {
+ const color = this.color();
+ const size = this.size();
+ return {
+ chip: true,
+ active: this.isSelectable() ? this.isSelected() : this.active(),
+ disabled: this.isDisabled(),
+ [`chip-${color}`]: !!color,
+ [`chip-${size}`]: !!size,
+ 'chip-clickable': this.clickable() || this.isSelectable(),
+ 'chip-outline': this.variant() === 'outline'
+ } as Record;
+ });
+
+ get element(): HTMLElement {
+ return this.#elementRef.nativeElement;
+ }
+
+ get disabled(): boolean {
+ return this.isDisabled();
+ }
+
+ constructor() {
+ this.#set?.register(this);
+ inject(DestroyRef).onDestroy(() => this.#set?.unregister(this));
+ afterEveryRender({
+ earlyRead: () => {
+ const content = this.element.textContent;
+ if (content === this.#content) {
+ return null;
+ }
+ this.#content = content;
+ return getChipText(this.element);
+ },
+ write: (text) => {
+ if (text !== null) {
+ this.text.set(text);
+ }
+ }
+ });
+ afterRenderEffect({
+ write: () => this.#writeLabel(this.#label())
+ });
+ }
+
+ ngOnInit(): void {
+ if (this.#set && this.tabindex() !== undefined) {
+ console.warn('CoreUI [c-chip] inside a chip set ignores tabindex - the set keeps one tab stop for its chips');
+ }
+ }
+
+ focus(): void {
+ this.element.focus();
+ }
+
+ getLabel(): string {
+ return this.text();
+ }
+
+ onClick($event: MouseEvent): void {
+ if (this.isDisabled() || ($event.target as HTMLElement).closest('.chip-remove')) {
+ return;
+ }
+ if (this.isSelectable()) {
+ this.#toggle();
+ }
+ }
+
+ onKeydown($event: KeyboardEvent): void {
+ if (this.isDisabled()) {
+ return;
+ }
+ switch ($event.key) {
+ case 'Enter':
+ case ' ':
+ case 'Spacebar': {
+ if (this.isSelectable() && !($event.target as HTMLElement).closest('.chip-remove')) {
+ $event.preventDefault();
+ this.#toggle();
+ } else if ($event.key !== 'Enter' && $event.target === this.element && this.focusable()) {
+ $event.preventDefault();
+ }
+ break;
+ }
+ case 'Backspace':
+ case 'Delete': {
+ if (this.isRemovable()) {
+ $event.preventDefault();
+ this.#requestRemove($event);
+ }
+ break;
+ }
+ }
+ }
+
+ onRemoveClick($event: MouseEvent): void {
+ $event.stopPropagation();
+ if (this.isRemovable() && !this.isDisabled()) {
+ this.#requestRemove($event);
+ }
+ }
+
+ #toggle(): void {
+ const next = !this.isSelected();
+ this.#selected.set(next);
+ this.selectedChange.emit(next);
+ if (this.isCoordinated()) {
+ this.#set!.toggle(this, next);
+ }
+ }
+
+ #requestRemove($event: MouseEvent | KeyboardEvent): void {
+ this.remove.emit($event);
+ this.#set?.requestRemove(this);
+ }
+
+ #writeLabel(label: string | null): void {
+ const current = this.element.getAttribute('aria-label');
+ if (current !== null && current !== this.#writtenLabel) {
+ this.#writtenLabel = null;
+ return;
+ }
+ if (label) {
+ this.element.setAttribute('aria-label', label);
+ } else if (current !== null) {
+ this.element.removeAttribute('aria-label');
+ }
+ this.#writtenLabel = label;
+ }
+}
diff --git a/projects/coreui-angular/src/lib/chip/chip.module.ts b/projects/coreui-angular/src/lib/chip/chip.module.ts
new file mode 100644
index 00000000..350a8294
--- /dev/null
+++ b/projects/coreui-angular/src/lib/chip/chip.module.ts
@@ -0,0 +1,8 @@
+import { NgModule } from '@angular/core';
+import { ChipComponent } from './chip.component';
+
+@NgModule({
+ imports: [ChipComponent],
+ exports: [ChipComponent]
+})
+export class ChipModule {}
diff --git a/projects/coreui-angular/src/lib/chip/chip.types.ts b/projects/coreui-angular/src/lib/chip/chip.types.ts
new file mode 100644
index 00000000..9c8bf929
--- /dev/null
+++ b/projects/coreui-angular/src/lib/chip/chip.types.ts
@@ -0,0 +1,18 @@
+import { Colors } from '../coreui.types';
+
+export type ChipSize = 'sm' | 'lg';
+
+export type ChipVariant = 'outline';
+
+export interface ChipOptions {
+ active: boolean;
+ ariaRemoveLabel: string;
+ clickable: boolean;
+ color: Colors;
+ disabled: boolean;
+ filter: boolean;
+ removable: boolean;
+ selectable: boolean;
+ size: ChipSize;
+ variant: ChipVariant;
+}
diff --git a/projects/coreui-angular/src/lib/chip/chip.utils.spec.ts b/projects/coreui-angular/src/lib/chip/chip.utils.spec.ts
new file mode 100644
index 00000000..9faff1cd
--- /dev/null
+++ b/projects/coreui-angular/src/lib/chip/chip.utils.spec.ts
@@ -0,0 +1,35 @@
+import { getChipText, optionalBooleanAttribute, optionalNumberAttribute } from './chip.utils';
+
+describe('chip utils', () => {
+ it('optionalBooleanAttribute keeps undefined and coerces the rest', () => {
+ expect(optionalBooleanAttribute(undefined)).toBeUndefined();
+ expect(optionalBooleanAttribute('')).toBe(true);
+ expect(optionalBooleanAttribute('false')).toBe(false);
+ expect(optionalBooleanAttribute(true)).toBe(true);
+ });
+
+ it('optionalNumberAttribute keeps undefined and null as undefined', () => {
+ expect(optionalNumberAttribute(undefined)).toBeUndefined();
+ expect(optionalNumberAttribute(null)).toBeUndefined();
+ expect(optionalNumberAttribute('2')).toBe(2);
+ expect(optionalNumberAttribute(-1)).toBe(-1);
+ });
+
+ it('getChipText drops indicators, icons and hidden content', () => {
+ const element = document.createElement('span');
+ element.innerHTML = `
+ ✓
+
+ close
+ Angular framework
+ `;
+ expect(getChipText(element)).toBe('Angular framework');
+ expect(element.querySelector('svg')).not.toBeNull();
+ });
+
+ it('getChipText returns an empty string for an icon-only chip', () => {
+ const element = document.createElement('span');
+ element.innerHTML = '';
+ expect(getChipText(element)).toBe('');
+ });
+});
diff --git a/projects/coreui-angular/src/lib/chip/chip.utils.ts b/projects/coreui-angular/src/lib/chip/chip.utils.ts
new file mode 100644
index 00000000..f6b7be51
--- /dev/null
+++ b/projects/coreui-angular/src/lib/chip/chip.utils.ts
@@ -0,0 +1,31 @@
+import { booleanAttribute, numberAttribute } from '@angular/core';
+
+const TEXT_EXCLUDED = '.chip-check, .chip-remove, svg, [aria-hidden="true"]';
+
+/**
+ * Coerces an attribute value to a boolean, keeping `undefined` so a chip can inherit the value from its set.
+ * @param value - The bound or static attribute value
+ * @returns `undefined` when nothing was bound, otherwise the `booleanAttribute` result
+ */
+export const optionalBooleanAttribute = (value: unknown): boolean | undefined =>
+ value === undefined ? undefined : booleanAttribute(value);
+
+/**
+ * Coerces an attribute value to a number, keeping `undefined` and `null` as `undefined`.
+ * @param value - The bound or static attribute value
+ * @returns `undefined` when nothing was bound, otherwise the `numberAttribute` result
+ */
+export const optionalNumberAttribute = (value: unknown): number | undefined =>
+ value === undefined || value === null ? undefined : numberAttribute(value);
+
+/**
+ * Reads the visible text of a chip: its text content without the check and remove indicators,
+ * icons (`svg`) and anything hidden from assistive technology.
+ * @param element - The chip host element
+ * @returns The trimmed text with collapsed whitespace, an empty string for an icon-only chip
+ */
+export const getChipText = (element: HTMLElement): string => {
+ const clone = element.cloneNode(true) as HTMLElement;
+ clone.querySelectorAll(TEXT_EXCLUDED).forEach((node) => node.remove());
+ return (clone.textContent ?? '').replace(/\s+/g, ' ').trim();
+};
diff --git a/projects/coreui-angular/src/lib/chip/index.ts b/projects/coreui-angular/src/lib/chip/index.ts
new file mode 100644
index 00000000..4aaf8f92
--- /dev/null
+++ b/projects/coreui-angular/src/lib/chip/index.ts
@@ -0,0 +1 @@
+export * from './public_api';
diff --git a/projects/coreui-angular/src/lib/chip/public_api.ts b/projects/coreui-angular/src/lib/chip/public_api.ts
new file mode 100644
index 00000000..11bdfe50
--- /dev/null
+++ b/projects/coreui-angular/src/lib/chip/public_api.ts
@@ -0,0 +1,3 @@
+export { ChipComponent } from './chip.component';
+export { ChipModule } from './chip.module';
+export type { ChipOptions, ChipSize, ChipVariant } from './chip.types';
diff --git a/projects/coreui-angular/src/lib/services/announcer.service.spec.ts b/projects/coreui-angular/src/lib/services/announcer.service.spec.ts
new file mode 100644
index 00000000..bd4c10c2
--- /dev/null
+++ b/projects/coreui-angular/src/lib/services/announcer.service.spec.ts
@@ -0,0 +1,131 @@
+import { PLATFORM_ID } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { AnnouncerService } from './announcer.service';
+
+describe('AnnouncerService', () => {
+ let service: AnnouncerService;
+
+ const regions = (host: Element) => host.querySelector(':scope > [data-coreui-live-announcer]');
+ const polite = (host: Element) => regions(host)?.querySelector('[aria-live="polite"]');
+
+ beforeEach(() => {
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] });
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(AnnouncerService);
+ });
+
+ afterEach(() => {
+ document
+ .querySelectorAll('[data-coreui-live-announcer], [aria-modal], dialog')
+ .forEach((element) => element.remove());
+ vi.useRealTimers();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('creates a visually hidden pair of log regions in body on the first message', () => {
+ expect(regions(document.body)).toBeNull();
+ service.announce('Saved');
+ const announcer = regions(document.body) as HTMLElement;
+ expect(announcer).not.toBeNull();
+ expect(announcer.style.position).toBe('absolute');
+ expect(announcer.style.width).toBe('1px');
+ const logs = [...announcer.querySelectorAll('[role="log"]')];
+ expect(logs.map((log) => log.getAttribute('aria-live'))).toEqual(['assertive', 'polite']);
+ expect(logs.every((log) => log.getAttribute('aria-relevant') === 'additions')).toBe(true);
+ });
+
+ it('adds the first message of a new region after 100 ms', () => {
+ service.announce('Saved');
+ vi.advanceTimersByTime(99);
+ expect(polite(document.body)?.textContent).toBe('');
+ vi.advanceTimersByTime(1);
+ expect(polite(document.body)?.textContent).toBe('Saved');
+ });
+
+ it('removes the message after its timeout, and reads the same text again as a new node', () => {
+ service.announce('Saved', { timeout: 1000 });
+ service.announce('Saved', { timeout: 1000 });
+ vi.advanceTimersByTime(100);
+ expect(polite(document.body)?.children.length).toBe(2);
+ vi.advanceTimersByTime(1000);
+ expect(polite(document.body)?.children.length).toBe(0);
+ });
+
+ it('keeps the message with a timeout of 0', () => {
+ service.announce('Saved', { timeout: 0 });
+ vi.advanceTimersByTime(10000);
+ expect(polite(document.body)?.children.length).toBe(1);
+ });
+
+ it('puts assertive messages in the assertive region', () => {
+ service.announce('Error', { priority: 'assertive' });
+ vi.advanceTimersByTime(100);
+ expect(regions(document.body)?.querySelector('[aria-live="assertive"]')?.textContent).toBe('Error');
+ });
+
+ it('cancels a message before it is added', () => {
+ const cancel = service.announce('Saved');
+ cancel();
+ vi.advanceTimersByTime(100);
+ expect(polite(document.body)?.textContent).toBe('');
+ });
+
+ it('announces inside the open aria-modal around the context', () => {
+ const modal = document.createElement('div');
+ modal.setAttribute('aria-modal', 'true');
+ const context = document.createElement('span');
+ modal.append(context);
+ document.body.append(modal);
+
+ service.announce('Removed', { context });
+ vi.advanceTimersByTime(100);
+ expect(polite(modal)?.textContent).toBe('Removed');
+ expect(polite(document.body)?.textContent ?? '').toBe('');
+ });
+
+ it('announces inside the aria-modal that holds focus, without a context', () => {
+ const modal = document.createElement('div');
+ const later = document.createElement('div');
+ modal.setAttribute('aria-modal', 'true');
+ later.setAttribute('aria-modal', 'true');
+ const button = document.createElement('button');
+ modal.append(button);
+ document.body.append(modal, later);
+ button.focus();
+
+ service.announce('Saved');
+ vi.advanceTimersByTime(100);
+ expect(polite(modal)?.textContent).toBe('Saved');
+ expect(regions(later)).toBeNull();
+ });
+
+ it('announces inside the last open modal when neither focus nor context is in one', () => {
+ const first = document.createElement('div');
+ const last = document.createElement('div');
+ first.setAttribute('aria-modal', 'true');
+ last.setAttribute('aria-modal', 'true');
+ document.body.append(first, last);
+
+ service.announce('Saved');
+ vi.advanceTimersByTime(100);
+ expect(polite(last)?.textContent).toBe('Saved');
+ expect(regions(first)).toBeNull();
+ });
+
+ it('does nothing for an empty message', () => {
+ service.announce('');
+ expect(regions(document.body)).toBeNull();
+ });
+});
+
+describe('AnnouncerService on the server', () => {
+ it('does nothing without a browser', () => {
+ TestBed.configureTestingModule({ providers: [{ provide: PLATFORM_ID, useValue: 'server' }] });
+ const service = TestBed.inject(AnnouncerService);
+ service.announce('Saved');
+ expect(document.querySelector('[data-coreui-live-announcer]')).toBeNull();
+ });
+});
diff --git a/projects/coreui-angular/src/lib/services/announcer.service.ts b/projects/coreui-angular/src/lib/services/announcer.service.ts
new file mode 100644
index 00000000..89c41f5a
--- /dev/null
+++ b/projects/coreui-angular/src/lib/services/announcer.service.ts
@@ -0,0 +1,198 @@
+import { DOCUMENT, isPlatformBrowser } from '@angular/common';
+import { inject, Injectable, NgZone, PLATFORM_ID } from '@angular/core';
+
+export type AnnouncePriority = 'assertive' | 'polite';
+
+export interface AnnounceOptions {
+ context?: Element | null;
+ priority?: AnnouncePriority;
+ timeout?: number;
+}
+
+interface PendingMessage {
+ context: Element | null;
+ node: HTMLElement;
+ page: HTMLElement | null;
+ priority: AnnouncePriority;
+ timeout: number;
+}
+
+const ATTRIBUTE = 'data-coreui-live-announcer';
+const FIRST_MESSAGE_DELAY = 100;
+const PRIORITIES: AnnouncePriority[] = ['assertive', 'polite'];
+
+const VISUALLY_HIDDEN: Partial = {
+ border: '0',
+ clip: 'rect(0 0 0 0)',
+ clipPath: 'inset(50%)',
+ height: '1px',
+ margin: '-1px',
+ overflow: 'hidden',
+ padding: '0',
+ position: 'absolute',
+ whiteSpace: 'nowrap',
+ width: '1px'
+};
+
+@Injectable({
+ providedIn: 'root'
+})
+export class AnnouncerService {
+ readonly #document = inject(DOCUMENT);
+ readonly #ngZone = inject(NgZone);
+ readonly #isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
+ readonly #pending: PendingMessage[] = [];
+ readonly #readyAt = new WeakMap();
+
+ /**
+ * Reads a message to screen reader users. The message goes to a visually hidden live region at the start of
+ * the page or, while a modal dialog leaves the rest of the page inert, to one inside that dialog.
+ * A new region takes its first message 100 ms after it is created, and each call adds a new node,
+ * so the same text is read again.
+ * @param message - The text to read
+ * @param options - `context` is the element the message comes from, `priority` picks the polite (default)
+ * or the assertive region, `timeout` (ms) is how long the message stays, `0` keeps it
+ * @returns A function that removes the message, or cancels it before it is added
+ */
+ announce(message: string, options: AnnounceOptions = {}): () => void {
+ const document = this.#document;
+ if (!this.#isBrowser || !document?.body || !message) {
+ return () => undefined;
+ }
+
+ const { context = null, priority = 'polite', timeout = 7000 } = options;
+ const node = document.createElement('div');
+ node.textContent = message;
+ const host = this.#getModal(context) ?? document.body;
+ const announcer = this.#getAnnouncer(host);
+
+ this.#pending.push({
+ context,
+ node,
+ page: host === document.body ? announcer : null,
+ priority: priority === 'assertive' ? 'assertive' : 'polite',
+ timeout
+ });
+ this.#schedule(this.#waitFor(announcer));
+
+ return () => {
+ const index = this.#pending.findIndex((pending) => pending.node === node);
+ if (index !== -1) {
+ this.#pending.splice(index, 1);
+ }
+ node.remove();
+ };
+ }
+
+ #schedule(delay: number): void {
+ this.#ngZone.runOutsideAngular(() => setTimeout(() => this.#flush(), Math.max(0, delay)));
+ }
+
+ #waitFor(announcer: Element): number {
+ return (this.#readyAt.get(announcer) ?? 0) - performance.now();
+ }
+
+ #flush(): void {
+ const document = this.#document;
+ while (this.#pending.length > 0) {
+ const [{ context, node, page, priority, timeout }] = this.#pending;
+
+ if (page && !page.isConnected) {
+ this.#pending.shift();
+ continue;
+ }
+
+ const announcer = this.#getAnnouncer(this.#getModal(context) ?? document.body);
+ const wait = this.#waitFor(announcer);
+ if (wait > 0) {
+ this.#schedule(wait);
+ return;
+ }
+
+ this.#pending.shift();
+ announcer.querySelector(`[aria-live="${priority}"]`)?.append(node);
+
+ if (timeout > 0 && Number.isFinite(timeout)) {
+ this.#ngZone.runOutsideAngular(() => setTimeout(() => node.remove(), timeout));
+ }
+ }
+ }
+
+ #getAnnouncer(host: HTMLElement): HTMLElement {
+ const existing = host.querySelector(`:scope > [${ATTRIBUTE}]`);
+ if (existing) {
+ if (!this.#readyAt.has(existing)) {
+ this.#readyAt.set(existing, performance.now() + FIRST_MESSAGE_DELAY);
+ }
+ return existing;
+ }
+
+ const announcer = this.#document.createElement('div');
+ announcer.setAttribute(ATTRIBUTE, '');
+ Object.assign(announcer.style, VISUALLY_HIDDEN);
+
+ for (const priority of PRIORITIES) {
+ const region = this.#document.createElement('div');
+ region.setAttribute('role', 'log');
+ region.setAttribute('aria-live', priority);
+ region.setAttribute('aria-relevant', 'additions');
+ announcer.append(region);
+ }
+
+ if (host === this.#document.body) {
+ host.prepend(announcer);
+ } else {
+ const onClose = (event: Event) => {
+ if (event.target === host && !(host as HTMLDialogElement).open) {
+ announcer.remove();
+ host.removeEventListener('close', onClose);
+ }
+ };
+ host.append(announcer);
+ host.addEventListener('close', onClose);
+ }
+
+ this.#readyAt.set(announcer, performance.now() + FIRST_MESSAGE_DELAY);
+ return announcer;
+ }
+
+ #getModal(context: Element | null): HTMLElement | null {
+ let focused = this.#document.activeElement;
+ while (focused?.shadowRoot?.activeElement) {
+ focused = focused.shadowRoot.activeElement;
+ }
+
+ const modals = [...this.#document.querySelectorAll('dialog[open], [aria-modal="true"]')].filter(
+ (element) => this.#isModal(element)
+ );
+
+ return this.#getDialogAround(focused) ?? this.#getDialogAround(context) ?? modals.at(-1) ?? null;
+ }
+
+ #getDialogAround(element: Element | null): HTMLElement | null {
+ let node = element;
+ while (node) {
+ if (this.#isModal(node)) {
+ return node as HTMLElement;
+ }
+ node = node.assignedSlot ?? node.parentElement ?? (node.getRootNode() as ShadowRoot).host ?? null;
+ }
+ return null;
+ }
+
+ #isModal(element: Element): boolean {
+ if (element.matches('dialog[open]')) {
+ try {
+ return element.matches(':modal');
+ } catch {
+ return false;
+ }
+ }
+
+ return (
+ element.matches('[aria-modal="true"]') &&
+ element.isConnected &&
+ (typeof element.checkVisibility !== 'function' || element.checkVisibility())
+ );
+ }
+}
diff --git a/projects/coreui-angular/src/public-api.ts b/projects/coreui-angular/src/public-api.ts
index f184649a..b06235bd 100644
--- a/projects/coreui-angular/src/public-api.ts
+++ b/projects/coreui-angular/src/public-api.ts
@@ -15,6 +15,8 @@ export * from './lib/button-group';
export * from './lib/callout';
export * from './lib/card';
export * from './lib/carousel';
+export * from './lib/chip';
+export * from './lib/chip-set';
export * from './lib/collapse';
export * from './lib/dropdown';
export * from './lib/footer';