From 6176dc1e60d75c1477bd53a5b54485c1aa8d2665 Mon Sep 17 00:00:00 2001 From: xidedix Date: Fri, 9 Oct 2026 20:34:22 +0200 Subject: [PATCH] feat(Chip): add Chip and Chip Set components c-chip: color, size, outline, active, clickable, selectable/filter with check icon, removable with remove button, Backspace/Delete removal; Space on a focusable chip never scrolls the page. Roles follow the v6 model: option in a selectable set, button with aria-pressed when selectable, no role and no aria-* otherwise. The remove button is labelled with the chip text ("Remove Angular") and becomes an aria-hidden indicator inside an option; a removable toggle chip takes its text as aria-label unless the page sets one. The text follows content changes. c-chip-set: data-driven [chips] (chipsChange, never mutated) plus projected chips, [(selected)] with single/multiple mode owned by the parent, listbox/group role, one roving tab stop (also before the first refresh and on the server), arrow/Home/End navigation mirrored in RTL, focus moved to the neighbour (or the set) after the focused chip is removed, added/removed chips announced through a page live region. AnnouncerService is internal and ports the v6 announce() core. Specs run zoneless. --- .../src/lib/chip-set/chip-set.component.html | 17 + .../lib/chip-set/chip-set.component.spec.ts | 679 ++++++++++++++++++ .../src/lib/chip-set/chip-set.component.ts | 229 ++++++ .../src/lib/chip-set/chip-set.module.ts | 9 + .../src/lib/chip-set/chip-set.service.spec.ts | 66 ++ .../src/lib/chip-set/chip-set.service.ts | 372 ++++++++++ .../src/lib/chip-set/chip-set.types.ts | 5 + .../coreui-angular/src/lib/chip-set/index.ts | 1 + .../src/lib/chip-set/public_api.ts | 3 + .../src/lib/chip/chip-set-ref.ts | 25 + .../src/lib/chip/chip.component.html | 44 ++ .../src/lib/chip/chip.component.spec.ts | 310 ++++++++ .../src/lib/chip/chip.component.ts | 374 ++++++++++ .../src/lib/chip/chip.module.ts | 8 + .../coreui-angular/src/lib/chip/chip.types.ts | 18 + .../src/lib/chip/chip.utils.spec.ts | 35 + .../coreui-angular/src/lib/chip/chip.utils.ts | 31 + projects/coreui-angular/src/lib/chip/index.ts | 1 + .../coreui-angular/src/lib/chip/public_api.ts | 3 + .../lib/services/announcer.service.spec.ts | 131 ++++ .../src/lib/services/announcer.service.ts | 198 +++++ projects/coreui-angular/src/public-api.ts | 2 + 22 files changed, 2561 insertions(+) create mode 100644 projects/coreui-angular/src/lib/chip-set/chip-set.component.html create mode 100644 projects/coreui-angular/src/lib/chip-set/chip-set.component.spec.ts create mode 100644 projects/coreui-angular/src/lib/chip-set/chip-set.component.ts create mode 100644 projects/coreui-angular/src/lib/chip-set/chip-set.module.ts create mode 100644 projects/coreui-angular/src/lib/chip-set/chip-set.service.spec.ts create mode 100644 projects/coreui-angular/src/lib/chip-set/chip-set.service.ts create mode 100644 projects/coreui-angular/src/lib/chip-set/chip-set.types.ts create mode 100644 projects/coreui-angular/src/lib/chip-set/index.ts create mode 100644 projects/coreui-angular/src/lib/chip-set/public_api.ts create mode 100644 projects/coreui-angular/src/lib/chip/chip-set-ref.ts create mode 100644 projects/coreui-angular/src/lib/chip/chip.component.html create mode 100644 projects/coreui-angular/src/lib/chip/chip.component.spec.ts create mode 100644 projects/coreui-angular/src/lib/chip/chip.component.ts create mode 100644 projects/coreui-angular/src/lib/chip/chip.module.ts create mode 100644 projects/coreui-angular/src/lib/chip/chip.types.ts create mode 100644 projects/coreui-angular/src/lib/chip/chip.utils.spec.ts create mode 100644 projects/coreui-angular/src/lib/chip/chip.utils.ts create mode 100644 projects/coreui-angular/src/lib/chip/index.ts create mode 100644 projects/coreui-angular/src/lib/chip/public_api.ts create mode 100644 projects/coreui-angular/src/lib/services/announcer.service.spec.ts create mode 100644 projects/coreui-angular/src/lib/services/announcer.service.ts diff --git a/projects/coreui-angular/src/lib/chip-set/chip-set.component.html b/projects/coreui-angular/src/lib/chip-set/chip-set.component.html new file mode 100644 index 00000000..fb0cb4e4 --- /dev/null +++ b/projects/coreui-angular/src/lib/chip-set/chip-set.component.html @@ -0,0 +1,17 @@ +@for (item of items(); track item.value) { + {{ item.label ?? item.value }} +} + diff --git a/projects/coreui-angular/src/lib/chip-set/chip-set.component.spec.ts b/projects/coreui-angular/src/lib/chip-set/chip-set.component.spec.ts new file mode 100644 index 00000000..006e8198 --- /dev/null +++ b/projects/coreui-angular/src/lib/chip-set/chip-set.component.spec.ts @@ -0,0 +1,679 @@ +import { Component, provideZonelessChangeDetection, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChipComponent } from '../chip/chip.component'; +import { AnnouncerService } from '../services/announcer.service'; +import { ChipSetComponent } from './chip-set.component'; +import { ChipItem, ChipSelectionMode } from './chip-set.types'; + +const KEY_CODES: Record = { + 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 (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: ` + StarAngular + 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 = ` + ✓ + Star + + 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 = 'Star'; + 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';