/** * AdminLTE Accessibility Module * WCAG 2.1 AA Compliance Features */ import { getLifecycleSignal } from './util/index' export interface AccessibilityConfig { announcements: boolean skipLinks: boolean focusManagement: boolean keyboardNavigation: boolean reducedMotion: boolean } export class AccessibilityManager { private config: AccessibilityConfig private liveRegion: HTMLElement | null = null private focusHistory: HTMLElement[] = [] // Listeners bound to `document` survive Turbo's swap, so they are // registered with this signal and torn down before the next re-init. private readonly signal: AbortSignal = getLifecycleSignal() constructor(config: Partial = {}) { this.config = { announcements: true, skipLinks: true, focusManagement: true, keyboardNavigation: true, reducedMotion: true, ...config } this.init() } private init(): void { if (this.config.announcements) { this.createLiveRegion() } if (this.config.skipLinks) { this.addSkipLinks() } if (this.config.focusManagement) { this.initFocusManagement() } if (this.config.keyboardNavigation) { this.initKeyboardNavigation() } if (this.config.reducedMotion) { this.respectReducedMotion() } this.initErrorAnnouncements() this.initTableAccessibility() this.initFormAccessibility() } // WCAG 4.1.3: Status Messages private createLiveRegion(): void { if (this.liveRegion) return // Adopt an existing region instead of appending a duplicate: Turbo caches // the including JS-injected nodes, so a region created before a // navigation is restored along with the snapshot. const existingRegion = document.getElementById('live-region') if (existingRegion) { this.liveRegion = existingRegion return } this.liveRegion = document.createElement('div') this.liveRegion.id = 'live-region' this.liveRegion.className = 'live-region' this.liveRegion.setAttribute('aria-live', 'polite') this.liveRegion.setAttribute('aria-atomic', 'true') this.liveRegion.setAttribute('role', 'status') document.body.append(this.liveRegion) } // WCAG 2.4.1: Bypass Blocks private addSkipLinks(): void { // Same Turbo snapshot concern as createLiveRegion: skip links restored // with a cached must not be injected a second time. if (document.querySelector('.skip-links')) { this.ensureSkipTargets() return } const skipLinksContainer = document.createElement('div') skipLinksContainer.className = 'skip-links' const skipToMain = document.createElement('a') skipToMain.href = '#main' skipToMain.className = 'skip-link' skipToMain.textContent = 'Skip to main content' const skipToNav = document.createElement('a') skipToNav.href = '#navigation' skipToNav.className = 'skip-link' skipToNav.textContent = 'Skip to navigation' skipLinksContainer.append(skipToMain) skipLinksContainer.append(skipToNav) document.body.insertBefore(skipLinksContainer, document.body.firstChild) // Ensure targets exist and are focusable this.ensureSkipTargets() } private ensureSkipTargets(): void { // An element the page already gave the id to always wins. This used to be // one querySelector over `#navigation, nav, [role="navigation"]`, but a // selector list returns the first match in *document order* rather than // the first selector that matched — so a page whose sidebar menu carried // `id="navigation"` had the id stamped a second time onto the header //