accessibility.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. /**
  2. * AdminLTE Accessibility Module
  3. * WCAG 2.1 AA Compliance Features
  4. */
  5. export interface AccessibilityConfig {
  6. announcements: boolean
  7. skipLinks: boolean
  8. focusManagement: boolean
  9. keyboardNavigation: boolean
  10. reducedMotion: boolean
  11. }
  12. export class AccessibilityManager {
  13. private config: AccessibilityConfig
  14. private liveRegion: HTMLElement | null = null
  15. private focusHistory: HTMLElement[] = []
  16. constructor(config: Partial<AccessibilityConfig> = {}) {
  17. this.config = {
  18. announcements: true,
  19. skipLinks: true,
  20. focusManagement: true,
  21. keyboardNavigation: true,
  22. reducedMotion: true,
  23. ...config
  24. }
  25. this.init()
  26. }
  27. private init(): void {
  28. if (this.config.announcements) {
  29. this.createLiveRegion()
  30. }
  31. if (this.config.skipLinks) {
  32. this.addSkipLinks()
  33. }
  34. if (this.config.focusManagement) {
  35. this.initFocusManagement()
  36. }
  37. if (this.config.keyboardNavigation) {
  38. this.initKeyboardNavigation()
  39. }
  40. if (this.config.reducedMotion) {
  41. this.respectReducedMotion()
  42. }
  43. this.initErrorAnnouncements()
  44. this.initTableAccessibility()
  45. this.initFormAccessibility()
  46. }
  47. // WCAG 4.1.3: Status Messages
  48. private createLiveRegion(): void {
  49. if (this.liveRegion) return
  50. this.liveRegion = document.createElement('div')
  51. this.liveRegion.id = 'live-region'
  52. this.liveRegion.className = 'live-region'
  53. this.liveRegion.setAttribute('aria-live', 'polite')
  54. this.liveRegion.setAttribute('aria-atomic', 'true')
  55. this.liveRegion.setAttribute('role', 'status')
  56. document.body.append(this.liveRegion)
  57. }
  58. // WCAG 2.4.1: Bypass Blocks
  59. private addSkipLinks(): void {
  60. const skipLinksContainer = document.createElement('div')
  61. skipLinksContainer.className = 'skip-links'
  62. const skipToMain = document.createElement('a')
  63. skipToMain.href = '#main'
  64. skipToMain.className = 'skip-link'
  65. skipToMain.textContent = 'Skip to main content'
  66. const skipToNav = document.createElement('a')
  67. skipToNav.href = '#navigation'
  68. skipToNav.className = 'skip-link'
  69. skipToNav.textContent = 'Skip to navigation'
  70. skipLinksContainer.append(skipToMain)
  71. skipLinksContainer.append(skipToNav)
  72. document.body.insertBefore(skipLinksContainer, document.body.firstChild)
  73. // Ensure targets exist and are focusable
  74. this.ensureSkipTargets()
  75. }
  76. private ensureSkipTargets(): void {
  77. const main = document.querySelector('#main, main, [role="main"]')
  78. if (main && !main.id) {
  79. main.id = 'main'
  80. }
  81. if (main && !main.hasAttribute('tabindex')) {
  82. main.setAttribute('tabindex', '-1')
  83. }
  84. const nav = document.querySelector('#navigation, nav, [role="navigation"]')
  85. if (nav && !nav.id) {
  86. nav.id = 'navigation'
  87. }
  88. if (nav && !nav.hasAttribute('tabindex')) {
  89. nav.setAttribute('tabindex', '-1')
  90. }
  91. }
  92. // WCAG 2.4.3: Focus Order & 2.4.7: Focus Visible
  93. private initFocusManagement(): void {
  94. document.addEventListener('keydown', (event) => {
  95. if (event.key === 'Tab') {
  96. this.handleTabNavigation(event)
  97. }
  98. if (event.key === 'Escape') {
  99. this.handleEscapeKey(event)
  100. }
  101. })
  102. // Focus management for modals and dropdowns
  103. this.initModalFocusManagement()
  104. this.initDropdownFocusManagement()
  105. }
  106. private handleTabNavigation(event: KeyboardEvent): void {
  107. const focusableElements = this.getFocusableElements()
  108. const currentIndex = focusableElements.indexOf(document.activeElement as HTMLElement)
  109. if (event.shiftKey) {
  110. // Shift+Tab (backward)
  111. if (currentIndex <= 0) {
  112. event.preventDefault()
  113. focusableElements.at(-1)?.focus()
  114. }
  115. } else if (currentIndex >= focusableElements.length - 1) {
  116. // Tab (forward)
  117. event.preventDefault()
  118. focusableElements[0]?.focus()
  119. }
  120. }
  121. private getFocusableElements(): HTMLElement[] {
  122. const selector = [
  123. 'a[href]',
  124. 'button:not([disabled])',
  125. 'input:not([disabled])',
  126. 'select:not([disabled])',
  127. 'textarea:not([disabled])',
  128. '[tabindex]:not([tabindex="-1"])',
  129. '[contenteditable="true"]'
  130. ].join(', ')
  131. return Array.from(document.querySelectorAll(selector)) as HTMLElement[]
  132. }
  133. private handleEscapeKey(event: KeyboardEvent): void {
  134. // Close dropdowns, but let Bootstrap handle its own modal keyboard behavior
  135. const activeModal = document.querySelector('.modal.show')
  136. if (activeModal) {
  137. // Do not interfere — Bootstrap handles Escape for modals,
  138. // including respecting keyboard: false and stacked modals
  139. return
  140. }
  141. const activeDropdown = document.querySelector('.dropdown-menu.show')
  142. if (activeDropdown) {
  143. const toggleButton = document.querySelector('[data-bs-toggle="dropdown"][aria-expanded="true"]') as HTMLElement
  144. toggleButton?.click()
  145. event.preventDefault()
  146. }
  147. }
  148. // WCAG 2.1.1: Keyboard Access
  149. private initKeyboardNavigation(): void {
  150. // Add keyboard support for custom components
  151. document.addEventListener('keydown', (event) => {
  152. const target = event.target as HTMLElement
  153. // Handle arrow key navigation for menus
  154. if (target.closest('.nav, .navbar-nav, .dropdown-menu')) {
  155. this.handleMenuNavigation(event)
  156. }
  157. // Handle Enter and Space for custom buttons
  158. if ((event.key === 'Enter' || event.key === ' ') && target.hasAttribute('role') && target.getAttribute('role') === 'button' && !target.matches('button, input[type="button"], input[type="submit"]')) {
  159. event.preventDefault()
  160. target.click()
  161. }
  162. })
  163. }
  164. private handleMenuNavigation(event: KeyboardEvent): void {
  165. if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
  166. return
  167. }
  168. const currentElement = event.target as HTMLElement
  169. const menuItems = Array.from(currentElement.closest('.nav, .navbar-nav, .dropdown-menu')?.querySelectorAll('a, button') || []) as HTMLElement[]
  170. const currentIndex = menuItems.indexOf(currentElement)
  171. let nextIndex: number
  172. switch (event.key) {
  173. case 'ArrowDown':
  174. case 'ArrowRight': {
  175. nextIndex = currentIndex < menuItems.length - 1 ? currentIndex + 1 : 0
  176. break
  177. }
  178. case 'ArrowUp':
  179. case 'ArrowLeft': {
  180. nextIndex = currentIndex > 0 ? currentIndex - 1 : menuItems.length - 1
  181. break
  182. }
  183. case 'Home': {
  184. nextIndex = 0
  185. break
  186. }
  187. case 'End': {
  188. nextIndex = menuItems.length - 1
  189. break
  190. }
  191. default: {
  192. return
  193. }
  194. }
  195. event.preventDefault()
  196. menuItems[nextIndex]?.focus()
  197. }
  198. // WCAG 2.3.3: Animation from Interactions
  199. private respectReducedMotion(): void {
  200. const prefersReducedMotion = globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches
  201. if (prefersReducedMotion) {
  202. document.body.classList.add('reduce-motion')
  203. // Disable smooth scrolling
  204. document.documentElement.style.scrollBehavior = 'auto'
  205. // Reduce animation duration
  206. const style = document.createElement('style')
  207. style.textContent = `
  208. *, *::before, *::after {
  209. animation-duration: 0.01ms !important;
  210. animation-iteration-count: 1 !important;
  211. transition-duration: 0.01ms !important;
  212. }
  213. `
  214. document.head.append(style)
  215. }
  216. }
  217. // WCAG 3.3.1: Error Identification
  218. private initErrorAnnouncements(): void {
  219. const observer = new MutationObserver((mutations) => {
  220. mutations.forEach((mutation) => {
  221. mutation.addedNodes.forEach((node) => {
  222. if (node.nodeType === Node.ELEMENT_NODE) {
  223. const element = node as Element
  224. // Check for error messages
  225. if (element.matches('.alert-danger, .invalid-feedback, .error')) {
  226. this.announce(element.textContent || 'Error occurred', 'assertive')
  227. }
  228. // Check for success messages
  229. if (element.matches('.alert-success, .success')) {
  230. this.announce(element.textContent || 'Success', 'polite')
  231. }
  232. }
  233. })
  234. })
  235. })
  236. observer.observe(document.body, {
  237. childList: true,
  238. subtree: true
  239. })
  240. }
  241. // WCAG 1.3.1: Info and Relationships
  242. private initTableAccessibility(): void {
  243. document.querySelectorAll('table').forEach((table) => {
  244. // Add table role if missing
  245. if (!table.hasAttribute('role')) {
  246. table.setAttribute('role', 'table')
  247. }
  248. // Ensure headers have proper scope
  249. table.querySelectorAll('th').forEach((th) => {
  250. if (!th.hasAttribute('scope')) {
  251. const isInThead = th.closest('thead')
  252. const isFirstColumn = th.cellIndex === 0
  253. if (isInThead) {
  254. th.setAttribute('scope', 'col')
  255. } else if (isFirstColumn) {
  256. th.setAttribute('scope', 'row')
  257. }
  258. }
  259. })
  260. // Add caption if missing but title exists
  261. if (!table.querySelector('caption') && table.hasAttribute('title')) {
  262. const caption = document.createElement('caption')
  263. caption.textContent = table.getAttribute('title') || ''
  264. table.insertBefore(caption, table.firstChild)
  265. }
  266. })
  267. }
  268. // WCAG 3.3.2: Labels or Instructions
  269. private initFormAccessibility(): void {
  270. document.querySelectorAll('input, select, textarea').forEach((input) => {
  271. const htmlInput = input as HTMLInputElement
  272. // Ensure all inputs have labels
  273. if (!htmlInput.labels?.length && !htmlInput.hasAttribute('aria-label') && !htmlInput.hasAttribute('aria-labelledby')) {
  274. const placeholder = htmlInput.getAttribute('placeholder')
  275. if (placeholder) {
  276. htmlInput.setAttribute('aria-label', placeholder)
  277. }
  278. }
  279. // Add required indicators
  280. if (htmlInput.hasAttribute('required')) {
  281. const label = htmlInput.labels?.[0]
  282. if (label && !label.querySelector('.required-indicator')) {
  283. const indicator = document.createElement('span')
  284. indicator.className = 'required-indicator sr-only'
  285. indicator.textContent = ' (required)'
  286. label.append(indicator)
  287. }
  288. }
  289. // Handle invalid state unless the element explicitly opts out via the
  290. // 'disable-adminlte-validations' class.
  291. if (!htmlInput.classList.contains('disable-adminlte-validations')) {
  292. htmlInput.addEventListener('invalid', () => {
  293. this.handleFormError(htmlInput)
  294. })
  295. }
  296. })
  297. }
  298. private handleFormError(input: HTMLInputElement): void {
  299. const errorId = `${input.id || input.name}-error`
  300. let errorElement = document.getElementById(errorId)
  301. if (!errorElement) {
  302. errorElement = document.createElement('div')
  303. errorElement.id = errorId
  304. errorElement.className = 'invalid-feedback'
  305. errorElement.setAttribute('role', 'alert')
  306. // Always append the error element as the last child of the parent.
  307. // This prevents breaking layouts where inputs use Bootstrap's
  308. // `.input-group-text` decorators, ensuring the error stays below
  309. // the entire input group.
  310. input.parentNode?.append(errorElement)
  311. }
  312. errorElement.textContent = input.validationMessage
  313. input.setAttribute('aria-describedby', errorId)
  314. input.classList.add('is-invalid')
  315. this.announce(`Error in ${input.labels?.[0]?.textContent || input.name}: ${input.validationMessage}`, 'assertive')
  316. }
  317. // Modal focus management
  318. private initModalFocusManagement(): void {
  319. document.addEventListener('shown.bs.modal', (event) => {
  320. const modal = event.target as HTMLElement
  321. const focusableElements = modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
  322. if (focusableElements.length > 0) {
  323. (focusableElements[0] as HTMLElement).focus()
  324. }
  325. // Store previous focus
  326. this.focusHistory.push(document.activeElement as HTMLElement)
  327. })
  328. document.addEventListener('hidden.bs.modal', () => {
  329. // Restore previous focus
  330. const previousElement = this.focusHistory.pop()
  331. if (previousElement) {
  332. previousElement.focus()
  333. }
  334. })
  335. }
  336. // Dropdown focus management
  337. private initDropdownFocusManagement(): void {
  338. document.addEventListener('shown.bs.dropdown', (event) => {
  339. const dropdown = event.target as HTMLElement
  340. const menu = dropdown.querySelector('.dropdown-menu')
  341. const firstItem = menu?.querySelector('a, button') as HTMLElement
  342. if (firstItem) {
  343. firstItem.focus()
  344. }
  345. })
  346. }
  347. // Public API methods
  348. public announce(message: string, priority: 'polite' | 'assertive' = 'polite'): void {
  349. if (!this.liveRegion) {
  350. this.createLiveRegion()
  351. }
  352. if (this.liveRegion) {
  353. this.liveRegion.setAttribute('aria-live', priority)
  354. this.liveRegion.textContent = message
  355. // Clear after announcement
  356. setTimeout(() => {
  357. if (this.liveRegion) {
  358. this.liveRegion.textContent = ''
  359. }
  360. }, 1000)
  361. }
  362. }
  363. public focusElement(selector: string): void {
  364. const element = document.querySelector(selector) as HTMLElement
  365. if (element) {
  366. element.focus()
  367. // Ensure element is visible
  368. element.scrollIntoView({ behavior: 'smooth', block: 'center' })
  369. }
  370. }
  371. public trapFocus(container: HTMLElement): void {
  372. const focusableElements = container.querySelectorAll(
  373. 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  374. ) as NodeListOf<HTMLElement>
  375. const focusableArray = Array.from(focusableElements)
  376. const firstElement = focusableArray[0]
  377. const lastElement = focusableArray.at(-1)
  378. container.addEventListener('keydown', (event) => {
  379. if (event.key === 'Tab') {
  380. if (event.shiftKey) {
  381. if (document.activeElement === firstElement) {
  382. lastElement?.focus()
  383. event.preventDefault()
  384. }
  385. } else if (document.activeElement === lastElement) {
  386. firstElement.focus()
  387. event.preventDefault()
  388. }
  389. }
  390. })
  391. }
  392. public addLandmarks(): void {
  393. // Add main landmark if missing
  394. const main = document.querySelector('main')
  395. if (!main) {
  396. const appMain = document.querySelector('.app-main')
  397. if (appMain) {
  398. appMain.setAttribute('role', 'main')
  399. appMain.id = 'main'
  400. }
  401. }
  402. // Add navigation landmarks
  403. document.querySelectorAll('.navbar-nav, .nav').forEach((nav, index) => {
  404. if (!nav.hasAttribute('role')) {
  405. nav.setAttribute('role', 'navigation')
  406. }
  407. if (!nav.hasAttribute('aria-label')) {
  408. nav.setAttribute('aria-label', `Navigation ${index + 1}`)
  409. }
  410. })
  411. // Add search landmark
  412. const searchForm = document.querySelector('form[role="search"], .navbar-search')
  413. if (searchForm && !searchForm.hasAttribute('role')) {
  414. searchForm.setAttribute('role', 'search')
  415. }
  416. }
  417. }
  418. // Initialize accessibility when DOM is ready
  419. export const initAccessibility = (config?: Partial<AccessibilityConfig>): AccessibilityManager => {
  420. return new AccessibilityManager(config)
  421. }
  422. // Utility function for luminance calculation
  423. const getLuminance = (color: string): number => {
  424. const rgb = color.match(/\d+/g)?.map(Number) || [0, 0, 0]
  425. const [r, g, b] = rgb.map(c => {
  426. c = c / 255
  427. return c <= 0.039_28 ? c / 12.92 : (c + 0.055) ** 2.4 / (1.055 ** 2.4)
  428. })
  429. return 0.2126 * r + 0.7152 * g + 0.0722 * b
  430. }
  431. // Export utility functions
  432. export const accessibilityUtils = {
  433. // WCAG 1.4.3: Contrast checking utility
  434. checkColorContrast: (foreground: string, background: string): { ratio: number; passes: boolean } => {
  435. const l1 = getLuminance(foreground)
  436. const l2 = getLuminance(background)
  437. const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)
  438. return {
  439. ratio: Math.round(ratio * 100) / 100,
  440. passes: ratio >= 4.5
  441. }
  442. },
  443. // Generate unique IDs for accessibility
  444. generateId: (prefix: string = 'a11y'): string => {
  445. return `${prefix}-${Math.random().toString(36).slice(2, 11)}`
  446. },
  447. // Check if element is focusable
  448. isFocusable: (element: HTMLElement): boolean => {
  449. const focusableSelectors = [
  450. 'a[href]',
  451. 'button:not([disabled])',
  452. 'input:not([disabled])',
  453. 'select:not([disabled])',
  454. 'textarea:not([disabled])',
  455. '[tabindex]:not([tabindex="-1"])',
  456. '[contenteditable="true"]'
  457. ]
  458. return focusableSelectors.some(selector => element.matches(selector))
  459. }
  460. }