UNPKG

@ordojs/forms

Version:

Comprehensive form handling system for OrdoJS

390 lines 14.7 kB
/** * Accessibility features for forms with ARIA attributes and screen reader support */ export class AccessibilityManager { static getInstance() { if (!AccessibilityManager.instance) { AccessibilityManager.instance = new AccessibilityManager(); } return AccessibilityManager.instance; } constructor() { this.announceElement = null; this.createAnnounceElement(); } /** * Create hidden element for screen reader announcements */ createAnnounceElement() { if (typeof document === 'undefined') return; this.announceElement = document.createElement('div'); this.announceElement.setAttribute('aria-live', 'polite'); this.announceElement.setAttribute('aria-atomic', 'true'); this.announceElement.style.position = 'absolute'; this.announceElement.style.left = '-10000px'; this.announceElement.style.width = '1px'; this.announceElement.style.height = '1px'; this.announceElement.style.overflow = 'hidden'; document.body.appendChild(this.announceElement); } /** * Announce message to screen readers */ announce(message, priority = 'polite') { if (!this.announceElement) return; this.announceElement.setAttribute('aria-live', priority); this.announceElement.textContent = message; // Clear after announcement setTimeout(() => { if (this.announceElement) { this.announceElement.textContent = ''; } }, 1000); } /** * Generate accessibility attributes for form fields */ generateFieldAttributes(fieldName, fieldState, config = {}) { const attributes = {}; // Basic ARIA attributes attributes['aria-invalid'] = fieldState.error ? 'true' : 'false'; if (config.required) { attributes['aria-required'] = 'true'; } // Associate with label if (config.labelledBy) { attributes['aria-labelledby'] = config.labelledBy; } else { attributes['aria-labelledby'] = `${fieldName}-label`; } // Associate with description const describedByParts = []; if (config.describedBy) { describedByParts.push(config.describedBy); } if (config.helpTextId) { describedByParts.push(config.helpTextId); } else { describedByParts.push(`${fieldName}-help`); } if (fieldState.error && config.errorId) { describedByParts.push(config.errorId); } else if (fieldState.error) { describedByParts.push(`${fieldName}-error`); } if (describedByParts.length > 0) { attributes['aria-describedby'] = describedByParts.join(' '); } return attributes; } /** * Generate accessibility attributes for form containers */ generateFormAttributes(formId, formInstance) { const attributes = {}; const state = formInstance.getState(); attributes['role'] = 'form'; attributes['aria-labelledby'] = `${formId}-title`; if (state.submitting) { attributes['aria-busy'] = 'true'; } // Add live region for form status if (!state.valid && Object.keys(state.errors).length > 0) { attributes['aria-live'] = 'polite'; } return attributes; } /** * Generate error message attributes */ generateErrorAttributes(fieldName) { return { 'id': `${fieldName}-error`, 'role': 'alert', 'aria-live': 'assertive' }; } /** * Generate help text attributes */ generateHelpTextAttributes(fieldName) { return { 'id': `${fieldName}-help` }; } /** * Generate label attributes */ generateLabelAttributes(fieldName) { return { 'id': `${fieldName}-label`, 'for': fieldName }; } /** * Handle field validation announcements */ announceFieldValidation(fieldName, fieldState) { if (fieldState.error && fieldState.touched) { const message = `${fieldName}: ${fieldState.error.message}`; this.announce(message, 'assertive'); } else if (!fieldState.error && fieldState.touched && fieldState.dirty) { const message = `${fieldName} is valid`; this.announce(message, 'polite'); } } /** * Handle form submission announcements */ announceFormSubmission(formInstance) { const state = formInstance.getState(); if (state.submitting) { this.announce('Form is being submitted', 'polite'); } else if (state.submitted && state.valid) { this.announce('Form submitted successfully', 'polite'); } else if (!state.valid) { const errorCount = Object.keys(state.errors).length; const message = `Form has ${errorCount} error${errorCount === 1 ? '' : 's'}. Please review and correct.`; this.announce(message, 'assertive'); } } /** * Focus management for form navigation */ focusFirstError(formInstance) { const errors = formInstance.getErrors(); const firstErrorField = Object.keys(errors)[0]; if (firstErrorField) { const element = document.getElementById(firstErrorField) || document.querySelector(`[name="${firstErrorField}"]`); if (element instanceof HTMLElement) { element.focus(); // Scroll into view if needed element.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } } /** * Create skip links for form navigation */ createSkipLinks(formId) { const skipLinksContainer = document.createElement('div'); skipLinksContainer.className = 'skip-links'; skipLinksContainer.style.position = 'absolute'; skipLinksContainer.style.top = '-40px'; skipLinksContainer.style.left = '6px'; skipLinksContainer.style.zIndex = '1000'; const skipToContent = document.createElement('a'); skipToContent.href = `#${formId}-content`; skipToContent.textContent = 'Skip to form content'; skipToContent.className = 'skip-link'; const skipToErrors = document.createElement('a'); skipToErrors.href = `#${formId}-errors`; skipToErrors.textContent = 'Skip to form errors'; skipToErrors.className = 'skip-link'; // Style skip links [skipToContent, skipToErrors].forEach(link => { link.style.background = '#000'; link.style.color = '#fff'; link.style.padding = '8px'; link.style.textDecoration = 'none'; link.style.display = 'inline-block'; link.style.marginRight = '8px'; // Show on focus link.addEventListener('focus', () => { skipLinksContainer.style.top = '6px'; }); link.addEventListener('blur', () => { skipLinksContainer.style.top = '-40px'; }); }); skipLinksContainer.appendChild(skipToContent); skipLinksContainer.appendChild(skipToErrors); return skipLinksContainer; } /** * Enhance form with accessibility features */ enhanceForm(formElement, formInstance) { // Add form attributes const formId = formElement.id || 'form-' + Math.random().toString(36).substr(2, 9); formElement.id = formId; const formAttributes = this.generateFormAttributes(formId, formInstance); Object.entries(formAttributes).forEach(([key, value]) => { formElement.setAttribute(key, value); }); // Add skip links const skipLinks = this.createSkipLinks(formId); formElement.parentNode?.insertBefore(skipLinks, formElement); // Create error summary container const errorSummary = document.createElement('div'); errorSummary.id = `${formId}-errors`; errorSummary.setAttribute('role', 'alert'); errorSummary.setAttribute('aria-live', 'assertive'); errorSummary.style.marginBottom = '1rem'; formElement.insertBefore(errorSummary, formElement.firstChild); // Listen for form state changes formInstance.subscribe((state) => { this.updateErrorSummary(errorSummary, state.errors); this.announceFormSubmission(formInstance); }); // Listen for field changes const fields = formElement.querySelectorAll('input, select, textarea'); fields.forEach(field => { if (field instanceof HTMLElement && field.getAttribute('name')) { const fieldName = field.getAttribute('name'); formInstance.subscribeToField(fieldName, (fieldState) => { this.updateFieldAccessibility(field, fieldName, fieldState); this.announceFieldValidation(fieldName, fieldState); }); } }); } /** * Update error summary */ updateErrorSummary(container, errors) { const errorKeys = Object.keys(errors); if (errorKeys.length === 0) { container.style.display = 'none'; container.innerHTML = ''; return; } container.style.display = 'block'; const title = document.createElement('h2'); title.textContent = `Form has ${errorKeys.length} error${errorKeys.length === 1 ? '' : 's'}`; title.style.color = '#d32f2f'; title.style.marginBottom = '0.5rem'; const list = document.createElement('ul'); list.style.color = '#d32f2f'; errorKeys.forEach(fieldName => { const error = errors[fieldName]; const listItem = document.createElement('li'); const link = document.createElement('a'); link.href = `#${fieldName}`; link.textContent = `${fieldName}: ${error.message}`; link.style.color = '#d32f2f'; link.addEventListener('click', (e) => { e.preventDefault(); const field = document.getElementById(fieldName) || document.querySelector(`[name="${fieldName}"]`); if (field instanceof HTMLElement) { field.focus(); } }); listItem.appendChild(link); list.appendChild(listItem); }); container.innerHTML = ''; container.appendChild(title); container.appendChild(list); } /** * Update field accessibility attributes */ updateFieldAccessibility(field, fieldName, fieldState) { const config = { required: field.required, invalid: !!fieldState.error }; const attributes = this.generateFieldAttributes(fieldName, fieldState, config); Object.entries(attributes).forEach(([key, value]) => { field.setAttribute(key, value); }); // Update associated error element const errorElement = document.getElementById(`${fieldName}-error`); if (errorElement) { if (fieldState.error) { errorElement.textContent = fieldState.error.message; errorElement.style.display = 'block'; } else { errorElement.textContent = ''; errorElement.style.display = 'none'; } } } /** * Create accessible field wrapper */ createFieldWrapper(fieldName, label, helpText) { const wrapper = document.createElement('div'); wrapper.className = 'field-wrapper'; wrapper.style.marginBottom = '1rem'; // Create label const labelElement = document.createElement('label'); const labelAttributes = this.generateLabelAttributes(fieldName); Object.entries(labelAttributes).forEach(([key, value]) => { labelElement.setAttribute(key, value); }); labelElement.textContent = label; labelElement.style.display = 'block'; labelElement.style.marginBottom = '0.25rem'; labelElement.style.fontWeight = 'bold'; // Create help text if (helpText) { const helpElement = document.createElement('div'); const helpAttributes = this.generateHelpTextAttributes(fieldName); Object.entries(helpAttributes).forEach(([key, value]) => { helpElement.setAttribute(key, value); }); helpElement.textContent = helpText; helpElement.style.fontSize = '0.875rem'; helpElement.style.color = '#666'; helpElement.style.marginBottom = '0.25rem'; wrapper.appendChild(labelElement); wrapper.appendChild(helpElement); } else { wrapper.appendChild(labelElement); } // Create error container const errorElement = document.createElement('div'); const errorAttributes = this.generateErrorAttributes(fieldName); Object.entries(errorAttributes).forEach(([key, value]) => { errorElement.setAttribute(key, value); }); errorElement.style.color = '#d32f2f'; errorElement.style.fontSize = '0.875rem'; errorElement.style.marginTop = '0.25rem'; errorElement.style.display = 'none'; return wrapper; } /** * Clean up accessibility resources */ cleanup() { if (this.announceElement && this.announceElement.parentNode) { this.announceElement.parentNode.removeChild(this.announceElement); this.announceElement = null; } } } AccessibilityManager.instance = null; /** * Global accessibility manager instance */ export const accessibility = AccessibilityManager.getInstance(); /** * Utility functions for accessibility */ export const a11y = { manager: accessibility, // Quick access functions announce: (message, priority) => accessibility.announce(message, priority), focusFirstError: (form) => accessibility.focusFirstError(form), enhanceForm: (element, form) => accessibility.enhanceForm(element, form), createFieldWrapper: (name, label, helpText) => accessibility.createFieldWrapper(name, label, helpText) }; //# sourceMappingURL=accessibility.js.map