UNPKG

claritykit-svelte

Version:

A comprehensive Svelte component library focused on accessibility, ADHD-optimized design, developer experience, and full SSR compatibility

573 lines (572 loc) 20.9 kB
/** * Accessibility Utilities for ClarityKit * * Comprehensive utilities for managing ARIA attributes, live regions, * keyboard navigation, and other accessibility features. */ // Browser check for SSR compatibility const isBrowser = typeof window !== 'undefined'; /** * Generates a unique ID for ARIA associations */ export function generateId(prefix = 'ck') { return `${prefix}-${Math.random().toString(36).substr(2, 9)}`; } export function createAriaAssociations(options) { const { fieldId, labelId, errorId, helperId, isInvalid = false, isRequired = false } = options; const describedByIds = [helperId, errorId].filter(Boolean); return { id: fieldId, 'aria-labelledby': labelId || undefined, 'aria-describedby': describedByIds.length > 0 ? describedByIds.join(' ') : undefined, 'aria-invalid': isInvalid ? 'true' : undefined, 'aria-required': isRequired ? 'true' : undefined }; } export function createFormInputAria(options) { const { id, required = false, invalid = false, disabled = false, readonly = false, labelId, errorId, helperId, expanded, hasPopup, role, multiline, autocomplete } = options; const describedByIds = [helperId, errorId].filter(Boolean); const ariaAttributes = { id, 'aria-required': required ? 'true' : undefined, 'aria-invalid': invalid ? 'true' : undefined, 'aria-disabled': disabled ? 'true' : undefined, 'aria-readonly': readonly ? 'true' : undefined, 'aria-labelledby': labelId || undefined, 'aria-describedby': describedByIds.length > 0 ? describedByIds.join(' ') : undefined, 'aria-expanded': expanded !== undefined ? String(expanded) : undefined, 'aria-haspopup': hasPopup !== undefined ? (typeof hasPopup === 'boolean' ? String(hasPopup) : hasPopup) : undefined, 'role': role || undefined, 'aria-multiline': multiline ? 'true' : undefined, 'autocomplete': autocomplete || undefined }; // Filter out undefined values return Object.fromEntries(Object.entries(ariaAttributes).filter(([, value]) => value !== undefined)); } /** * Form validation announcements */ export function announceFormValidation(isValid, errorCount = 0, fieldName = 'field') { if (!isBrowser) return; const message = isValid ? `${fieldName} is valid` : `${fieldName} has ${errorCount} error${errorCount !== 1 ? 's' : ''}`; manager.announce(message, { politeness: 'assertive' }); } class LiveRegionManager { constructor() { Object.defineProperty(this, "container", { enumerable: true, configurable: true, writable: true, value: null }); Object.defineProperty(this, "regions", { enumerable: true, configurable: true, writable: true, value: new Map() }); if (isBrowser) { this.initialize(); } } initialize() { // Create main live region container this.container = document.createElement('div'); this.container.setAttribute('aria-live', 'polite'); this.container.setAttribute('aria-atomic', 'false'); this.container.className = 'sr-only ck-live-region-container'; this.container.setAttribute('data-testid', 'live-region-container'); // Add to document document.body.appendChild(this.container); } /** * Announces a message via live region */ announce(message, options = {}) { if (!isBrowser || !this.container) return; const { politeness = 'polite', atomic = true, relevant = 'additions', timeout = 0 } = options; // Create unique region for this announcement const regionId = generateId('live-region'); const region = document.createElement('div'); region.setAttribute('aria-live', politeness); region.setAttribute('aria-atomic', atomic.toString()); region.setAttribute('aria-relevant', relevant); region.className = 'sr-only'; region.textContent = message; this.container.appendChild(region); this.regions.set(regionId, region); // Auto-remove after timeout if (timeout > 0) { setTimeout(() => { this.remove(regionId); }, timeout); } // Clean up old announcements to prevent accumulation if (this.regions.size > 10) { const oldestKey = this.regions.keys().next().value; if (oldestKey) { this.remove(oldestKey); } } } /** * Removes a specific live region */ remove(regionId) { const region = this.regions.get(regionId); if (region && region.parentNode) { region.parentNode.removeChild(region); this.regions.delete(regionId); } } /** * Clears all live regions */ clear() { this.regions.forEach((region) => { if (region.parentNode) { region.parentNode.removeChild(region); } }); this.regions.clear(); } /** * Updates container politeness level */ setPoliteness(politeness) { if (this.container) { this.container.setAttribute('aria-live', politeness); } } } // Global live region manager instance export const liveRegion = new LiveRegionManager(); /** * Convenience functions for common live region announcements */ export function announcePolitely(message, timeout = 5000) { liveRegion.announce(message, { politeness: 'polite', timeout }); } export function announceAssertively(message, timeout = 5000) { liveRegion.announce(message, { politeness: 'assertive', timeout }); } export function announceStatus(message) { liveRegion.announce(message, { politeness: 'polite', atomic: true }); } export function announceError(message) { liveRegion.announce(message, { politeness: 'assertive', atomic: true }); } export function announceValidation(options) { const { fieldName, isValid, errorCount = 0, errors = [] } = options; if (isValid) { announcePolitely(`${fieldName} is now valid`); } else { const errorText = errors.length > 0 ? `${fieldName} has ${errorCount} error${errorCount > 1 ? 's' : ''}: ${errors.join(', ')}` : `${fieldName} has ${errorCount} error${errorCount > 1 ? 's' : ''}`; announceAssertively(errorText); } } export class FocusManager { constructor() { Object.defineProperty(this, "focusStack", { enumerable: true, configurable: true, writable: true, value: [] }); } /** * Saves current focus for later restoration */ saveFocus() { const activeElement = document.activeElement; if (activeElement && activeElement !== document.body) { this.focusStack.push(activeElement); } } /** * Restores the most recently saved focus */ restoreFocus() { const elementToFocus = this.focusStack.pop(); if (elementToFocus && elementToFocus.focus) { elementToFocus.focus({ preventScroll: true }); } } /** * Sets focus to an element with options */ setFocus(element, options = {}) { const target = typeof element === 'string' ? document.getElementById(element) || document.querySelector(element) : element; if (target && target.focus) { if (options.restoreFocus) { this.saveFocus(); } target.focus({ preventScroll: options.preventScroll }); } } /** * Creates a focus trap within a container */ createFocusTrap(container) { const focusableSelector = [ 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', 'a[href]', '[tabindex]:not([tabindex="-1"])' ].join(', '); const handleKeyDown = (event) => { if (event.key !== 'Tab') return; const focusableElements = Array.from(container.querySelectorAll(focusableSelector)); if (focusableElements.length === 0) return; const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; if (event.shiftKey) { // Shift + Tab if (document.activeElement === firstElement) { event.preventDefault(); lastElement.focus(); } } else { // Tab if (document.activeElement === lastElement) { event.preventDefault(); firstElement.focus(); } } }; container.addEventListener('keydown', handleKeyDown); // Focus first element const firstFocusable = container.querySelector(focusableSelector); if (firstFocusable) { firstFocusable.focus(); } // Return cleanup function return () => { container.removeEventListener('keydown', handleKeyDown); }; } } export const focusManager = new FocusManager(); export function createKeyboardNavigation(container, options) { const { direction, wrap = true, homeEndKeys = true, enterActivates = true, spaceActivates = true } = options; const getNavigableElements = () => { return Array.from(container.querySelectorAll('[role="option"], [role="tab"], [role="menuitem"], button:not([disabled]), [tabindex="0"]')); }; const getCurrentIndex = (elements) => { const activeElement = document.activeElement; return elements.findIndex(el => el === activeElement); }; const focusElement = (elements, index) => { if (index >= 0 && index < elements.length) { elements[index].focus(); return true; } return false; }; const handleKeyDown = (event) => { const elements = getNavigableElements(); if (elements.length === 0) return; const currentIndex = getCurrentIndex(elements); let newIndex = currentIndex; let handled = false; switch (event.key) { case 'ArrowDown': if (direction === 'vertical' || direction === 'grid') { newIndex = currentIndex + 1; if (wrap && newIndex >= elements.length) newIndex = 0; handled = true; } break; case 'ArrowUp': if (direction === 'vertical' || direction === 'grid') { newIndex = currentIndex - 1; if (wrap && newIndex < 0) newIndex = elements.length - 1; handled = true; } break; case 'ArrowRight': if (direction === 'horizontal' || direction === 'grid') { newIndex = currentIndex + 1; if (wrap && newIndex >= elements.length) newIndex = 0; handled = true; } break; case 'ArrowLeft': if (direction === 'horizontal' || direction === 'grid') { newIndex = currentIndex - 1; if (wrap && newIndex < 0) newIndex = elements.length - 1; handled = true; } break; case 'Home': if (homeEndKeys) { newIndex = 0; handled = true; } break; case 'End': if (homeEndKeys) { newIndex = elements.length - 1; handled = true; } break; case 'Enter': if (enterActivates && currentIndex >= 0) { elements[currentIndex].click(); handled = true; } break; case ' ': if (spaceActivates && currentIndex >= 0) { event.preventDefault(); // Prevent page scroll elements[currentIndex].click(); handled = true; } break; } if (handled) { event.preventDefault(); if (newIndex !== currentIndex) { focusElement(elements, newIndex); } } }; container.addEventListener('keydown', handleKeyDown); return () => { container.removeEventListener('keydown', handleKeyDown); }; } export function enhanceTableAccessibility(table, options) { const { caption, summary, sortable = false, rowHeaders = false, columnHeaders = true } = options; // Add caption if provided if (caption && !table.querySelector('caption')) { const captionElement = document.createElement('caption'); captionElement.textContent = caption; table.insertBefore(captionElement, table.firstChild); } // Add summary if provided (using aria-describedby) if (summary) { const summaryId = generateId('table-summary'); const summaryElement = document.createElement('div'); summaryElement.id = summaryId; summaryElement.className = 'sr-only'; summaryElement.textContent = summary; table.parentNode?.insertBefore(summaryElement, table); table.setAttribute('aria-describedby', summaryId); } // Enhance headers const headerCells = table.querySelectorAll('th'); headerCells.forEach((th, index) => { if (!th.id) { th.id = generateId('th'); } if (sortable) { th.setAttribute('role', 'columnheader'); th.setAttribute('tabindex', '0'); th.setAttribute('aria-sort', 'none'); } if (columnHeaders && th.closest('thead')) { th.setAttribute('scope', 'col'); } else if (rowHeaders && th.closest('tbody')) { th.setAttribute('scope', 'row'); } }); // Associate data cells with headers const dataCells = table.querySelectorAll('td'); dataCells.forEach((td) => { const row = td.closest('tr'); const cellIndex = Array.from(row?.children || []).indexOf(td); const headerCell = table.querySelector(`thead th:nth-child(${cellIndex + 1})`); if (headerCell?.id) { const existingHeaders = td.getAttribute('headers') || ''; const newHeaders = existingHeaders ? `${existingHeaders} ${headerCell.id}` : headerCell.id; td.setAttribute('headers', newHeaders); } }); } export function createChartTextAlternative(options) { const { title, description, data, type = 'chart' } = options; const titleId = generateId('chart-title'); const descriptionId = generateId('chart-desc'); const tableId = generateId('chart-table'); // Create text alternative elements const titleElement = document.createElement('div'); titleElement.id = titleId; titleElement.className = 'sr-only'; titleElement.textContent = title; const descriptionElement = document.createElement('div'); descriptionElement.id = descriptionId; descriptionElement.className = 'sr-only'; descriptionElement.textContent = description; // Create data table alternative const tableElement = document.createElement('table'); tableElement.id = tableId; tableElement.className = 'sr-only'; tableElement.setAttribute('aria-label', `Data table for ${title}`); const caption = document.createElement('caption'); caption.textContent = `${title} - Data Table`; tableElement.appendChild(caption); const thead = document.createElement('thead'); const headerRow = document.createElement('tr'); const labelHeader = document.createElement('th'); labelHeader.textContent = 'Label'; labelHeader.setAttribute('scope', 'col'); const valueHeader = document.createElement('th'); valueHeader.textContent = 'Value'; valueHeader.setAttribute('scope', 'col'); headerRow.appendChild(labelHeader); headerRow.appendChild(valueHeader); thead.appendChild(headerRow); tableElement.appendChild(thead); const tbody = document.createElement('tbody'); data.forEach((item) => { const row = document.createElement('tr'); const labelCell = document.createElement('td'); labelCell.textContent = item.label; const valueCell = document.createElement('td'); valueCell.textContent = item.value.toString(); row.appendChild(labelCell); row.appendChild(valueCell); tbody.appendChild(row); }); tableElement.appendChild(tbody); return { titleId, descriptionId, tableId, ariaAttributes: { 'aria-labelledby': titleId, 'aria-describedby': `${descriptionId} ${tableId}`, 'role': 'img', 'aria-label': `${type}: ${title}` } }; } /** * Reduced Motion Utilities */ export function prefersReducedMotion() { if (!isBrowser) return false; return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } export function prefersHighContrast() { if (!isBrowser) return false; return window.matchMedia('(prefers-contrast: high)').matches; } /** * Screen Reader Testing Utilities */ export function getAccessibleText(element) { // Get the accessible name/text that would be announced by screen readers const ariaLabel = element.getAttribute('aria-label'); if (ariaLabel) return ariaLabel; const ariaLabelledBy = element.getAttribute('aria-labelledby'); if (ariaLabelledBy) { const labelElement = document.getElementById(ariaLabelledBy); if (labelElement) return labelElement.textContent || ''; } const textContent = element.textContent || ''; if (textContent.trim()) return textContent.trim(); // For form elements, check associated labels if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT') { const labels = document.querySelectorAll(`label[for="${element.id}"]`); if (labels.length > 0) { return labels[0].textContent || ''; } } return ''; } /** * ARIA Live Region Testing */ export function getAriaLiveRegions() { return Array.from(document.querySelectorAll('[aria-live]')); } export function getAriaLiveContent() { return getAriaLiveRegions() .map(region => region.textContent || '') .filter(text => text.trim().length > 0); } export function handleFormKeyNavigation(event, options = {}) { const { onSubmit, onCancel, onNext, onPrevious, submitKeys = ['Enter'], cancelKeys = ['Escape'], nextKeys = ['Tab'], previousKeys = ['Shift+Tab'] } = options; const key = event.key; const isCtrlOrCmd = event.ctrlKey || event.metaKey; const isShift = event.shiftKey; // Handle submit keys (e.g., Ctrl+Enter) if (submitKeys.some(submitKey => { if (submitKey === 'Enter' && key === 'Enter' && !isShift) return true; if (submitKey === 'Ctrl+Enter' && key === 'Enter' && isCtrlOrCmd) return true; return submitKey === key; })) { event.preventDefault(); onSubmit?.(); return; } // Handle cancel keys if (cancelKeys.includes(key)) { event.preventDefault(); onCancel?.(); return; } // Handle next keys if (nextKeys.some(nextKey => { if (nextKey === 'Tab' && key === 'Tab' && !isShift) return true; return nextKey === key; })) { if (onNext) { event.preventDefault(); onNext(); } return; } // Handle previous keys if (previousKeys.some(prevKey => { if (prevKey === 'Shift+Tab' && key === 'Tab' && isShift) return true; return prevKey === key; })) { if (onPrevious) { event.preventDefault(); onPrevious(); } return; } } /** * Convenience function for common announce patterns */ export function announce(message, priority = 'polite') { liveRegion.announce(message, { politeness: priority }); } // Create a global manager instance for compatibility export const manager = liveRegion;