admin-lte
Version:
Responsive open source admin dashboard and control panel.
611 lines (521 loc) • 20.6 kB
text/typescript
/**
* 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 <body> swap, so they are
// registered with this signal and torn down before the next re-init.
private readonly signal: AbortSignal = getLifecycleSignal()
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
// Adopt an existing region instead of appending a duplicate: Turbo caches
// the <body> 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 <body> 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 {
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 {
// Note: focus must never be wrapped at the document edges — trapping Tab
// inside the page prevents keyboard users from reaching browser chrome
// and is itself a WCAG 2.1.2 (No Keyboard Trap) failure. Focus trapping
// is only legitimate inside active modal dialogs, which Bootstrap already
// handles.
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
this.handleEscapeKey(event)
}
}, { signal: this.signal })
// Focus management for modals and dropdowns
this.initModalFocusManagement()
this.initDropdownFocusManagement()
}
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
// Never intercept keys inside editable controls: arrow keys must keep
// moving the caret in e.g. a navbar search input.
if (target.matches('input, textarea, select, [contenteditable], [contenteditable] *')) {
return
}
// 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()
}
}, { signal: this.signal })
}
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[])
// Skip items hidden inside collapsed submenus or closed dropdowns.
.filter(item => item.offsetParent !== null)
const currentIndex = menuItems.indexOf(currentElement)
// Only take over navigation when focus is actually on a menu item;
// otherwise stealing the arrow keys would yank focus out of unrelated
// controls that merely sit inside a nav container.
if (currentIndex === -1) {
return
}
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. The <head> survives Turbo navigations, so
// only inject the override stylesheet once to avoid stacking duplicates.
if (!document.getElementById('adminlte-reduce-motion')) {
const style = document.createElement('style')
style.id = 'adminlte-reduce-motion'
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
})
// Stop observing the old <body> before Turbo swaps in a new one.
this.signal.addEventListener('abort', () => {
observer.disconnect()
}, { once: 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 {
// Inputs lacking both an id and a name would otherwise collide on a
// single "-error" id. Assign a stable generated id once and persist it on
// the element so repeated `invalid` events reuse the same error node
// instead of appending a new orphaned one each time.
if (!input.id && !input.name) {
input.id = accessibilityUtils.generateId('field')
}
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
// Append to any existing aria-describedby (e.g. help text) instead of
// clobbering it.
const describedBy = (input.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean)
if (!describedBy.includes(errorId)) {
describedBy.push(errorId)
}
input.setAttribute('aria-describedby', describedBy.join(' '))
input.classList.add('is-invalid')
this.announce(`Error in ${input.labels?.[0]?.textContent || input.name}: ${input.validationMessage}`, 'assertive')
}
// Modal focus management
private initModalFocusManagement(): void {
// Capture the trigger on `show` — before Bootstrap (or the `shown`
// handler below) moves focus into the modal. Capturing on `shown` would
// store an element inside the modal itself, so closing it would "restore"
// focus to a now-hidden node and drop focus to <body>.
document.addEventListener('show.bs.modal', () => {
this.focusHistory.push(document.activeElement as HTMLElement)
}, { signal: this.signal })
document.addEventListener('shown.bs.modal', (event) => {
const modal = event.target as HTMLElement
// Respect an explicit [autofocus] before falling back to the first
// focusable control.
const autofocusElement = modal.querySelector('[autofocus]') as HTMLElement | null
const firstFocusable = autofocusElement ||
(modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])') as HTMLElement | null)
firstFocusable?.focus()
}, { signal: this.signal })
document.addEventListener('hidden.bs.modal', () => {
// Restore previous focus, unless the trigger has since been removed
// from the document (e.g. a delete-confirmation modal removing its row).
const previousElement = this.focusHistory.pop()
if (previousElement?.isConnected) {
previousElement.focus()
}
}, { signal: this.signal })
}
// 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()
}
}, { signal: this.signal })
}
// 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()
}
}
}, { signal: this.signal })
}
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')
if (!appMain.id) {
appMain.id = 'main'
}
}
}
// Add navigation landmarks.
// Skip <ul>/<ol>: assigning role="navigation" to a list element strips its
// implicit list semantics, orphaning its <li> children for screen readers
// and failing Lighthouse's "list items not contained within list parent"
// audit (#6038). Lists that should be navigation landmarks belong inside a
// <nav> element, which already provides the landmark and accessible name.
document.querySelectorAll('.navbar-nav, .nav').forEach((nav, index) => {
if (nav.tagName === 'UL' || nav.tagName === 'OL') {
return
}
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)
}
// Parse a CSS color into RGB channels. Supports rgb()/rgba() strings and
// 3- or 6-digit hex notation; anything else falls back to black.
const parseColorChannels = (color: string): number[] => {
const hexMatch = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(color.trim())
if (hexMatch) {
let hex = hexMatch[1]
if (hex.length === 3) {
hex = [...hex].map(character => character + character).join('')
}
return [
Number.parseInt(hex.slice(0, 2), 16),
Number.parseInt(hex.slice(2, 4), 16),
Number.parseInt(hex.slice(4, 6), 16)
]
}
return color.match(/\d+/g)?.map(Number) || [0, 0, 0]
}
// Utility function for luminance calculation
const getLuminance = (color: string): number => {
const [r, g, b] = parseColorChannels(color).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))
}
}