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
JavaScript
/**
* 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;