- Complete project structure with Next.js frontend - GraphQL API backend with Apollo Server - Portal application with NextAuth - Crossplane Proxmox provider - GitOps configurations - CI/CD pipelines - Testing infrastructure (Vitest, Jest, Go tests) - Error handling and monitoring - Security hardening - UI component library - Documentation
94 lines
2.3 KiB
TypeScript
94 lines
2.3 KiB
TypeScript
/**
|
|
* Accessibility utilities
|
|
*/
|
|
|
|
/**
|
|
* Get accessible label for an element
|
|
*/
|
|
export function getAccessibleLabel(
|
|
label?: string,
|
|
placeholder?: string,
|
|
fallback = 'Interactive element'
|
|
): string {
|
|
return label || placeholder || fallback
|
|
}
|
|
|
|
/**
|
|
* Generate unique ID for form elements
|
|
*/
|
|
export function generateId(prefix = 'id'): string {
|
|
return `${prefix}-${Math.random().toString(36).substring(2, 9)}`
|
|
}
|
|
|
|
/**
|
|
* Check if element is focusable
|
|
*/
|
|
export function isFocusable(element: HTMLElement): boolean {
|
|
const focusableSelectors = [
|
|
'a[href]',
|
|
'button:not([disabled])',
|
|
'input:not([disabled])',
|
|
'select:not([disabled])',
|
|
'textarea:not([disabled])',
|
|
'[tabindex]:not([tabindex="-1"])',
|
|
].join(', ')
|
|
|
|
return element.matches(focusableSelectors)
|
|
}
|
|
|
|
/**
|
|
* Trap focus within a container
|
|
*/
|
|
export function trapFocus(container: HTMLElement): () => void {
|
|
const focusableElements = container.querySelectorAll<HTMLElement>(
|
|
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
)
|
|
|
|
const firstElement = focusableElements[0]
|
|
const lastElement = focusableElements[focusableElements.length - 1]
|
|
|
|
const handleTab = (e: KeyboardEvent) => {
|
|
if (e.key !== 'Tab') {
|
|
return
|
|
}
|
|
|
|
if (e.shiftKey) {
|
|
if (document.activeElement === firstElement) {
|
|
e.preventDefault()
|
|
lastElement?.focus()
|
|
}
|
|
} else {
|
|
if (document.activeElement === lastElement) {
|
|
e.preventDefault()
|
|
firstElement?.focus()
|
|
}
|
|
}
|
|
}
|
|
|
|
container.addEventListener('keydown', handleTab)
|
|
|
|
// Return cleanup function
|
|
return () => {
|
|
container.removeEventListener('keydown', handleTab)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Announce message to screen readers
|
|
*/
|
|
export function announceToScreenReader(message: string, priority: 'polite' | 'assertive' = 'polite') {
|
|
const announcement = document.createElement('div')
|
|
announcement.setAttribute('role', 'status')
|
|
announcement.setAttribute('aria-live', priority)
|
|
announcement.setAttribute('aria-atomic', 'true')
|
|
announcement.className = 'sr-only'
|
|
announcement.textContent = message
|
|
|
|
document.body.appendChild(announcement)
|
|
|
|
setTimeout(() => {
|
|
document.body.removeChild(announcement)
|
|
}, 1000)
|
|
}
|
|
|