| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543 |
- /**
- * AdminLTE Accessibility Module
- * WCAG 2.1 AA Compliance Features
- */
- 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[] = []
- constructor(config: Partial<AccessibilityConfig> = {}) {
- 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
- 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 {
- 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 {
- const main = document.querySelector('#main, main, [role="main"]')
- if (main && !main.id) {
- main.id = 'main'
- }
- if (main && !main.hasAttribute('tabindex')) {
- main.setAttribute('tabindex', '-1')
- }
- const nav = document.querySelector('#navigation, nav, [role="navigation"]')
- if (nav && !nav.id) {
- nav.id = 'navigation'
- }
- if (nav && !nav.hasAttribute('tabindex')) {
- nav.setAttribute('tabindex', '-1')
- }
- }
- // WCAG 2.4.3: Focus Order & 2.4.7: Focus Visible
- private initFocusManagement(): void {
- document.addEventListener('keydown', (event) => {
- if (event.key === 'Tab') {
- this.handleTabNavigation(event)
- }
- if (event.key === 'Escape') {
- this.handleEscapeKey(event)
- }
- })
- // Focus management for modals and dropdowns
- this.initModalFocusManagement()
- this.initDropdownFocusManagement()
- }
- private handleTabNavigation(event: KeyboardEvent): void {
- const focusableElements = this.getFocusableElements()
- const currentIndex = focusableElements.indexOf(document.activeElement as HTMLElement)
-
- if (event.shiftKey) {
- // Shift+Tab (backward)
- if (currentIndex <= 0) {
- event.preventDefault()
- focusableElements.at(-1)?.focus()
- }
- } else if (currentIndex >= focusableElements.length - 1) {
- // Tab (forward)
- event.preventDefault()
- focusableElements[0]?.focus()
- }
- }
- private getFocusableElements(): HTMLElement[] {
- const selector = [
- 'a[href]',
- 'button:not([disabled])',
- 'input:not([disabled])',
- 'select:not([disabled])',
- 'textarea:not([disabled])',
- '[tabindex]:not([tabindex="-1"])',
- '[contenteditable="true"]'
- ].join(', ')
- return Array.from(document.querySelectorAll(selector)) as HTMLElement[]
- }
- private handleEscapeKey(event: KeyboardEvent): void {
- // Close dropdowns, but let Bootstrap handle its own modal keyboard behavior
- const activeModal = document.querySelector('.modal.show')
- if (activeModal) {
- // Do not interfere — Bootstrap handles Escape for modals,
- // including respecting keyboard: false and stacked modals
- return
- }
- const activeDropdown = document.querySelector('.dropdown-menu.show')
- if (activeDropdown) {
- const toggleButton = document.querySelector('[data-bs-toggle="dropdown"][aria-expanded="true"]') as HTMLElement
- toggleButton?.click()
- event.preventDefault()
- }
- }
- // WCAG 2.1.1: Keyboard Access
- private initKeyboardNavigation(): void {
- // Add keyboard support for custom components
- document.addEventListener('keydown', (event) => {
- const target = event.target as HTMLElement
-
- // Handle arrow key navigation for menus
- if (target.closest('.nav, .navbar-nav, .dropdown-menu')) {
- this.handleMenuNavigation(event)
- }
-
- // Handle Enter and Space for custom buttons
- if ((event.key === 'Enter' || event.key === ' ') && target.hasAttribute('role') && target.getAttribute('role') === 'button' && !target.matches('button, input[type="button"], input[type="submit"]')) {
- event.preventDefault()
- target.click()
- }
- })
- }
- private handleMenuNavigation(event: KeyboardEvent): void {
- if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
- return
- }
- const currentElement = event.target as HTMLElement
- const menuItems = Array.from(currentElement.closest('.nav, .navbar-nav, .dropdown-menu')?.querySelectorAll('a, button') || []) as HTMLElement[]
- const currentIndex = menuItems.indexOf(currentElement)
-
- let nextIndex: number
-
- switch (event.key) {
- case 'ArrowDown':
- case 'ArrowRight': {
- nextIndex = currentIndex < menuItems.length - 1 ? currentIndex + 1 : 0
- break
- }
- case 'ArrowUp':
- case 'ArrowLeft': {
- nextIndex = currentIndex > 0 ? currentIndex - 1 : menuItems.length - 1
- break
- }
- case 'Home': {
- nextIndex = 0
- break
- }
- case 'End': {
- nextIndex = menuItems.length - 1
- break
- }
- default: {
- return
- }
- }
-
- event.preventDefault()
- menuItems[nextIndex]?.focus()
- }
- // WCAG 2.3.3: Animation from Interactions
- private respectReducedMotion(): void {
- const prefersReducedMotion = globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches
-
- if (prefersReducedMotion) {
- document.body.classList.add('reduce-motion')
-
- // Disable smooth scrolling
- document.documentElement.style.scrollBehavior = 'auto'
-
- // Reduce animation duration
- const style = document.createElement('style')
- style.textContent = `
- *, *::before, *::after {
- animation-duration: 0.01ms !important;
- animation-iteration-count: 1 !important;
- transition-duration: 0.01ms !important;
- }
- `
- document.head.append(style)
- }
- }
- // WCAG 3.3.1: Error Identification
- private initErrorAnnouncements(): void {
- const observer = new MutationObserver((mutations) => {
- mutations.forEach((mutation) => {
- mutation.addedNodes.forEach((node) => {
- if (node.nodeType === Node.ELEMENT_NODE) {
- const element = node as Element
-
- // Check for error messages
- if (element.matches('.alert-danger, .invalid-feedback, .error')) {
- this.announce(element.textContent || 'Error occurred', 'assertive')
- }
-
- // Check for success messages
- if (element.matches('.alert-success, .success')) {
- this.announce(element.textContent || 'Success', 'polite')
- }
- }
- })
- })
- })
- observer.observe(document.body, {
- childList: true,
- subtree: true
- })
- }
- // WCAG 1.3.1: Info and Relationships
- private initTableAccessibility(): void {
- document.querySelectorAll('table').forEach((table) => {
- // Add table role if missing
- if (!table.hasAttribute('role')) {
- table.setAttribute('role', 'table')
- }
- // Ensure headers have proper scope
- table.querySelectorAll('th').forEach((th) => {
- if (!th.hasAttribute('scope')) {
- const isInThead = th.closest('thead')
- const isFirstColumn = th.cellIndex === 0
-
- if (isInThead) {
- th.setAttribute('scope', 'col')
- } else if (isFirstColumn) {
- th.setAttribute('scope', 'row')
- }
- }
- })
- // Add caption if missing but title exists
- if (!table.querySelector('caption') && table.hasAttribute('title')) {
- const caption = document.createElement('caption')
- caption.textContent = table.getAttribute('title') || ''
- table.insertBefore(caption, table.firstChild)
- }
- })
- }
- // WCAG 3.3.2: Labels or Instructions
- private initFormAccessibility(): void {
- document.querySelectorAll('input, select, textarea').forEach((input) => {
- const htmlInput = input as HTMLInputElement
-
- // Ensure all inputs have labels
- if (!htmlInput.labels?.length && !htmlInput.hasAttribute('aria-label') && !htmlInput.hasAttribute('aria-labelledby')) {
- const placeholder = htmlInput.getAttribute('placeholder')
- if (placeholder) {
- htmlInput.setAttribute('aria-label', placeholder)
- }
- }
- // Add required indicators
- if (htmlInput.hasAttribute('required')) {
- const label = htmlInput.labels?.[0]
- if (label && !label.querySelector('.required-indicator')) {
- const indicator = document.createElement('span')
- indicator.className = 'required-indicator sr-only'
- indicator.textContent = ' (required)'
- label.append(indicator)
- }
- }
- // Handle invalid state unless the element explicitly opts out via the
- // 'disable-adminlte-validations' class.
- if (!htmlInput.classList.contains('disable-adminlte-validations')) {
- htmlInput.addEventListener('invalid', () => {
- this.handleFormError(htmlInput)
- })
- }
- })
- }
- private handleFormError(input: HTMLInputElement): void {
- const errorId = `${input.id || input.name}-error`
- let errorElement = document.getElementById(errorId)
-
- if (!errorElement) {
- errorElement = document.createElement('div')
- errorElement.id = errorId
- errorElement.className = 'invalid-feedback'
- errorElement.setAttribute('role', 'alert')
- // Always append the error element as the last child of the parent.
- // This prevents breaking layouts where inputs use Bootstrap's
- // `.input-group-text` decorators, ensuring the error stays below
- // the entire input group.
- input.parentNode?.append(errorElement)
- }
-
- errorElement.textContent = input.validationMessage
- input.setAttribute('aria-describedby', errorId)
- input.classList.add('is-invalid')
-
- this.announce(`Error in ${input.labels?.[0]?.textContent || input.name}: ${input.validationMessage}`, 'assertive')
- }
- // Modal focus management
- private initModalFocusManagement(): void {
- document.addEventListener('shown.bs.modal', (event) => {
- const modal = event.target as HTMLElement
- const focusableElements = modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
-
- if (focusableElements.length > 0) {
- (focusableElements[0] as HTMLElement).focus()
- }
-
- // Store previous focus
- this.focusHistory.push(document.activeElement as HTMLElement)
- })
- document.addEventListener('hidden.bs.modal', () => {
- // Restore previous focus
- const previousElement = this.focusHistory.pop()
- if (previousElement) {
- previousElement.focus()
- }
- })
- }
- // Dropdown focus management
- private initDropdownFocusManagement(): void {
- document.addEventListener('shown.bs.dropdown', (event) => {
- const dropdown = event.target as HTMLElement
- const menu = dropdown.querySelector('.dropdown-menu')
- const firstItem = menu?.querySelector('a, button') as HTMLElement
-
- if (firstItem) {
- firstItem.focus()
- }
- })
- }
- // Public API methods
- public announce(message: string, priority: 'polite' | 'assertive' = 'polite'): void {
- if (!this.liveRegion) {
- this.createLiveRegion()
- }
-
- if (this.liveRegion) {
- this.liveRegion.setAttribute('aria-live', priority)
- this.liveRegion.textContent = message
-
- // Clear after announcement
- setTimeout(() => {
- if (this.liveRegion) {
- this.liveRegion.textContent = ''
- }
- }, 1000)
- }
- }
- public focusElement(selector: string): void {
- const element = document.querySelector(selector) as HTMLElement
- if (element) {
- element.focus()
-
- // Ensure element is visible
- element.scrollIntoView({ behavior: 'smooth', block: 'center' })
- }
- }
- public trapFocus(container: HTMLElement): void {
- const focusableElements = container.querySelectorAll(
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
- ) as NodeListOf<HTMLElement>
-
- const focusableArray = Array.from(focusableElements)
- const firstElement = focusableArray[0]
- const lastElement = focusableArray.at(-1)
-
- container.addEventListener('keydown', (event) => {
- if (event.key === 'Tab') {
- if (event.shiftKey) {
- if (document.activeElement === firstElement) {
- lastElement?.focus()
- event.preventDefault()
- }
- } else if (document.activeElement === lastElement) {
- firstElement.focus()
- event.preventDefault()
- }
- }
- })
- }
- public addLandmarks(): void {
- // Add main landmark if missing
- const main = document.querySelector('main')
- if (!main) {
- const appMain = document.querySelector('.app-main')
- if (appMain) {
- appMain.setAttribute('role', 'main')
- appMain.id = 'main'
- }
- }
- // Add navigation landmarks
- document.querySelectorAll('.navbar-nav, .nav').forEach((nav, index) => {
- if (!nav.hasAttribute('role')) {
- nav.setAttribute('role', 'navigation')
- }
- if (!nav.hasAttribute('aria-label')) {
- nav.setAttribute('aria-label', `Navigation ${index + 1}`)
- }
- })
- // Add search landmark
- const searchForm = document.querySelector('form[role="search"], .navbar-search')
- if (searchForm && !searchForm.hasAttribute('role')) {
- searchForm.setAttribute('role', 'search')
- }
- }
- }
- // Initialize accessibility when DOM is ready
- export const initAccessibility = (config?: Partial<AccessibilityConfig>): AccessibilityManager => {
- return new AccessibilityManager(config)
- }
- // Utility function for luminance calculation
- const getLuminance = (color: string): number => {
- const rgb = color.match(/\d+/g)?.map(Number) || [0, 0, 0]
- const [r, g, b] = rgb.map(c => {
- c = c / 255
- return c <= 0.039_28 ? c / 12.92 : (c + 0.055) ** 2.4 / (1.055 ** 2.4)
- })
- return 0.2126 * r + 0.7152 * g + 0.0722 * b
- }
- // Export utility functions
- export const accessibilityUtils = {
- // WCAG 1.4.3: Contrast checking utility
- checkColorContrast: (foreground: string, background: string): { ratio: number; passes: boolean } => {
- const l1 = getLuminance(foreground)
- const l2 = getLuminance(background)
- const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)
-
- return {
- ratio: Math.round(ratio * 100) / 100,
- passes: ratio >= 4.5
- }
- },
- // Generate unique IDs for accessibility
- generateId: (prefix: string = 'a11y'): string => {
- return `${prefix}-${Math.random().toString(36).slice(2, 11)}`
- },
- // Check if element is focusable
- isFocusable: (element: HTMLElement): boolean => {
- const focusableSelectors = [
- 'a[href]',
- 'button:not([disabled])',
- 'input:not([disabled])',
- 'select:not([disabled])',
- 'textarea:not([disabled])',
- '[tabindex]:not([tabindex="-1"])',
- '[contenteditable="true"]'
- ]
-
- return focusableSelectors.some(selector => element.matches(selector))
- }
- }
|