diff --git a/playwright/cps-accessibility.spec.ts b/playwright/cps-accessibility.spec.ts index 21dc4e4e7..0647f6d0c 100644 --- a/playwright/cps-accessibility.spec.ts +++ b/playwright/cps-accessibility.spec.ts @@ -199,7 +199,7 @@ const components: ComponentEntry[] = [ selector: '.example-content cps-tab-group' }, // { route: '/table', name: 'Table', selector: 'cps-table' }, - // { route: '/tag', name: 'Tag', selector: 'cps-tag' }, + { route: '/tag', name: 'Tag', selector: 'cps-tag' }, { route: '/textarea', name: 'Textarea', selector: 'cps-textarea' }, { route: '/timepicker', diff --git a/projects/composition/src/app/pages/tag-page/tag-page.component.scss b/projects/composition/src/app/pages/tag-page/tag-page.component.scss index 515c5a5ac..b6b8c82ed 100644 --- a/projects/composition/src/app/pages/tag-page/tag-page.component.scss +++ b/projects/composition/src/app/pages/tag-page/tag-page.component.scss @@ -1,11 +1,12 @@ .tags-group { - gap: 32px; + margin-left: 0.5rem; + gap: 2rem; display: flex; flex-direction: column; } .sync-val-example { .sync-val { - margin-top: 16px; + margin-top: 1rem; } } diff --git a/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.html b/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.html index 14124fb1e..ac90d0936 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.html @@ -1,7 +1,15 @@
-

{{ label }}

+ [style.borderColor]="cvtColor" + [attr.role]="selectable ? 'checkbox' : null" + [attr.aria-checked]="selectable ? value : null" + [attr.aria-disabled]="selectable && disabled ? true : null" + [attr.tabindex]="selectable ? (disabled ? -1 : 0) : null" + (click)="toggleSelected()" + (keydown.enter)="handleEnterKeydown($event)" + [class.cps-tag--pressing]="pressing" + (keydown.space)="handleSpaceKeydown($event)" + (keyup.space)="toggleSelected()"> + {{ label }}
diff --git a/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.scss b/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.scss index 8da639612..ad1b680cf 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.scss @@ -1,3 +1,5 @@ +@use '../../../../styles/mixins' as *; + :host { width: fit-content; display: inline-block; @@ -6,28 +8,34 @@ user-select: none; .cps-tag { font-family: 'Source Sans Pro', sans-serif; - min-height: 25px; + min-height: 1.5625rem; align-items: center; - padding: 0 10px; + padding: 0 0.625rem; background-color: white; display: inline-flex; cursor: default; - border: solid 1px; - border-left: solid 4px; + border: solid 0.0625rem; + border-left: solid 0.25rem; &.cps-tag--selectable { cursor: pointer; - &:not(:active):not(:disabled) { - box-shadow: 1px 3px 4px rgb(0 0 0 / 10%); + &:not(:active):not(.cps-tag--pressing) { + box-shadow: 0.0625rem 0.1875rem 0.25rem rgb(0 0 0 / 10%); + } + &:focus { + outline: none; + } + &:focus-visible { + @include focus-ring(0.25rem, 0.1875rem, 0); } } &.cps-tag--disabled { pointer-events: none; - border-color: var(--cps-color-line-dark) !important; - p { - color: var(--cps-color-text-light); + border-color: var(--cps-color-text-lightest) !important; + span { + color: var(--cps-color-text-mild); } } @@ -35,9 +43,9 @@ border-color: var(--cps-color-text-light) !important; } - p { + span { margin: 0; - font-size: 11px; + font-size: 0.6875rem; color: var(--cps-color-text-dark); } } diff --git a/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.spec.ts index 8aa058563..502ceec22 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.spec.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.spec.ts @@ -13,6 +13,7 @@ describe('CpsTagComponent', () => { fixture = TestBed.createComponent(CpsTagComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('label', 'Tag'); fixture.detectChanges(); }); @@ -21,17 +22,17 @@ describe('CpsTagComponent', () => { }); it('should have default values', () => { - expect(component.label).toBe(''); expect(component.color).toBe('calm'); expect(component.disabled).toBe(false); expect(component.selectable).toBe(false); expect(component.value).toBe(false); + expect(component.pressing).toBe(false); }); it('should render label', () => { fixture.componentRef.setInput('label', 'Test Tag'); fixture.detectChanges(); - const tag = fixture.nativeElement.querySelector('p'); + const tag = fixture.nativeElement.querySelector('span'); expect(tag.textContent).toContain('Test Tag'); }); @@ -103,7 +104,7 @@ describe('CpsTagComponent', () => { it('should call setClasses on ngOnChanges', () => { const spy = jest.spyOn(component, 'setClasses'); - fixture.componentRef.setInput('color', 'calm'); + fixture.componentRef.setInput('selectable', true); fixture.detectChanges(); expect(spy).toHaveBeenCalled(); }); @@ -127,4 +128,206 @@ describe('CpsTagComponent', () => { component.value = true; expect(fn).toHaveBeenCalledWith(true); }); + + describe('ARIA attributes', () => { + it('should set role="checkbox" when selectable', () => { + fixture.componentRef.setInput('selectable', true); + fixture.detectChanges(); + const div = fixture.nativeElement.querySelector('div'); + expect(div.getAttribute('role')).toBe('checkbox'); + }); + + it('should not set role when not selectable', () => { + fixture.detectChanges(); + const div = fixture.nativeElement.querySelector('div'); + expect(div.getAttribute('role')).toBeNull(); + }); + + it('should set aria-checked to false when selectable and not selected', () => { + fixture.componentRef.setInput('selectable', true); + fixture.detectChanges(); + const div = fixture.nativeElement.querySelector('div'); + expect(div.getAttribute('aria-checked')).toBe('false'); + }); + + it('should set aria-checked to true when selectable and selected', () => { + fixture.componentRef.setInput('selectable', true); + fixture.detectChanges(); + component.toggleSelected(); + fixture.detectChanges(); + const div = fixture.nativeElement.querySelector('div'); + expect(div.getAttribute('aria-checked')).toBe('true'); + }); + + it('should not set aria-checked when not selectable', () => { + fixture.detectChanges(); + const div = fixture.nativeElement.querySelector('div'); + expect(div.getAttribute('aria-checked')).toBeNull(); + }); + + it('should set aria-disabled when selectable and disabled', () => { + fixture.componentRef.setInput('selectable', true); + fixture.componentRef.setInput('disabled', true); + fixture.detectChanges(); + const div = fixture.nativeElement.querySelector('div'); + expect(div.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should not set aria-disabled when not disabled', () => { + fixture.componentRef.setInput('selectable', true); + fixture.detectChanges(); + const div = fixture.nativeElement.querySelector('div'); + expect(div.getAttribute('aria-disabled')).toBeNull(); + }); + + it('should not set aria-disabled on non-selectable tags', () => { + fixture.componentRef.setInput('disabled', true); + fixture.detectChanges(); + const div = fixture.nativeElement.querySelector('div'); + expect(div.getAttribute('aria-disabled')).toBeNull(); + }); + }); + + describe('tabindex', () => { + it('should set tabindex="0" when selectable and not disabled', () => { + fixture.componentRef.setInput('selectable', true); + fixture.detectChanges(); + const div = fixture.nativeElement.querySelector('div'); + expect(div.getAttribute('tabindex')).toBe('0'); + }); + + it('should set tabindex="-1" when selectable and disabled', () => { + fixture.componentRef.setInput('selectable', true); + fixture.componentRef.setInput('disabled', true); + fixture.detectChanges(); + const div = fixture.nativeElement.querySelector('div'); + expect(div.getAttribute('tabindex')).toBe('-1'); + }); + + it('should not set tabindex when not selectable', () => { + fixture.detectChanges(); + const div = fixture.nativeElement.querySelector('div'); + expect(div.getAttribute('tabindex')).toBeNull(); + }); + }); + + describe('keyboard interaction', () => { + beforeEach(() => { + component.selectable = true; + }); + + it('should toggle on Enter keydown', () => { + const event = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true + }); + component.handleEnterKeydown(event); + expect(component.value).toBe(true); + }); + + it('should not toggle on repeated Enter keydown', () => { + const event = new KeyboardEvent('keydown', { + key: 'Enter', + repeat: true, + bubbles: true + }); + component.handleEnterKeydown(event); + expect(component.value).toBe(false); + }); + + it('should prevent default on Enter keydown', () => { + const event = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true + }); + jest.spyOn(event, 'preventDefault'); + component.handleEnterKeydown(event); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('should set pressing=true on Space keydown', () => { + const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true }); + component.handleSpaceKeydown(event); + expect(component.pressing).toBe(true); + }); + + it('should not toggle on Space keydown', () => { + const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true }); + component.handleSpaceKeydown(event); + expect(component.value).toBe(false); + }); + + it('should prevent default on Space keydown', () => { + const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true }); + jest.spyOn(event, 'preventDefault'); + component.handleSpaceKeydown(event); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('should toggle and clear pressing on toggleSelected (Space keyup)', () => { + component.pressing = true; + component.toggleSelected(); + expect(component.value).toBe(true); + expect(component.pressing).toBe(false); + }); + + it('should clear pressing even when disabled', () => { + component.disabled = true; + component.pressing = true; + component.toggleSelected(); + expect(component.pressing).toBe(false); + expect(component.value).toBe(false); + }); + }); + + describe('_logMissingLabelError', () => { + let errorFixture: ComponentFixture; + let errorComponent: CpsTagComponent; + + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + errorFixture = TestBed.createComponent(CpsTagComponent); + errorComponent = errorFixture.componentInstance; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should log error on init when label is empty', () => { + errorFixture.detectChanges(); + expect(console.error).toHaveBeenCalledWith( + 'CpsTagComponent: the tag must have a label.' + ); + }); + + it('should log error on init when label is whitespace only', () => { + errorFixture.componentRef.setInput('label', ' '); + errorFixture.detectChanges(); + expect(console.error).toHaveBeenCalledWith( + 'CpsTagComponent: the tag must have a label.' + ); + }); + + it('should not log error when label is set', () => { + errorFixture.componentRef.setInput('label', 'Tag'); + errorFixture.detectChanges(); + expect(console.error).not.toHaveBeenCalled(); + }); + + it('should log error on ngOnChanges when label becomes empty', () => { + errorFixture.componentRef.setInput('label', 'Tag'); + errorFixture.detectChanges(); + jest.clearAllMocks(); + errorFixture.componentRef.setInput('label', ''); + errorFixture.detectChanges(); + expect(console.error).toHaveBeenCalledWith( + 'CpsTagComponent: the tag must have a label.' + ); + }); + + it('should not set errorComponent label', () => { + expect(errorComponent.label).toBe(''); + }); + }); }); diff --git a/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.ts b/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.ts index 43f0cb5b8..915ceed15 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.ts @@ -4,10 +4,12 @@ import { EventEmitter, Inject, Input, + OnInit, OnChanges, Optional, Output, - Self + Self, + type SimpleChanges } from '@angular/core'; import { getCSSColor } from '../../utils/colors-utils'; import { ControlValueAccessor, NgControl } from '@angular/forms'; @@ -22,7 +24,9 @@ import { ControlValueAccessor, NgControl } from '@angular/forms'; templateUrl: './cps-tag.component.html', styleUrls: ['./cps-tag.component.scss'] }) -export class CpsTagComponent implements ControlValueAccessor, OnChanges { +export class CpsTagComponent + implements ControlValueAccessor, OnInit, OnChanges +{ /** * Label of the tag. * @group Props @@ -69,6 +73,8 @@ export class CpsTagComponent implements ControlValueAccessor, OnChanges { @Output() valueChanged = new EventEmitter(); classesList: string[] = []; + pressing = false; + cvtColor = ''; private _value = false; @@ -81,20 +87,32 @@ export class CpsTagComponent implements ControlValueAccessor, OnChanges { } } - ngOnChanges(): void { - this.color = getCSSColor(this.color, this.document); + ngOnInit(): void { + this.cvtColor = getCSSColor(this.color, this.document); this.setClasses(); + this._logMissingLabelError(); } - setClasses(): void { - this.classesList = ['cps-tag']; + ngOnChanges(changes: SimpleChanges): void { + if (changes.color) { + this.cvtColor = getCSSColor(this.color, this.document); + } + if (changes.selectable || changes.disabled) { + this.setClasses(); + } + this._logMissingLabelError(); + } + + setClasses(): void { + const classes = ['cps-tag']; if (this.selectable) { - this.classesList.push('cps-tag--selectable'); + classes.push('cps-tag--selectable'); } if (this.disabled) { - this.classesList.push('cps-tag--disabled'); + classes.push('cps-tag--disabled'); } + this.classesList = classes; } // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -115,7 +133,19 @@ export class CpsTagComponent implements ControlValueAccessor, OnChanges { this.value = value; } + handleEnterKeydown(event: Event) { + if ((event as KeyboardEvent).repeat) return; + event.preventDefault(); + this.toggleSelected(); + } + + handleSpaceKeydown(event: Event) { + event.preventDefault(); + this.pressing = true; + } + toggleSelected() { + this.pressing = false; if (this.disabled || !this.selectable) return; this._updateValue(!this.value); } @@ -125,4 +155,10 @@ export class CpsTagComponent implements ControlValueAccessor, OnChanges { this.onChange(value); this.valueChanged.emit(value); } + + private _logMissingLabelError() { + if (!this.label?.trim()) { + console.error('CpsTagComponent: the tag must have a label.'); + } + } }