import { Injectable, OnDestroy } from '@angular/core'; export type AriaLivePoliteness = 'off' | 'polite' | 'assertive'; export interface LiveAnnouncementConfig { politeness?: AriaLivePoliteness; duration?: number; clearPrevious?: boolean; } @Injectable({ providedIn: 'root' }) export class LiveAnnouncerService implements OnDestroy { private liveElement?: HTMLElement; private timeouts: Map = new Map(); constructor() { this.createLiveElement(); } ngOnDestroy(): void { this.timeouts.forEach(timeout => clearTimeout(timeout)); this.removeLiveElement(); } /** * Announces a message to screen readers */ announce( message: string, politeness: AriaLivePoliteness = 'polite', duration?: number ): void { this.announceWithConfig(message, { politeness, duration, clearPrevious: true }); } /** * Announces a message with full configuration options */ announceWithConfig(message: string, config: LiveAnnouncementConfig = {}): void { const { politeness = 'polite', duration = 4000, clearPrevious = true } = config; if (!this.liveElement) { this.createLiveElement(); } if (!this.liveElement) return; // Clear any existing timeout for this politeness level if (this.timeouts.has(politeness)) { clearTimeout(this.timeouts.get(politeness)); } // Clear previous message if requested if (clearPrevious) { this.liveElement.textContent = ''; } // Set the politeness level this.liveElement.setAttribute('aria-live', politeness); // Announce the message setTimeout(() => { if (this.liveElement) { this.liveElement.textContent = message; } }, 100); // Clear the message after the specified duration if (duration > 0) { const timeoutId = setTimeout(() => { if (this.liveElement) { this.liveElement.textContent = ''; } this.timeouts.delete(politeness); }, duration); this.timeouts.set(politeness, timeoutId); } } /** * Clears all current announcements */ clear(): void { if (this.liveElement) { this.liveElement.textContent = ''; } this.timeouts.forEach(timeout => clearTimeout(timeout)); this.timeouts.clear(); } /** * Announces multiple messages with delays between them */ announceSequence( messages: string[], politeness: AriaLivePoliteness = 'polite', delayBetween = 1000 ): void { messages.forEach((message, index) => { setTimeout(() => { this.announce(message, politeness); }, index * delayBetween); }); } /** * Creates the live region element */ private createLiveElement(): void { if (typeof document === 'undefined') return; // Remove existing element if it exists this.removeLiveElement(); // Create new live region this.liveElement = document.createElement('div'); this.liveElement.setAttribute('aria-live', 'polite'); this.liveElement.setAttribute('aria-atomic', 'true'); this.liveElement.setAttribute('id', 'a11y-live-announcer'); // Make it invisible but still accessible to screen readers this.liveElement.style.position = 'absolute'; this.liveElement.style.left = '-10000px'; this.liveElement.style.width = '1px'; this.liveElement.style.height = '1px'; this.liveElement.style.overflow = 'hidden'; document.body.appendChild(this.liveElement); } /** * Removes the live region element */ private removeLiveElement(): void { const existing = document.getElementById('a11y-live-announcer'); if (existing) { existing.remove(); } this.liveElement = undefined; } }