@ordojs/forms
Version:
Comprehensive form handling system for OrdoJS
390 lines • 14.7 kB
JavaScript
/**
* 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