natura11y
Version:
Natura11y is an open-source design system for creating beautiful web interfaces with a focus on accessible interaction design and front-end development.
490 lines (397 loc) • 16.4 kB
JavaScript
import { delegateEvent } from './utilities/eventDelegation';
//////////////////////////////////////////////
// A. Shared Methods & Base Class
//////////////////////////////////////////////
const isEmpty = (value, element) => {
// For groups
if (element?.type === 'checkbox' || element?.type === 'radio') {
const groupName = element.name;
const form = element.closest('form');
const groupInputs = form ? form.querySelectorAll(`input[name="${groupName}"]`) : document.querySelectorAll(`input[name="${groupName}"]`);
return !Array.from(groupInputs).some(input => input.checked);
}
// For single fields
return !value?.trim();
};
const setFieldValidity = (field, isValid, invalidClasses = ['is-invalid']) => {
const entryRoot = field.closest('.form-entry');
entryRoot.classList.toggle(invalidClasses[0], !isValid);
field.setAttribute('aria-invalid', !isValid);
};
// Base class for form components with common functionality
class FormBase {
#invalidClasses = ['is-invalid'];
#formSubmitAttempted = false;
get invalidClasses() {
return this.#invalidClasses;
}
get formSubmitAttempted() {
return this.#formSubmitAttempted;
}
set formSubmitAttempted(value) {
this.#formSubmitAttempted = value;
}
#checkIfEmpty(field) {
const isFieldEmpty = isEmpty(field.value, field);
setFieldValidity(field, !isFieldEmpty, this.#invalidClasses);
return isFieldEmpty;
}
// Protected method for subclasses to use
_checkIfEmpty(field) {
return this.#checkIfEmpty(field);
}
// Helper method for common cleanup pattern
_cleanupHandlers(handlersMap, removeEvent) {
handlersMap.forEach((handler, element) => {
if (typeof removeEvent === 'string') {
element.removeEventListener(removeEvent, handler);
} else {
// For complex cleanup scenarios, pass a function
removeEvent(element, handler);
}
});
handlersMap.clear();
}
// Abstract method - subclasses must implement
cleanup() {
throw new Error('cleanup() must be implemented by subclass');
}
// Abstract method - subclasses must implement
init() {
throw new Error('init() must be implemented by subclass');
}
}
//////////////////////////////////////////////
// B. Form Input
//////////////////////////////////////////////
export default class FormInput extends FormBase {
#formEntryList = document.querySelectorAll('.form-entry');
#inputHandlers = new Map();
#changeHandlers = new Map();
#clickHandlers = new Map();
// Private methods
#handleInputChange(formEntryInput, isRequired) {
// Check if empty or not if submission has already been attempted
if (this.formSubmitAttempted && isRequired) {
this._checkIfEmpty(formEntryInput);
}
// Toggle 'has-value' class based on input content
formEntryInput.closest('.form-entry').classList.toggle('has-value', formEntryInput.value !== '');
}
// Attach input event listener to dynamically validate while typing
#addDynamicValidation(formEntryInput, isRequired) {
// Only add validation for required fields
if (!isRequired) return;
// Remove existing handler if any
const existingHandler = this.#inputHandlers.get(formEntryInput);
if (existingHandler) {
formEntryInput.removeEventListener('input', existingHandler);
}
const handler = () => {
this._checkIfEmpty(formEntryInput); // Remove error if the field becomes valid
};
formEntryInput.addEventListener('input', handler);
this.#inputHandlers.set(formEntryInput, handler);
}
#processFormEntryInput(formEntryInput, isRequired) {
const isInputText = formEntryInput.closest('.form-entry').querySelector('.form-entry__field__input');
if (isRequired) {
formEntryInput.setAttribute('required', 'true');
formEntryInput.setAttribute('aria-required', 'true');
}
// Add dynamic validation on input event (removes error class when user types)
this.#addDynamicValidation(formEntryInput, isRequired);
// Handle input change on 'change' event
const existingChangeHandler = this.#changeHandlers.get(formEntryInput);
if (existingChangeHandler) {
formEntryInput.removeEventListener('change', existingChangeHandler);
}
const changeHandler = () => this.#handleInputChange(formEntryInput, isRequired);
formEntryInput.addEventListener('change', changeHandler);
this.#changeHandlers.set(formEntryInput, changeHandler);
// Handle click events on input text spans
if (isInputText) {
const existingClickHandler = this.#clickHandlers.get(isInputText);
if (existingClickHandler) {
isInputText.removeEventListener('click', existingClickHandler);
}
const clickHandler = this.#handleClickOnInputText;
isInputText.addEventListener('click', clickHandler);
this.#clickHandlers.set(isInputText, clickHandler);
}
}
#handleClickOnInputText(event) {
const clickInput = event.target.closest('.form-entry__field__input').querySelector('input');
if (event.target.tagName === 'SPAN') clickInput.focus();
}
// Cleanup method to remove all event listeners
cleanup() {
this._cleanupHandlers(this.#inputHandlers, 'input');
this._cleanupHandlers(this.#changeHandlers, 'change');
this._cleanupHandlers(this.#clickHandlers, 'click');
}
// Public methods
init() {
this.#formEntryList.forEach((formEntry) => {
const isRequired = formEntry.hasAttribute('data-required');
const formEntryInputList = formEntry.querySelectorAll('input, select, textarea');
// Process each form entry input
formEntryInputList.forEach((formEntryInput) => this.#processFormEntryInput(formEntryInput, isRequired));
// Scoped Event Delegation for focusin and focusout (within each formEntry)
delegateEvent(formEntry, 'focusin', 'input, select, textarea', (event) => {
this.#toggleFocusClass(event, true);
});
delegateEvent(formEntry, 'focusout', 'input, select, textarea', (event) => {
this.#toggleFocusClass(event, false);
});
});
}
#toggleFocusClass(event, isFocused = true) {
event.target.closest('.form-entry').classList.toggle('is-focused', isFocused);
}
}
//////////////////////////////////////////////
// C. Form Submission
//////////////////////////////////////////////
export class FormSubmission extends FormBase {
#formList = document.querySelectorAll('form[data-validate]');
#submitHandlers = new Map();
#processFormErrors(formErrorsList, errorsArray) {
formErrorsList.forEach((formError) => {
const formEntry = formError.closest('.form-entry');
const formEntryLabel = formEntry.querySelector('.form-entry__field__label');
const errorMessage = formEntry.getAttribute('data-error-message') || 'This field is required';
const helpText = formEntry.querySelector('.form-entry__help')?.innerHTML || '';
errorsArray.push([errorMessage, helpText]);
if (!formEntry.querySelector('.form-entry__feedback')) {
formEntryLabel.insertAdjacentHTML('afterend', this.#createErrorMessage(errorMessage, helpText));
}
});
}
#createErrorMessage(desc, inst) {
return `
<small class="form-entry__feedback">
<span class="icon icon-warn" aria-hidden="true"></span>
<span class="message">
<strong>${desc}</strong> ${inst || ''}
</span>
</small>
`;
}
#announceErrors(form, errorCount) {
const alertHTML = `
<div class="alert alert--warn font-size-md margin-bottom-4" aria-labelledby="alert-label" aria-describedby="alert-description" role="alert">
<div class="alert__title h5">
<span class="icon icon-warn" aria-hidden="true"></span>
<span class="alert__title__text" id="alert-label">Form Validation Error</span>
</div>
<div class="alert__description" id="alert-description">
<p>${errorCount === 1 ? 'There is 1 error' : `There are ${errorCount} errors`} in the form. Please review and correct the highlighted fields below.</p>
</div>
</div>
`;
form.insertAdjacentHTML('afterbegin', alertHTML);
}
#scrollToFirstError(form) {
const firstError = form.querySelector('.is-invalid, [data-alert]');
if (firstError) {
const errorInput = firstError.querySelector('input, select, textarea');
if (errorInput) {
errorInput.focus();
errorInput.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}
#handleFormSubmission(form) {
const existingHandler = this.#submitHandlers.get(form);
if (existingHandler) {
form.removeEventListener('submit', existingHandler);
}
const handler = (event) => {
event.preventDefault();
// Clean up any existing alert
const existingAlert = form.querySelector('.alert');
if (existingAlert) {
existingAlert.remove();
}
this.formSubmitAttempted = true;
const errorsArray = [];
const inputFields = form.querySelectorAll('input, select, textarea');
inputFields.forEach((field) => {
if (field.hasAttribute('required')) {
this._checkIfEmpty(field);
}
});
const formErrorsList = form.querySelectorAll(':invalid');
this.#processFormErrors(formErrorsList, errorsArray);
if (errorsArray.length > 0) {
event.preventDefault();
this.#announceErrors(form, errorsArray.length);
this.#scrollToFirstError(form);
}
};
form.addEventListener('submit', handler);
this.#submitHandlers.set(form, handler);
}
// Cleanup method to remove all event listeners
cleanup() {
this._cleanupHandlers(this.#submitHandlers, 'submit');
}
init() {
this.#formList.forEach((form) => this.#handleFormSubmission(form));
}
}
//////////////////////////////////////////////
// D. Form File Upload
//////////////////////////////////////////////
export class FormFileUpload extends FormBase {
#fileUploadList = document.querySelectorAll('.file-upload');
#fileUploadHandlers = new Map();
#handleFileChange(fileUpload) {
return (event) => {
const [file] = event.target.files;
if (!file) return;
const { name: fileName, size } = file;
const fileSize = size >= 1e6 ? `${(size / 1e6).toFixed(2)} MB` : `${(size / 1e3).toFixed(2)} KB`;
const fileUploadData = fileUpload.querySelector('.file-upload__data');
if (fileUploadData) fileUploadData.remove();
fileUpload.insertAdjacentHTML('beforeend', `
<span class="file-upload__data">
<span class="file-name">${fileName}</span>
<span class="file-size">${fileSize}</span>
</span>
`);
};
}
dragOver(event) {
event.preventDefault();
event.target.closest('.form-entry').classList.add('is-focused');
}
dragOff(event) {
event.target.closest('.form-entry').classList.remove('is-focused');
}
dropped(fileUpload) {
return (event) => {
event.preventDefault();
event.target.closest('.form-entry').classList.remove('is-focused');
// Handle dropped files
const files = event.dataTransfer.files;
if (files.length > 0) {
const fileInput = fileUpload.querySelector('input[type="file"]');
fileInput.files = files;
// Trigger change event to handle file display
const changeEvent = new Event('change', { bubbles: true });
fileInput.dispatchEvent(changeEvent);
}
};
}
#handleFileUpload(fileUpload) {
const fileUploadInput = fileUpload.querySelector('input[type="file"]');
// Remove existing handlers if any
const existingHandlers = this.#fileUploadHandlers.get(fileUpload);
if (existingHandlers) {
fileUploadInput.removeEventListener('change', existingHandlers.changeHandler);
fileUpload.removeEventListener('dragenter', existingHandlers.dragenterHandler);
fileUpload.removeEventListener('dragover', existingHandlers.dragoverHandler);
fileUpload.removeEventListener('dragleave', existingHandlers.dragleaveHandler);
fileUpload.removeEventListener('dragend', existingHandlers.dragendHandler);
fileUpload.removeEventListener('drop', existingHandlers.dropHandler);
}
// Create new handlers
const changeHandler = this.#handleFileChange(fileUpload);
const dragenterHandler = this.dragOver.bind(this);
const dragoverHandler = this.dragOver.bind(this);
const dragleaveHandler = this.dragOff.bind(this);
const dragendHandler = this.dragOff.bind(this);
const dropHandler = this.dropped(fileUpload);
// Add new handlers
fileUploadInput.addEventListener('change', changeHandler);
fileUpload.addEventListener('dragenter', dragenterHandler);
fileUpload.addEventListener('dragover', dragoverHandler);
fileUpload.addEventListener('dragleave', dragleaveHandler);
fileUpload.addEventListener('dragend', dragendHandler);
fileUpload.addEventListener('drop', dropHandler);
// Store handlers for cleanup
this.#fileUploadHandlers.set(fileUpload, {
input: fileUploadInput,
changeHandler,
dragenterHandler,
dragoverHandler,
dragleaveHandler,
dragendHandler,
dropHandler
});
}
// Cleanup method to remove all event listeners
cleanup() {
this._cleanupHandlers(this.#fileUploadHandlers, (fileUpload, handlers) => {
handlers.input.removeEventListener('change', handlers.changeHandler);
fileUpload.removeEventListener('dragenter', handlers.dragenterHandler);
fileUpload.removeEventListener('dragover', handlers.dragoverHandler);
fileUpload.removeEventListener('dragleave', handlers.dragleaveHandler);
fileUpload.removeEventListener('dragend', handlers.dragendHandler);
fileUpload.removeEventListener('drop', handlers.dropHandler);
});
}
init() {
this.#fileUploadList.forEach((fileUpload) => this.#handleFileUpload(fileUpload));
}
}
//////////////////////////////////////////////
// E. Form Search
//////////////////////////////////////////////
export class FormSearch extends FormBase {
#searchList = document.querySelectorAll('.form-entry--search');
#inputHandlers = new Map();
#clickHandlers = new Map();
#liveRegions = new Map();
#liveRegionTimers = new Map();
#handleInput(formEntry, input) {
formEntry.classList.toggle('has-value', input.value !== '');
}
#handleClear(formEntry, input) {
input.value = '';
formEntry.classList.remove('has-value');
input.dispatchEvent(new Event('input', { bubbles: true }));
input.focus();
const liveRegion = this.#liveRegions.get(formEntry);
if (liveRegion) {
clearTimeout(this.#liveRegionTimers.get(formEntry));
liveRegion.textContent = 'Search cleared';
const timer = setTimeout(() => { liveRegion.textContent = ''; }, 1000);
this.#liveRegionTimers.set(formEntry, timer);
}
}
cleanup() {
this._cleanupHandlers(this.#inputHandlers, 'input');
this._cleanupHandlers(this.#clickHandlers, 'click');
this.#liveRegionTimers.forEach((timer) => clearTimeout(timer));
this.#liveRegionTimers.clear();
this.#liveRegions.forEach((region) => region.remove());
this.#liveRegions.clear();
}
init() {
this.#searchList.forEach((formEntry) => {
const input = formEntry.querySelector('input[type="search"]');
const clearBtn = formEntry.querySelector('[data-search-clear]');
if (!input) return;
if (input.value !== '') {
formEntry.classList.add('has-value');
}
const liveRegion = document.createElement('span');
liveRegion.setAttribute('aria-live', 'polite');
liveRegion.setAttribute('aria-atomic', 'true');
liveRegion.classList.add('screen-reader-only');
formEntry.appendChild(liveRegion);
this.#liveRegions.set(formEntry, liveRegion);
const inputHandler = () => this.#handleInput(formEntry, input);
input.addEventListener('input', inputHandler);
this.#inputHandlers.set(input, inputHandler);
if (clearBtn) {
const clickHandler = () => this.#handleClear(formEntry, input);
clearBtn.addEventListener('click', clickHandler);
this.#clickHandlers.set(clearBtn, clickHandler);
}
});
}
}