Major accessibility and es2022 updates

This commit is contained in:
Aigars Silkalns
2025-06-20 16:52:49 +03:00
parent 53081955d7
commit 63aabd6610
63 changed files with 5454 additions and 2501 deletions

View File

@@ -16,5 +16,10 @@ export default {
banner,
name: 'adminlte'
},
plugins: [typescript()]
plugins: [
typescript({
tsconfig: './tsconfig.json',
sourceMap: true
})
]
}

View File

@@ -5,33 +5,62 @@ const cssPath = isRtl ? ".rtl" : "";
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>{title}</title>
<!--begin::Accessibility Meta Tags-->
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#007bff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#1a1a1a" media="(prefers-color-scheme: dark)" />
<!--end::Accessibility Meta Tags-->
<!--begin::Primary Meta Tags-->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="title" content={title} />
<meta name="author" content="ColorlibHQ" />
<meta
name="description"
content="AdminLTE is a Free Bootstrap 5 Admin Dashboard, 30 example pages using Vanilla JS."
content="AdminLTE is a Free Bootstrap 5 Admin Dashboard, 30 example pages using Vanilla JS. Fully accessible with WCAG 2.1 AA compliance."
/>
<meta
name="keywords"
content="bootstrap 5, bootstrap, bootstrap 5 admin dashboard, bootstrap 5 dashboard, bootstrap 5 charts, bootstrap 5 calendar, bootstrap 5 datepicker, bootstrap 5 tables, bootstrap 5 datatable, vanilla js datatable, colorlibhq, colorlibhq dashboard, colorlibhq admin dashboard"
content="bootstrap 5, bootstrap, bootstrap 5 admin dashboard, bootstrap 5 dashboard, bootstrap 5 charts, bootstrap 5 calendar, bootstrap 5 datepicker, bootstrap 5 tables, bootstrap 5 datatable, vanilla js datatable, colorlibhq, colorlibhq dashboard, colorlibhq admin dashboard, accessible admin panel, WCAG compliant"
/>
<!--end::Primary Meta Tags-->
<!--begin::Accessibility Features-->
<!-- Skip links will be dynamically added by accessibility.js -->
<meta name="supported-color-schemes" content="light dark" />
<link rel="preload" href={path + "/css/adminlte" + cssPath + ".css"} as="style" />
<!--end::Accessibility Features-->
<!--begin::Fonts-->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@fontsource/source-sans-3@5.0.12/index.css"
integrity="sha256-tXJfXfp6Ewt1ilPzLDtQnJV4hclT9XuaZUKyUvmyr+Q="
crossorigin="anonymous"
media="print"
onload="this.media='all'"
/>
<!--end::Fonts-->
<!--begin::Third Party Plugin(OverlayScrollbars)-->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/overlayscrollbars@2.10.1/styles/overlayscrollbars.min.css" integrity="sha256-tZHrRjVqNSRyWg2wbppGnT833E/Ys0DHWGwT04GiqQg=" crossorigin="anonymous">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/overlayscrollbars@2.10.1/styles/overlayscrollbars.min.css"
integrity="sha256-tZHrRjVqNSRyWg2wbppGnT833E/Ys0DHWGwT04GiqQg="
crossorigin="anonymous"
/>
<!--end::Third Party Plugin(OverlayScrollbars)-->
<!--begin::Third Party Plugin(Bootstrap Icons)-->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" integrity="sha256-9kPW/n5nn53j4WMRYAxe9c1rCY96Oogo/MKSVdKzPmI=" crossorigin="anonymous">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
integrity="sha256-9kPW/n5nn53j4WMRYAxe9c1rCY96Oogo/MKSVdKzPmI="
crossorigin="anonymous"
/>
<!--end::Third Party Plugin(Bootstrap Icons)-->
<!--begin::Required Plugin(AdminLTE)-->
<link rel="stylesheet" href={path + "/css/adminlte" + cssPath + ".css"} />
<!--end::Required Plugin(AdminLTE)-->

View File

@@ -31,8 +31,10 @@ const htmlPath = convertPathToHtml(path);
<ul
class="nav sidebar-menu flex-column"
data-lte-toggle="treeview"
role="menu"
role="navigation"
aria-label="Main navigation"
data-accordion="false"
id="navigation"
>
<li class:list={["nav-item", mainPage === "dashboard" && "menu-open"]}>
<a

View File

@@ -0,0 +1,292 @@
/* ==========================================================================
AdminLTE Accessibility Styles - WCAG 2.1 AA Compliance
========================================================================== */
/* Skip Links - WCAG 2.4.1: Bypass Blocks */
.skip-link {
position: absolute;
top: -40px;
left: 6px;
z-index: 999999;
padding: 8px 16px;
font-weight: 600;
color: var(--bs-white);
text-decoration: none;
background: var(--bs-primary);
&:focus {
top: 0;
outline: 3px solid var(--bs-warning);
outline-offset: 2px;
}
&:hover {
color: var(--bs-white);
text-decoration: none;
background: var(--bs-primary-emphasis);
}
}
/* Enhanced Focus Indicators - WCAG 2.4.7: Focus Visible */
.focus-enhanced {
&:focus {
outline: 3px solid var(--bs-focus-ring-color, #0d6efd);
outline-offset: 2px;
box-shadow: 0 0 0 .25rem rgba(13, 110, 253, .25);
}
}
/* High Contrast Mode Support */
@media (prefers-contrast: high) {
.card {
border: 2px solid;
}
.btn {
border-width: 2px;
}
.nav-link {
border: 1px solid transparent;
&:hover,
&:focus {
border-color: currentcolor;
}
}
}
/* Reduced Motion Support - WCAG 2.3.3: Animation from Interactions */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: .01ms !important;
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
}
.fade {
display: block;
}
.collapse {
display: block;
}
}
/* Screen Reader Only Content */
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
.sr-only-focusable:focus {
position: static !important;
width: auto !important;
height: auto !important;
padding: inherit !important;
margin: inherit !important;
overflow: visible !important;
clip: auto !important;
white-space: normal !important;
}
/* Focus Trap Utilities */
.focus-trap {
&:focus {
box-shadow: 0 0 0 .25rem rgba(13, 110, 253, .25);
}
}
/* Accessible Color Combinations - WCAG 1.4.3: Contrast (Minimum) */
.text-accessible-primary {
color: #003d82; /* 4.5:1 contrast on white */
}
.text-accessible-success {
color: #0f5132; /* 4.5:1 contrast on white */
}
.text-accessible-danger {
color: #842029; /* 4.5:1 contrast on white */
}
.text-accessible-warning {
color: #664d03; /* 4.5:1 contrast on white */
}
/* ARIA Live Regions */
.live-region {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
&.live-region-visible {
position: static;
left: auto;
width: auto;
height: auto;
overflow: visible;
}
}
/* Enhanced Error States - WCAG 3.3.1: Error Identification */
.form-control.is-invalid {
border-color: var(--bs-danger);
&:focus {
border-color: var(--bs-danger);
box-shadow: 0 0 0 .25rem rgba(220, 53, 69, .25);
}
}
.invalid-feedback {
display: block;
width: 100%;
margin-top: .25rem;
font-size: .875em;
color: var(--bs-danger);
&[role="alert"] {
font-weight: 600;
}
}
/* Target Size - WCAG 2.5.8: Target Size (Minimum) */
.touch-target {
min-width: 44px;
min-height: 44px;
&.touch-target-small {
min-width: 24px;
min-height: 24px;
}
}
/* Table Accessibility */
.table-accessible {
th {
font-weight: 600;
background-color: var(--bs-secondary-bg);
&[scope="col"] {
border-bottom: 2px solid var(--bs-border-color);
}
&[scope="row"] {
border-right: 2px solid var(--bs-border-color);
}
}
caption {
padding: .75rem;
font-weight: 600;
color: var(--bs-secondary);
text-align: left;
caption-side: top;
}
}
/* Navigation Landmarks */
nav[role="navigation"] {
&:not([aria-label]):not([aria-labelledby]) {
&::before {
position: absolute;
left: -10000px;
content: "Navigation";
}
}
}
/* Form Fieldset Styling */
fieldset {
padding: 1rem;
margin-bottom: 1rem;
border: 1px solid var(--bs-border-color);
legend {
padding: 0 .5rem;
margin-bottom: .5rem;
font-size: 1.1em;
font-weight: 600;
}
}
/* Loading States */
.loading[aria-busy="true"] {
position: relative;
pointer-events: none;
&::after {
position: absolute;
top: 50%;
left: 50%;
width: 20px;
height: 20px;
margin-top: -10px;
margin-left: -10px;
content: "";
border: 2px solid var(--bs-primary);
border-top-color: transparent;
animation: spin 1s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
&::after {
border-top-color: var(--bs-primary);
animation: none;
}
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Dark Mode Accessibility */
[data-bs-theme="dark"] {
.text-accessible-primary {
color: #6ea8fe;
}
.text-accessible-success {
color: #75b798;
}
.text-accessible-danger {
color: #f1aeb5;
}
.text-accessible-warning {
color: #ffda6a;
}
}
/* Print Accessibility */
@media print {
.skip-link,
.btn,
.nav-link {
color: #000 !important;
background: transparent !important;
border: 1px solid #000 !important;
}
a[href^="http"]::after {
font-size: .8em;
content: " (" attr(href) ")";
}
}

View File

@@ -73,3 +73,6 @@
@import "parts/extra-components";
@import "parts/pages";
@import "parts/miscellaneous";
// AdminLTE Accessibility Styles - WCAG 2.1 AA Compliance
@import "accessibility";

533
src/ts/accessibility.ts Normal file
View File

@@ -0,0 +1,533 @@
/**
* 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 modals, dropdowns, etc.
const activeModal = document.querySelector('.modal.show')
const activeDropdown = document.querySelector('.dropdown-menu.show')
if (activeModal) {
const closeButton = activeModal.querySelector('[data-bs-dismiss="modal"]') as HTMLElement
closeButton?.click()
event.preventDefault()
} else 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 states
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')
input.parentNode?.insertBefore(errorElement, input.nextSibling)
}
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))
}
}

View File

@@ -1,15 +1,54 @@
import Layout from './layout'
import PushMenu from './push-menu'
import Treeview from './treeview'
import DirectChat from './direct-chat'
import CardWidget from './card-widget'
import FullScreen from './fullscreen'
import { onDOMContentLoaded } from './util/index.js'
import Layout from './layout.js'
import CardWidget from './card-widget.js'
import Treeview from './treeview.js'
import DirectChat from './direct-chat.js'
import FullScreen from './fullscreen.js'
import PushMenu from './push-menu.js'
import { initAccessibility } from './accessibility.js'
/**
* AdminLTE v4.0.0-rc1
* Author: Colorlib
* Website: AdminLTE.io <https://adminlte.io>
* License: Open source - MIT <https://opensource.org/licenses/MIT>
*/
onDOMContentLoaded(() => {
/**
* Initialize AdminLTE Core Components
* -------------------------------
*/
const layout = new Layout(document.body)
layout.holdTransition()
/**
* Initialize Accessibility Features - WCAG 2.1 AA Compliance
* --------------------------------------------------------
*/
const accessibilityManager = initAccessibility({
announcements: true,
skipLinks: true,
focusManagement: true,
keyboardNavigation: true,
reducedMotion: true
})
// Add semantic landmarks
accessibilityManager.addLandmarks()
// Mark app as loaded after initialization
setTimeout(() => {
document.body.classList.add('app-loaded')
}, 400)
})
export {
Layout,
PushMenu,
CardWidget,
Treeview,
DirectChat,
CardWidget,
FullScreen
FullScreen,
PushMenu,
initAccessibility
}

View File

@@ -17,6 +17,35 @@ const onDOMContentLoaded = (callback: () => void): void => {
}
}
/* ES2022 UTILITY FUNCTIONS */
/**
* Check if an element has a specific data attribute using ES2022 Object.hasOwn()
*/
const hasDataAttribute = (element: HTMLElement, attribute: string): boolean => {
return Object.hasOwn(element.dataset, attribute)
}
/**
* Get the last element from a NodeList using ES2022 Array.at()
*/
const getLastElement = <T extends Element>(elements: NodeListOf<T> | T[]): T | undefined => {
const elementsArray = Array.from(elements)
return elementsArray.at(-1)
}
/**
* Safe property access with better error handling
*/
const safePropertyAccess = (obj: Record<string, unknown>, property: string): unknown => {
try {
return Object.hasOwn(obj, property) ? obj[property] : undefined
} catch (error) {
// ES2022 Error cause
throw new Error(`Failed to access property '${property}'`, { cause: error })
}
}
/* SLIDE UP */
const slideUp = (target: HTMLElement, duration = 500) => {
target.style.transitionProperty = 'height, margin, padding'
@@ -97,5 +126,8 @@ export {
onDOMContentLoaded,
slideUp,
slideDown,
slideToggle
slideToggle,
hasDataAttribute,
getLastElement,
safePropertyAccess
}