diff --git a/playwright/cps-accessibility.spec.ts b/playwright/cps-accessibility.spec.ts
index b2b7ecfb3..35bbfdc67 100644
--- a/playwright/cps-accessibility.spec.ts
+++ b/playwright/cps-accessibility.spec.ts
@@ -121,7 +121,22 @@ const components: ComponentEntry[] = [
// { route: '/icon', name: 'Icon', selector: 'cps-icon' },
{ route: '/info-circle', name: 'Info circle', selector: 'cps-info-circle' },
{ route: '/input', name: 'Input', selector: '.example-content cps-input' },
- // { route: '/loader', name: 'Loader', selector: 'cps-loader' },
+ { route: '/loader', name: 'Loader', selector: '.example-content cps-loader' },
+ {
+ route: '/loader',
+ name: 'Loader fullscreen',
+ selector: '.example-content cps-loader',
+ setup: async (page) => {
+ await page.waitForSelector('.example-content');
+ await page
+ .locator('.example-content cps-button')
+ .filter({ hasText: /Toggle fullscreen loader/i })
+ .click();
+ await page.waitForSelector(
+ '.cps-loader-overlay[style*="position: fixed"]'
+ );
+ }
+ },
{
route: '/menu',
name: 'Menu standard',
diff --git a/projects/composition/src/app/api-data/cps-loader.json b/projects/composition/src/app/api-data/cps-loader.json
index e4d2c2384..173c03d20 100644
--- a/projects/composition/src/app/api-data/cps-loader.json
+++ b/projects/composition/src/app/api-data/cps-loader.json
@@ -36,6 +36,30 @@
"type": "boolean",
"default": "true",
"description": "Determines whether to show 'Loading...' label."
+ },
+ {
+ "name": "label",
+ "optional": false,
+ "readonly": false,
+ "type": "string",
+ "default": "Loading...",
+ "description": "Text shown visually when showLabel is true."
+ },
+ {
+ "name": "ariaLabel",
+ "optional": false,
+ "readonly": false,
+ "type": "string",
+ "default": "Loading",
+ "description": "Text announced by screen readers. Used when showLabel is false or label\nis empty."
+ },
+ {
+ "name": "doneAriaLabel",
+ "optional": false,
+ "readonly": false,
+ "type": "string",
+ "default": "Loading complete",
+ "description": "Text announced by screen readers when the loader is destroyed."
}
]
}
diff --git a/projects/composition/src/app/pages/loader-page/loader-page.component.scss b/projects/composition/src/app/pages/loader-page/loader-page.component.scss
index 8dcf78cf1..2180af06f 100644
--- a/projects/composition/src/app/pages/loader-page/loader-page.component.scss
+++ b/projects/composition/src/app/pages/loader-page/loader-page.component.scss
@@ -1,19 +1,20 @@
$loaders-label-color: var(--cps-color-text-darkest);
.loaders-group {
- gap: 30px;
+ gap: 1.875rem;
+ margin-left: 0.5rem;
display: flex;
flex-direction: column;
&-label {
- margin-bottom: 8px;
+ margin-bottom: 0.5rem;
line-height: 2;
color: $loaders-label-color;
}
.loader-container {
- width: 450px;
- height: 200px;
- border: 1px solid lightgrey;
+ width: 28.125rem;
+ height: 12.5rem;
+ border: 0.0625rem solid lightgrey;
}
}
diff --git a/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.spec.ts
index 62969c9ab..211b44074 100644
--- a/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.spec.ts
+++ b/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.spec.ts
@@ -281,18 +281,28 @@ describe('CpsButtonComponent', () => {
expect(button.getAttribute('type')).toBe('submit');
});
- it('should fall back to "button" native type if nativeType set to null', () => {
+ it('should fall back to "button" and warn if nativeType set to null', () => {
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
fixture.componentRef.setInput('nativeType', null);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button.getAttribute('type')).toBe('button');
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Invalid nativeType value')
+ );
+ warnSpy.mockRestore();
});
- it('should fall back to "button" native type if nativeType set to undefined', () => {
+ it('should fall back to "button" and warn if nativeType set to undefined', () => {
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
fixture.componentRef.setInput('nativeType', undefined);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button.getAttribute('type')).toBe('button');
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Invalid nativeType value')
+ );
+ warnSpy.mockRestore();
});
it('should fall back to "button" and warn if nativeType is an invalid string', () => {
diff --git a/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.html b/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.html
index 7a6ce3895..5df7799e4 100644
--- a/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.html
+++ b/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.html
@@ -5,12 +5,15 @@
position: fullScreen ? 'fixed' : 'relative'
}">
- @if (showLabel) {
-
+ @if (showLabel && label.trim()) {
+
+ {{ label }}
+
}
-
+
diff --git a/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.scss b/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.scss
index 5905270b4..250fcc4d7 100644
--- a/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.scss
+++ b/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.scss
@@ -23,25 +23,23 @@ $color-inner: var(--cps-color-energy);
&-text {
display: block;
text-align: center;
- font-size: 20px;
+ font-size: 1.25rem;
font-family: 'Source Sans Pro', sans-serif;
font-weight: 600;
- padding-bottom: 15px;
+ padding-bottom: 0.9375rem;
animation: cps-loader-text-animation 4s linear infinite;
- -webkit-user-select: none; /* Safari */
- -ms-user-select: none; /* IE 10 and IE 11 */
- user-select: none; /* Standard syntax */
+ user-select: none;
}
&-circles {
- height: 85px;
- width: 90px;
+ height: 5.3125rem;
+ width: 5.625rem;
&-circle {
- border-left: 5px solid;
+ border-left: 0.3125rem solid;
border-top-left-radius: 100%;
- border-top: 5px solid;
- margin: 5px;
+ border-top: 0.3125rem solid;
+ margin: 0.3125rem;
animation-name: cps-loader-circles-animation;
animation-duration: 1000ms;
animation-timing-function: linear;
@@ -54,20 +52,20 @@ $color-inner: var(--cps-color-energy);
.cps-sp1 {
border-left-color: $color-outer;
border-top-color: $color-outer;
- width: 40px;
- height: 40px;
+ width: 2.5rem;
+ height: 2.5rem;
}
.cps-sp2 {
border-left-color: $color-middle;
border-top-color: $color-middle;
- width: 30px;
- height: 30px;
+ width: 1.875rem;
+ height: 1.875rem;
}
.cps-sp3 {
- width: 20px;
- height: 20px;
+ width: 1.25rem;
+ height: 1.25rem;
border-left-color: $color-inner;
border-top-color: $color-inner;
}
diff --git a/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.spec.ts
index a2175b82d..9d9ce37c6 100644
--- a/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.spec.ts
+++ b/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.spec.ts
@@ -1,13 +1,20 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CpsLoaderComponent } from './cps-loader.component';
+import { CPS_LIVE_ANNOUNCER_SERVICE } from '../../services/cps-live-announcer/cps-live-announcer.service';
describe('CpsLoaderComponent', () => {
let component: CpsLoaderComponent;
let fixture: ComponentFixture
;
+ let mockAnnouncer: { announce: jest.Mock };
beforeEach(async () => {
+ mockAnnouncer = { announce: jest.fn() };
+
await TestBed.configureTestingModule({
- imports: [CpsLoaderComponent]
+ imports: [CpsLoaderComponent],
+ providers: [
+ { provide: CPS_LIVE_ANNOUNCER_SERVICE, useValue: mockAnnouncer }
+ ]
}).compileComponents();
fixture = TestBed.createComponent(CpsLoaderComponent);
@@ -24,6 +31,9 @@ describe('CpsLoaderComponent', () => {
expect(component.opacity).toBe(0.1);
expect(component.labelColor).toBeTruthy();
expect(component.showLabel).toBe(true);
+ expect(component.label).toBe('Loading...');
+ expect(component.ariaLabel).toBe('Loading');
+ expect(component.doneAriaLabel).toBe('Loading complete');
});
it('should display loading label by default', () => {
@@ -31,7 +41,7 @@ describe('CpsLoaderComponent', () => {
'.cps-loader-overlay-content-text'
);
expect(label).toBeTruthy();
- expect(label.textContent).toContain('Loading...');
+ expect(label.textContent.trim()).toBe('Loading...');
});
it('should hide loading label when showLabel is false', () => {
@@ -72,4 +82,168 @@ describe('CpsLoaderComponent', () => {
it('should have correct default opacity', () => {
expect(component.backgroundColor).toContain('0.1');
});
+
+ describe('Accessibility (a11y)', () => {
+ it('should NOT have aria-label on the overlay', () => {
+ const overlay = fixture.nativeElement.querySelector(
+ '.cps-loader-overlay'
+ );
+ expect(overlay.getAttribute('aria-label')).toBeNull();
+ });
+
+ it('should have aria-busy="true" on the host element', () => {
+ expect(fixture.nativeElement.getAttribute('aria-busy')).toBe('true');
+ });
+
+ it('should render the loading text as a span, not a label element', () => {
+ const span = fixture.nativeElement.querySelector(
+ 'span.cps-loader-overlay-content-text'
+ );
+ const labelEl = fixture.nativeElement.querySelector(
+ 'label.cps-loader-overlay-content-text'
+ );
+ expect(span).toBeTruthy();
+ expect(labelEl).toBeFalsy();
+ });
+
+ it('should render the label input text in the visible span', () => {
+ fixture.componentRef.setInput('label', 'Saving...');
+ fixture.detectChanges();
+ const span = fixture.nativeElement.querySelector(
+ '.cps-loader-overlay-content-text'
+ );
+ expect(span.textContent.trim()).toBe('Saving...');
+ });
+
+ it('should have aria-hidden="true" on the circles container', () => {
+ const circles = fixture.nativeElement.querySelector(
+ '.cps-loader-overlay-content-circles'
+ );
+ expect(circles.getAttribute('aria-hidden')).toBe('true');
+ });
+
+ it('should have aria-hidden="true" on the visible text span', () => {
+ const span = fixture.nativeElement.querySelector(
+ '.cps-loader-overlay-content-text'
+ );
+ expect(span.getAttribute('aria-hidden')).toBe('true');
+ });
+
+ describe('live announcements', () => {
+ it('should announce label text on init', () => {
+ expect(mockAnnouncer.announce).toHaveBeenCalledWith('Loading...');
+ });
+
+ it('should announce ariaLabel when showLabel is false', () => {
+ mockAnnouncer.announce.mockClear();
+ fixture.componentRef.setInput('showLabel', false);
+ component.ngAfterViewInit();
+ expect(mockAnnouncer.announce).toHaveBeenCalledWith('Loading');
+ });
+
+ it('should announce ariaLabel when label is empty', () => {
+ mockAnnouncer.announce.mockClear();
+ fixture.componentRef.setInput('label', '');
+ component.ngAfterViewInit();
+ expect(mockAnnouncer.announce).toHaveBeenCalledWith('Loading');
+ });
+
+ it('should announce custom ariaLabel', () => {
+ mockAnnouncer.announce.mockClear();
+ fixture.componentRef.setInput('showLabel', false);
+ fixture.componentRef.setInput('ariaLabel', 'Saving...');
+ component.ngAfterViewInit();
+ expect(mockAnnouncer.announce).toHaveBeenCalledWith('Saving...');
+ });
+
+ it('should announce doneAriaLabel on destroy', () => {
+ mockAnnouncer.announce.mockClear();
+ fixture.destroy();
+ expect(mockAnnouncer.announce).toHaveBeenCalledWith('Loading complete');
+ });
+
+ it('should announce custom doneAriaLabel on destroy', () => {
+ fixture.componentRef.setInput('doneAriaLabel', 'Saving complete');
+ mockAnnouncer.announce.mockClear();
+ fixture.destroy();
+ expect(mockAnnouncer.announce).toHaveBeenCalledWith('Saving complete');
+ });
+
+ it('should NOT announce on destroy when doneAriaLabel is empty', () => {
+ fixture.componentRef.setInput('doneAriaLabel', '');
+ mockAnnouncer.announce.mockClear();
+ fixture.destroy();
+ expect(mockAnnouncer.announce).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('logMissingAriaLabelError', () => {
+ let errorFixture: ComponentFixture;
+
+ beforeEach(() => {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ errorFixture = TestBed.createComponent(CpsLoaderComponent);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('should not log error by default', () => {
+ errorFixture.detectChanges();
+ expect(console.error).not.toHaveBeenCalled();
+ });
+
+ it('should log error on init when both label and ariaLabel are empty', () => {
+ errorFixture.componentRef.setInput('label', '');
+ errorFixture.componentRef.setInput('ariaLabel', '');
+ errorFixture.detectChanges();
+ expect(console.error).toHaveBeenCalledWith(
+ 'CpsLoaderComponent: unlabeled component must have an ariaLabel for accessibility.'
+ );
+ });
+
+ it('should log error on init when both label and ariaLabel are whitespace only', () => {
+ errorFixture.componentRef.setInput('label', ' ');
+ errorFixture.componentRef.setInput('ariaLabel', ' ');
+ errorFixture.detectChanges();
+ expect(console.error).toHaveBeenCalledWith(
+ 'CpsLoaderComponent: unlabeled component must have an ariaLabel for accessibility.'
+ );
+ });
+
+ it('should not log error when label is set and ariaLabel is empty', () => {
+ errorFixture.componentRef.setInput('ariaLabel', '');
+ errorFixture.detectChanges();
+ expect(console.error).not.toHaveBeenCalled();
+ });
+
+ it('should not log error when ariaLabel is set and label is empty', () => {
+ errorFixture.componentRef.setInput('label', '');
+ errorFixture.detectChanges();
+ expect(console.error).not.toHaveBeenCalled();
+ });
+
+ it('should log error on ngOnChanges when both label and ariaLabel become empty', () => {
+ errorFixture.detectChanges();
+ jest.clearAllMocks();
+ errorFixture.componentRef.setInput('label', '');
+ errorFixture.componentRef.setInput('ariaLabel', '');
+ errorFixture.detectChanges();
+ expect(console.error).toHaveBeenCalledWith(
+ 'CpsLoaderComponent: unlabeled component must have an ariaLabel for accessibility.'
+ );
+ });
+
+ it('should not log error on ngOnChanges when ariaLabel is set while label is empty', () => {
+ errorFixture.componentRef.setInput('label', '');
+ errorFixture.componentRef.setInput('ariaLabel', '');
+ errorFixture.detectChanges();
+ jest.clearAllMocks();
+ errorFixture.componentRef.setInput('ariaLabel', 'Loading...');
+ errorFixture.detectChanges();
+ expect(console.error).not.toHaveBeenCalled();
+ });
+ });
+ });
});
diff --git a/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.ts b/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.ts
index 371e371eb..b0ac70864 100644
--- a/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.ts
+++ b/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.ts
@@ -1,6 +1,17 @@
import { CommonModule, DOCUMENT } from '@angular/common';
-import { Component, Inject, Input, OnInit } from '@angular/core';
+import {
+ AfterViewInit,
+ Component,
+ inject,
+ Input,
+ OnChanges,
+ OnDestroy,
+ OnInit,
+ type SimpleChanges
+} from '@angular/core';
import { getCSSColor } from '../../utils/colors-utils';
+import { logMissingAriaLabelError } from '../../utils/internal/accessibility-utils';
+import { CPS_LIVE_ANNOUNCER_SERVICE } from '../../services/cps-live-announcer/cps-live-announcer.service';
/**
* CpsLoaderComponent is a fetch data indicator.
@@ -10,9 +21,12 @@ import { getCSSColor } from '../../utils/colors-utils';
imports: [CommonModule],
selector: 'cps-loader',
templateUrl: './cps-loader.component.html',
- styleUrls: ['./cps-loader.component.scss']
+ styleUrls: ['./cps-loader.component.scss'],
+ host: { 'aria-busy': 'true' }
})
-export class CpsLoaderComponent implements OnInit {
+export class CpsLoaderComponent
+ implements OnInit, OnChanges, AfterViewInit, OnDestroy
+{
/**
* Option for loader component to take up the whole screen.
* @group Props
@@ -37,13 +51,56 @@ export class CpsLoaderComponent implements OnInit {
*/
@Input() showLabel = true;
+ /**
+ * Text shown visually when showLabel is true.
+ * @group Props
+ */
+ @Input() label = 'Loading...';
+
+ /**
+ * Text announced by screen readers. Used when showLabel is false or label
+ * is empty.
+ * @group Props
+ */
+ @Input() ariaLabel = 'Loading';
+
+ /**
+ * Text announced by screen readers when the loader is destroyed.
+ * @group Props
+ */
+ @Input() doneAriaLabel = 'Loading complete';
+
backgroundColor = 'var(--cps-surface-overlay)';
+ cvtLabelColor = '';
- // eslint-disable-next-line no-useless-constructor
- constructor(@Inject(DOCUMENT) private document: Document) {}
+ private _document = inject(DOCUMENT);
+ private _announcer = inject(CPS_LIVE_ANNOUNCER_SERVICE);
ngOnInit(): void {
this.backgroundColor = `rgba(0, 0, 0, ${this.opacity})`;
- this.labelColor = getCSSColor(this.labelColor, this.document);
+ this.cvtLabelColor = getCSSColor(this.labelColor, this._document);
+ logMissingAriaLabelError('CpsLoaderComponent', this.label, this.ariaLabel);
+ }
+
+ ngAfterViewInit(): void {
+ this._announcer?.announce(
+ this.showLabel && this.label.trim() ? this.label : this.ariaLabel
+ );
+ }
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes.opacity) {
+ this.backgroundColor = `rgba(0, 0, 0, ${this.opacity})`;
+ }
+ if (changes.labelColor) {
+ this.cvtLabelColor = getCSSColor(this.labelColor, this._document);
+ }
+ logMissingAriaLabelError('CpsLoaderComponent', this.label, this.ariaLabel);
+ }
+
+ ngOnDestroy(): void {
+ if (this.doneAriaLabel.trim()) {
+ this._announcer?.announce(this.doneAriaLabel);
+ }
}
}
diff --git a/projects/cps-ui-kit/src/lib/services/cps-live-announcer/cps-live-announcer.service.spec.ts b/projects/cps-ui-kit/src/lib/services/cps-live-announcer/cps-live-announcer.service.spec.ts
new file mode 100644
index 000000000..2caee2132
--- /dev/null
+++ b/projects/cps-ui-kit/src/lib/services/cps-live-announcer/cps-live-announcer.service.spec.ts
@@ -0,0 +1,191 @@
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+import {
+ CpsLiveAnnouncerPoliteness,
+ CpsLiveAnnouncerService
+} from './cps-live-announcer.service';
+
+describe('CpsLiveAnnouncerService', () => {
+ let service: CpsLiveAnnouncerService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(CpsLiveAnnouncerService);
+ });
+
+ afterEach(() => {
+ service.ngOnDestroy();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should add a polite live region to document.body on creation', () => {
+ expect(document.body.querySelector('[aria-live="polite"]')).toBeTruthy();
+ });
+
+ it('should add an assertive live region to document.body on creation', () => {
+ expect(document.body.querySelector('[aria-live="assertive"]')).toBeTruthy();
+ });
+
+ it('should reuse existing live region elements instead of creating duplicates', () => {
+ TestBed.inject(CpsLiveAnnouncerService);
+ expect(
+ document.body.querySelectorAll('.cps-polite-live-announcer-element')
+ .length
+ ).toBe(1);
+ expect(
+ document.body.querySelectorAll('.cps-assertive-live-announcer-element')
+ .length
+ ).toBe(1);
+ });
+
+ it('should set aria-atomic="true" on both live regions', () => {
+ const polite = document.body.querySelector('[aria-live="polite"]');
+ const assertive = document.body.querySelector('[aria-live="assertive"]');
+ expect(polite?.getAttribute('aria-atomic')).toBe('true');
+ expect(assertive?.getAttribute('aria-atomic')).toBe('true');
+ });
+
+ it('should apply the correct classes to each live region element', () => {
+ expect(
+ document.body.querySelector('.cps-polite-live-announcer-element')
+ ).toBeTruthy();
+ expect(
+ document.body.querySelector('.cps-assertive-live-announcer-element')
+ ).toBeTruthy();
+ });
+
+ describe('announce', () => {
+ it('should set polite live region text on next tick', fakeAsync(() => {
+ service.announce('Loading...');
+ tick(0);
+ expect(
+ document.body.querySelector('[aria-live="polite"]')?.textContent
+ ).toBe('Loading...');
+ }));
+
+ it('should set assertive live region text on next tick', fakeAsync(() => {
+ service.announce('Error occurred', 'assertive');
+ tick(0);
+ expect(
+ document.body.querySelector('[aria-live="assertive"]')?.textContent
+ ).toBe('Error occurred');
+ }));
+
+ it('should default to polite politeness', fakeAsync(() => {
+ service.announce('Hello');
+ tick(0);
+ const politeText = document.body.querySelector(
+ '[aria-live="polite"]'
+ )?.textContent;
+ const assertiveText = document.body.querySelector(
+ '[aria-live="assertive"]'
+ )?.textContent;
+ expect(politeText).toBe('Hello');
+ expect(assertiveText).toBe('');
+ }));
+
+ it('should clear existing text before setting new text', fakeAsync(() => {
+ service.announce('First');
+ tick(0);
+ service.announce('Second');
+ const el = document.body.querySelector('[aria-live="polite"]');
+ expect(el?.textContent).toBe('');
+ tick(0);
+ expect(el?.textContent).toBe('Second');
+ }));
+
+ it('should re-announce the same message', fakeAsync(() => {
+ service.announce('Loading...');
+ tick(0);
+ service.announce('Loading...');
+ const el = document.body.querySelector('[aria-live="polite"]');
+ expect(el?.textContent).toBe('');
+ tick(0);
+ expect(el?.textContent).toBe('Loading...');
+ }));
+
+ it('should clear the region after durationMs', fakeAsync(() => {
+ service.announce('Loading...', 'polite', 5000);
+ tick(0);
+ const el = document.body.querySelector('[aria-live="polite"]');
+ expect(el?.textContent).toBe('Loading...');
+ tick(5000);
+ expect(el?.textContent).toBe('');
+ }));
+
+ it('should not clear the region when durationMs is 0', fakeAsync(() => {
+ service.announce('Loading...', 'polite', 0);
+ tick(0);
+ const el = document.body.querySelector('[aria-live="polite"]');
+ expect(el?.textContent).toBe('Loading...');
+ tick(10000);
+ expect(el?.textContent).toBe('Loading...');
+ }));
+
+ it('should cancel the clear timer when re-announced before it fires', fakeAsync(() => {
+ service.announce('First', 'polite', 2000);
+ tick(0);
+ service.announce('Second', 'polite', 5000);
+ tick(0);
+ const el = document.body.querySelector('[aria-live="polite"]');
+ tick(2000);
+ expect(el?.textContent).toBe('Second');
+ tick(3000);
+ expect(el?.textContent).toBe('');
+ }));
+ });
+
+ describe('clear', () => {
+ it('should clear the polite region by default', fakeAsync(() => {
+ service.announce('Message');
+ tick(0);
+ service.clear();
+ expect(
+ document.body.querySelector('[aria-live="polite"]')?.textContent
+ ).toBe('');
+ }));
+
+ it('should clear the assertive region when specified', fakeAsync(() => {
+ service.announce('Alert', 'assertive');
+ tick(0);
+ service.clear('assertive');
+ expect(
+ document.body.querySelector('[aria-live="assertive"]')?.textContent
+ ).toBe('');
+ }));
+
+ it('should cancel the duration timer when clear is called', fakeAsync(() => {
+ service.announce('Message', 'polite', 3000);
+ tick(0);
+ service.clear();
+ tick(3000);
+ expect(
+ document.body.querySelector('[aria-live="polite"]')?.textContent
+ ).toBe('');
+ }));
+ });
+
+ describe('ngOnDestroy', () => {
+ it('should remove both live regions from document.body', () => {
+ service.ngOnDestroy();
+ expect(document.body.querySelector('[aria-live="polite"]')).toBeNull();
+ expect(document.body.querySelector('[aria-live="assertive"]')).toBeNull();
+ });
+ });
+
+ describe('politeness type', () => {
+ it.each([['polite'], ['assertive']] as CpsLiveAnnouncerPoliteness[][])(
+ 'should accept %s politeness',
+ fakeAsync((politeness: CpsLiveAnnouncerPoliteness) => {
+ service.announce('Test', politeness);
+ tick(0);
+ expect(
+ document.body.querySelector(`[aria-live="${politeness}"]`)
+ ?.textContent
+ ).toBe('Test');
+ })
+ );
+ });
+});
diff --git a/projects/cps-ui-kit/src/lib/services/cps-live-announcer/cps-live-announcer.service.ts b/projects/cps-ui-kit/src/lib/services/cps-live-announcer/cps-live-announcer.service.ts
new file mode 100644
index 000000000..68ece0991
--- /dev/null
+++ b/projects/cps-ui-kit/src/lib/services/cps-live-announcer/cps-live-announcer.service.ts
@@ -0,0 +1,112 @@
+import { inject, Injectable, InjectionToken, OnDestroy } from '@angular/core';
+import { DOCUMENT } from '@angular/common';
+
+export type CpsLiveAnnouncerPoliteness = 'polite' | 'assertive';
+
+type RegionState = {
+ el: HTMLElement;
+ writeTimer: ReturnType | undefined;
+ clearTimer: ReturnType | undefined;
+};
+
+/**
+ * Service for making accessible live-region announcements without relying on a
+ * specific component's live region. Creates two persistent hidden elements in
+ * document.body (one polite, one assertive) that screen readers monitor for
+ * content changes.
+ */
+@Injectable({ providedIn: 'root' })
+export class CpsLiveAnnouncerService implements OnDestroy {
+ private _document = inject(DOCUMENT);
+ private _regions: Record = {
+ polite: {
+ el: this._createElement('polite'),
+ writeTimer: undefined,
+ clearTimer: undefined
+ },
+ assertive: {
+ el: this._createElement('assertive'),
+ writeTimer: undefined,
+ clearTimer: undefined
+ }
+ };
+
+ /**
+ * Announces a message through the appropriate live region. Clears any
+ * existing text first so the same message can be re-announced, then writes
+ * the new text on the next tick so screen readers observe the content change.
+ * After `duration` ms the region is cleared automatically; pass `0` to keep
+ * the message indefinitely. Defaults to 5000ms.
+ */
+ announce(
+ message: string,
+ politeness: CpsLiveAnnouncerPoliteness = 'polite',
+ durationMs = 5000
+ ): void {
+ this.clear(politeness);
+ const region = this._regions[politeness];
+ region.writeTimer = setTimeout(() => {
+ region.writeTimer = undefined;
+ region.el.textContent = message;
+ if (durationMs > 0) {
+ region.clearTimer = setTimeout(() => {
+ region.clearTimer = undefined;
+ region.el.textContent = '';
+ }, durationMs);
+ }
+ });
+ }
+
+ /**
+ * Clears the live region without making a new announcement.
+ */
+ clear(politeness: CpsLiveAnnouncerPoliteness = 'polite'): void {
+ const region = this._regions[politeness];
+ clearTimeout(region.writeTimer);
+ clearTimeout(region.clearTimer);
+ region.writeTimer = undefined;
+ region.clearTimer = undefined;
+ region.el.textContent = '';
+ }
+
+ ngOnDestroy(): void {
+ for (const region of Object.values(this._regions)) {
+ clearTimeout(region.writeTimer);
+ clearTimeout(region.clearTimer);
+ region.el.remove();
+ }
+ }
+
+ private _createElement(politeness: CpsLiveAnnouncerPoliteness): HTMLElement {
+ const cls = `cps-${politeness}-live-announcer-element`;
+ const existing = this._document.body.querySelector(`.${cls}`);
+ if (existing) return existing;
+ const el = this._document.createElement('div');
+ el.setAttribute('aria-live', politeness);
+ el.setAttribute('aria-atomic', 'true');
+ el.className = `cps-sr-only ${cls}`;
+ this._document.body.appendChild(el);
+ return el;
+ }
+}
+
+/**
+ * Injection token for the live-announcer service.
+ *
+ * By default it resolves to the singleton {@link CpsLiveAnnouncerService}.
+ * Consumer applications can override it to supply a custom implementation or
+ * provide `null` to disable all live-region announcements.
+ *
+ * @example Disable announcements:
+ * ```typescript
+ * providers: [{ provide: CPS_LIVE_ANNOUNCER_SERVICE, useValue: null }]
+ * ```
+ */
+export const CPS_LIVE_ANNOUNCER_SERVICE =
+ new InjectionToken(
+ 'CpsLiveAnnouncerService',
+ {
+ providedIn: 'root',
+ factory: () => inject(CpsLiveAnnouncerService)
+ }
+ );
diff --git a/projects/cps-ui-kit/src/public-api.ts b/projects/cps-ui-kit/src/public-api.ts
index 797cb1af9..30fb34bb7 100644
--- a/projects/cps-ui-kit/src/public-api.ts
+++ b/projects/cps-ui-kit/src/public-api.ts
@@ -63,5 +63,6 @@ export * from './lib/services/cps-root-font-size/cps-root-font-size.service';
export * from './lib/services/cps-focus/cps-focus.service';
export * from './lib/services/cps-theme/cps-theme.service';
export * from './lib/services/cps-cron-validation/cps-cron-validation.service';
+export * from './lib/services/cps-live-announcer/cps-live-announcer.service';
export * from './lib/utils/colors-utils';