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.');
+ }
+ }
}