Understanding Form Events
Forms are the primary way users interact with web applications. Think of form events as conversations between your users and your application - each interaction (typing, selecting, submitting) is a message that your code can respond to. Proper form handling and validation ensures a smooth, secure user experience.
Common Form Events
Forms trigger various events during user interaction, each serving a specific purpose.
Input Events
// input event - fires on every change
const searchInput = document.querySelector('#search');
searchInput.addEventListener('input', (e) => {
console.log('Current value:', e.target.value);
// Perfect for real-time search, validation, or filtering
performSearch(e.target.value);
});
// change event - fires when value is committed
const selectElement = document.querySelector('#category');
selectElement.addEventListener('change', (e) => {
console.log('Selected:', e.target.value);
// Good for dropdown selections, radio buttons
updateCategory(e.target.value);
});
// Real-time character counter
const textarea = document.querySelector('#message');
const charCount = document.querySelector('#char-count');
const maxChars = 280;
textarea.addEventListener('input', (e) => {
const remaining = maxChars - e.target.value.length;
charCount.textContent = `${remaining} characters remaining`;
// Visual feedback
if (remaining < 20) {
charCount.style.color = 'red';
} else {
charCount.style.color = 'green';
}
});
// Input event with debouncing for performance
function debounce(func, delay) {
let timeoutId;
return function(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
}
const searchBox = document.querySelector('#search-box');
const debouncedSearch = debounce((query) => {
// Expensive search operation
fetch(`/api/search?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(results => displayResults(results));
}, 300);
searchBox.addEventListener('input', (e) => {
debouncedSearch(e.target.value);
});
Focus and Blur Events
// Focus events for UI enhancement
const formInputs = document.querySelectorAll('input, textarea');
formInputs.forEach(input => {
input.addEventListener('focus', (e) => {
// Highlight the focused field
e.target.parentElement.classList.add('focused');
// Show helper text
const helper = e.target.parentElement.querySelector('.helper-text');
if (helper) {
helper.style.display = 'block';
}
});
input.addEventListener('blur', (e) => {
// Remove highlight
e.target.parentElement.classList.remove('focused');
// Hide helper text
const helper = e.target.parentElement.querySelector('.helper-text');
if (helper) {
helper.style.display = 'none';
}
// Validate on blur
validateField(e.target);
});
});
// Focus management for accessibility
class FormFocusManager {
constructor(form) {
this.form = form;
this.focusableElements = form.querySelectorAll(
'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
);
this.setupFocusManagement();
}
setupFocusManagement() {
// Trap focus within form (useful for modals)
this.form.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
const focusable = Array.from(this.focusableElements);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
});
}
focusFirstElement() {
this.focusableElements[0]?.focus();
}
focusFirstInvalid() {
const firstInvalid = this.form.querySelector('.invalid');
if (firstInvalid) {
firstInvalid.focus();
return true;
}
return false;
}
}
Submit Event
// Basic form submission
const form = document.querySelector('#contact-form');
form.addEventListener('submit', (e) => {
e.preventDefault(); // Prevent default form submission
// Get form data
const formData = new FormData(form);
// Validate form
if (validateForm(form)) {
// Submit data
submitForm(formData);
}
});
// Advanced form submission handling
class FormHandler {
constructor(form) {
this.form = form;
this.submitButton = form.querySelector('button[type="submit"]');
this.setupSubmitHandler();
}
setupSubmitHandler() {
this.form.addEventListener('submit', async (e) => {
e.preventDefault();
if (!this.validateForm()) {
return;
}
this.setLoadingState(true);
try {
const formData = new FormData(this.form);
const response = await this.submitData(formData);
if (response.ok) {
this.handleSuccess(response);
} else {
this.handleError(response);
}
} catch (error) {
this.handleError(error);
} finally {
this.setLoadingState(false);
}
});
}
validateForm() {
// Implementation in validation section
return true;
}
setLoadingState(loading) {
if (loading) {
this.submitButton.disabled = true;
this.submitButton.innerHTML = ' Submitting...';
this.form.classList.add('submitting');
} else {
this.submitButton.disabled = false;
this.submitButton.textContent = 'Submit';
this.form.classList.remove('submitting');
}
}
async submitData(formData) {
const response = await fetch(this.form.action, {
method: this.form.method || 'POST',
body: formData
});
return response;
}
handleSuccess(response) {
this.form.reset();
this.showMessage('Form submitted successfully!', 'success');
// Redirect if needed
if (response.redirected) {
window.location.href = response.url;
}
}
handleError(error) {
this.showMessage('An error occurred. Please try again.', 'error');
console.error('Form submission error:', error);
}
showMessage(message, type) {
const messageEl = document.createElement('div');
messageEl.className = `form-message ${type}`;
messageEl.textContent = message;
this.form.insertBefore(messageEl, this.form.firstChild);
// Remove message after 5 seconds
setTimeout(() => {
messageEl.remove();
}, 5000);
}
}
Form Validation
Validation ensures data integrity and provides immediate feedback to users. There are three types of validation: HTML5 built-in, JavaScript custom, and server-side validation.
HTML5 Validation
Custom JavaScript Validation
// Comprehensive form validator
class FormValidator {
constructor(form, rules) {
this.form = form;
this.rules = rules;
this.errors = {};
this.setupValidation();
}
setupValidation() {
// Real-time validation on input
Object.keys(this.rules).forEach(fieldName => {
const field = this.form.elements[fieldName];
if (field) {
field.addEventListener('input', () => {
this.validateField(fieldName);
});
field.addEventListener('blur', () => {
this.validateField(fieldName);
});
}
});
// Form submission validation
this.form.addEventListener('submit', (e) => {
e.preventDefault();
if (this.validateForm()) {
this.submitForm();
}
});
}
validateField(fieldName) {
const field = this.form.elements[fieldName];
const rules = this.rules[fieldName];
const value = field.value;
// Clear previous errors
delete this.errors[fieldName];
// Check each rule
for (const rule of rules) {
const result = rule.validator(value, field);
if (!result) {
this.errors[fieldName] = rule.message;
this.showFieldError(field, rule.message);
return false;
}
}
this.clearFieldError(field);
return true;
}
validateForm() {
let isValid = true;
Object.keys(this.rules).forEach(fieldName => {
if (!this.validateField(fieldName)) {
isValid = false;
}
});
return isValid;
}
showFieldError(field, message) {
// Remove existing error message
this.clearFieldError(field);
// Add error class
field.classList.add('invalid');
field.setAttribute('aria-invalid', 'true');
// Create error message
const errorElement = document.createElement('div');
errorElement.className = 'error-message';
errorElement.textContent = message;
errorElement.id = `${field.name}-error`;
// Associate error with field
field.setAttribute('aria-describedby', errorElement.id);
// Insert error message
field.parentNode.insertBefore(errorElement, field.nextSibling);
}
clearFieldError(field) {
field.classList.remove('invalid');
field.removeAttribute('aria-invalid');
field.removeAttribute('aria-describedby');
const errorElement = field.parentNode.querySelector('.error-message');
if (errorElement) {
errorElement.remove();
}
}
async submitForm() {
const formData = new FormData(this.form);
try {
const response = await fetch(this.form.action, {
method: 'POST',
body: formData
});
if (response.ok) {
this.form.reset();
this.showSuccess('Form submitted successfully!');
} else {
const error = await response.json();
this.showError(error.message || 'Submission failed');
}
} catch (error) {
this.showError('Network error. Please try again.');
}
}
showSuccess(message) {
// Implementation
}
showError(message) {
// Implementation
}
}
// Validation rules
const validationRules = {
username: [
{
validator: value => value.length >= 3,
message: 'Username must be at least 3 characters long'
},
{
validator: value => /^[a-zA-Z0-9_]+$/.test(value),
message: 'Username can only contain letters, numbers, and underscores'
}
],
email: [
{
validator: value => value.length > 0,
message: 'Email is required'
},
{
validator: value => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
message: 'Please enter a valid email address'
}
],
password: [
{
validator: value => value.length >= 8,
message: 'Password must be at least 8 characters long'
},
{
validator: value => /[A-Z]/.test(value),
message: 'Password must contain at least one uppercase letter'
},
{
validator: value => /[a-z]/.test(value),
message: 'Password must contain at least one lowercase letter'
},
{
validator: value => /[0-9]/.test(value),
message: 'Password must contain at least one number'
},
{
validator: value => /[^A-Za-z0-9]/.test(value),
message: 'Password must contain at least one special character'
}
],
confirmPassword: [
{
validator: (value, field) => {
const password = field.form.elements.password.value;
return value === password;
},
message: 'Passwords do not match'
}
]
};
// Initialize validator
const form = document.querySelector('#registration-form');
const validator = new FormValidator(form, validationRules);
Advanced Validation Patterns
// Cross-field validation
class CrossFieldValidator {
constructor(form) {
this.form = form;
this.setupValidation();
}
setupValidation() {
// Password confirmation
const password = this.form.elements.password;
const confirmPassword = this.form.elements.confirmPassword;
confirmPassword.addEventListener('input', () => {
if (confirmPassword.value !== password.value) {
confirmPassword.setCustomValidity('Passwords do not match');
} else {
confirmPassword.setCustomValidity('');
}
});
// Date range validation
const startDate = this.form.elements.startDate;
const endDate = this.form.elements.endDate;
endDate.addEventListener('change', () => {
if (new Date(endDate.value) < new Date(startDate.value)) {
endDate.setCustomValidity('End date must be after start date');
} else {
endDate.setCustomValidity('');
}
});
}
}
// Async validation (e.g., checking username availability)
class AsyncValidator {
constructor(field, checkEndpoint) {
this.field = field;
this.checkEndpoint = checkEndpoint;
this.timeout = null;
this.setupValidation();
}
setupValidation() {
this.field.addEventListener('input', () => {
clearTimeout(this.timeout);
// Debounce the check
this.timeout = setTimeout(() => {
this.checkAvailability();
}, 500);
});
}
async checkAvailability() {
const value = this.field.value;
if (value.length < 3) {
return;
}
try {
const response = await fetch(`${this.checkEndpoint}?value=${encodeURIComponent(value)}`);
const result = await response.json();
if (result.available) {
this.showSuccess('Available!');
} else {
this.showError('Already taken');
}
} catch (error) {
console.error('Availability check failed:', error);
}
}
showSuccess(message) {
this.field.classList.remove('invalid');
this.field.classList.add('valid');
// Update message UI
}
showError(message) {
this.field.classList.remove('valid');
this.field.classList.add('invalid');
// Update message UI
}
}
// Custom validation frameworks
const ValidationFramework = {
rules: {
required: value => value.trim().length > 0,
email: value => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
minLength: (value, length) => value.length >= length,
maxLength: (value, length) => value.length <= length,
pattern: (value, pattern) => new RegExp(pattern).test(value),
numeric: value => !isNaN(value) && value !== '',
alphanumeric: value => /^[a-zA-Z0-9]+$/.test(value),
phone: value => /^\+?[\d\s-()]+$/.test(value),
url: value => {
try {
new URL(value);
return true;
} catch {
return false;
}
},
creditCard: value => {
// Basic credit card validation
const cleaned = value.replace(/\D/g, '');
return cleaned.length >= 13 && cleaned.length <= 19;
},
date: value => !isNaN(Date.parse(value)),
matches: (value, otherFieldName, form) => {
const otherField = form.elements[otherFieldName];
return value === otherField.value;
}
},
validate(field, rules) {
const errors = [];
rules.forEach(rule => {
if (typeof rule === 'string') {
// Simple rule
if (!this.rules[rule](field.value)) {
errors.push(`${field.name} is invalid`);
}
} else {
// Rule with parameters
const [ruleName, ...params] = rule;
if (!this.rules[ruleName](field.value, ...params)) {
errors.push(`${field.name} is invalid`);
}
}
});
return errors;
}
};
Working with Form Components
Different form elements require specific handling approaches.
Input Types
// Text inputs
const textInput = document.querySelector('input[type="text"]');
textInput.addEventListener('input', (e) => {
// Sanitize input
e.target.value = e.target.value.replace(/[<>]/g, '');
});
// Number inputs
const numberInput = document.querySelector('input[type="number"]');
numberInput.addEventListener('input', (e) => {
// Ensure valid number
const value = parseInt(e.target.value);
if (isNaN(value)) {
e.target.value = '';
}
});
// Date inputs
const dateInput = document.querySelector('input[type="date"]');
dateInput.addEventListener('change', (e) => {
const selectedDate = new Date(e.target.value);
const today = new Date();
if (selectedDate > today) {
alert('Cannot select future date');
e.target.value = '';
}
});
// File inputs
const fileInput = document.querySelector('input[type="file"]');
fileInput.addEventListener('change', (e) => {
const files = e.target.files;
// Validate file types
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf'];
const maxSize = 5 * 1024 * 1024; // 5MB
Array.from(files).forEach(file => {
if (!allowedTypes.includes(file.type)) {
alert(`Invalid file type: ${file.type}`);
e.target.value = '';
return;
}
if (file.size > maxSize) {
alert(`File too large: ${file.name}`);
e.target.value = '';
return;
}
// Preview image files
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
const preview = document.createElement('img');
preview.src = e.target.result;
preview.className = 'file-preview';
document.querySelector('#preview-container').appendChild(preview);
};
reader.readAsDataURL(file);
}
});
});
// Password visibility toggle
function createPasswordToggle(passwordInput) {
const toggleButton = document.createElement('button');
toggleButton.type = 'button';
toggleButton.textContent = '👁️';
toggleButton.className = 'password-toggle';
toggleButton.addEventListener('click', () => {
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
toggleButton.textContent = '👁️🗨️';
} else {
passwordInput.type = 'password';
toggleButton.textContent = '👁️';
}
});
passwordInput.parentNode.appendChild(toggleButton);
}
Select Elements
// Basic select handling
const select = document.querySelector('select');
select.addEventListener('change', (e) => {
const selectedOption = e.target.options[e.target.selectedIndex];
console.log('Selected:', selectedOption.value, selectedOption.text);
});
// Dynamic select options
class DynamicSelect {
constructor(selectElement, dataSource) {
this.select = selectElement;
this.dataSource = dataSource;
this.initialize();
}
async initialize() {
// Load initial options
const data = await this.dataSource.getData();
this.populateOptions(data);
// Setup filtering if searchable
if (this.select.dataset.searchable) {
this.makeSearchable();
}
}
populateOptions(data) {
// Clear existing options
this.select.innerHTML = '';
// Add default option
const defaultOption = document.createElement('option');
defaultOption.value = '';
defaultOption.textContent = 'Select an option';
this.select.appendChild(defaultOption);
// Add data options
data.forEach(item => {
const option = document.createElement('option');
option.value = item.value;
option.textContent = item.label;
if (item.data) {
Object.entries(item.data).forEach(([key, value]) => {
option.dataset[key] = value;
});
}
this.select.appendChild(option);
});
}
makeSearchable() {
// Create search input
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = 'Search...';
searchInput.className = 'select-search';
// Insert before select
this.select.parentNode.insertBefore(searchInput, this.select);
// Filter options on input
searchInput.addEventListener('input', (e) => {
const searchTerm = e.target.value.toLowerCase();
const options = this.select.options;
for (let i = 1; i < options.length; i++) {
const option = options[i];
const text = option.textContent.toLowerCase();
if (text.includes(searchTerm)) {
option.style.display = '';
} else {
option.style.display = 'none';
}
}
});
}
}
// Dependent selects (e.g., Country -> State -> City)
class DependentSelects {
constructor(selects, dataSource) {
this.selects = selects; // Array of select elements
this.dataSource = dataSource;
this.initialize();
}
initialize() {
this.selects.forEach((select, index) => {
if (index < this.selects.length - 1) {
select.addEventListener('change', () => {
this.updateDependentSelect(index);
});
}
});
// Load initial data for first select
this.loadSelectData(0);
}
async loadSelectData(index, parentValue = null) {
const select = this.selects[index];
const data = await this.dataSource.getData(index, parentValue);
// Clear select
select.innerHTML = '';
// Populate options
data.forEach(item => {
const option = document.createElement('option');
option.value = item.value;
option.textContent = item.label;
select.appendChild(option);
});
// Clear subsequent selects
for (let i = index + 1; i < this.selects.length; i++) {
this.selects[i].innerHTML = '';
this.selects[i].disabled = true;
}
}
async updateDependentSelect(index) {
const currentSelect = this.selects[index];
const nextSelect = this.selects[index + 1];
if (currentSelect.value) {
nextSelect.disabled = false;
await this.loadSelectData(index + 1, currentSelect.value);
} else {
nextSelect.disabled = true;
nextSelect.innerHTML = '';
}
}
}
Checkboxes and Radio Buttons
// Checkbox group handling
class CheckboxGroup {
constructor(container) {
this.container = container;
this.checkboxes = container.querySelectorAll('input[type="checkbox"]');
this.selectAllCheckbox = container.querySelector('.select-all');
this.initialize();
}
initialize() {
// Handle individual checkboxes
this.checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', () => {
this.updateSelectAll();
this.triggerChangeEvent();
});
});
// Handle select all
if (this.selectAllCheckbox) {
this.selectAllCheckbox.addEventListener('change', (e) => {
this.checkboxes.forEach(checkbox => {
checkbox.checked = e.target.checked;
});
this.triggerChangeEvent();
});
}
}
updateSelectAll() {
if (!this.selectAllCheckbox) return;
const checkedCount = this.getCheckedValues().length;
const totalCount = this.checkboxes.length;
this.selectAllCheckbox.checked = checkedCount === totalCount;
this.selectAllCheckbox.indeterminate =
checkedCount > 0 && checkedCount < totalCount;
}
getCheckedValues() {
return Array.from(this.checkboxes)
.filter(cb => cb.checked)
.map(cb => cb.value);
}
triggerChangeEvent() {
const event = new CustomEvent('checkboxGroupChange', {
detail: { values: this.getCheckedValues() }
});
this.container.dispatchEvent(event);
}
}
// Radio button group validation
class RadioGroup {
constructor(name) {
this.name = name;
this.radios = document.querySelectorAll(`input[type="radio"][name="${name}"]`);
this.initialize();
}
initialize() {
this.radios.forEach(radio => {
radio.addEventListener('change', () => {
this.clearValidationErrors();
});
});
}
getValue() {
const checked = Array.from(this.radios).find(r => r.checked);
return checked ? checked.value : null;
}
validate() {
if (!this.getValue()) {
this.showError('Please select an option');
return false;
}
return true;
}
showError(message) {
const firstRadio = this.radios[0];
const errorEl = document.createElement('div');
errorEl.className = 'radio-error';
errorEl.textContent = message;
firstRadio.parentNode.appendChild(errorEl);
}
clearValidationErrors() {
const errors = document.querySelectorAll('.radio-error');
errors.forEach(error => error.remove());
}
}
Custom Form Controls
// Custom select dropdown
class CustomSelect {
constructor(element) {
this.element = element;
this.select = element.querySelector('select');
this.createCustomUI();
}
createCustomUI() {
// Hide original select
this.select.style.display = 'none';
// Create custom UI
this.customSelect = document.createElement('div');
this.customSelect.className = 'custom-select';
this.selected = document.createElement('div');
this.selected.className = 'custom-select-selected';
this.selected.textContent = this.select.options[this.select.selectedIndex].text;
this.optionsList = document.createElement('div');
this.optionsList.className = 'custom-select-options';
// Populate options
Array.from(this.select.options).forEach((option, index) => {
const customOption = document.createElement('div');
customOption.className = 'custom-select-option';
customOption.textContent = option.text;
customOption.dataset.value = option.value;
customOption.addEventListener('click', () => {
this.selectOption(index);
});
this.optionsList.appendChild(customOption);
});
this.customSelect.appendChild(this.selected);
this.customSelect.appendChild(this.optionsList);
this.element.appendChild(this.customSelect);
// Toggle dropdown
this.selected.addEventListener('click', () => {
this.customSelect.classList.toggle('open');
});
// Close on outside click
document.addEventListener('click', (e) => {
if (!this.customSelect.contains(e.target)) {
this.customSelect.classList.remove('open');
}
});
}
selectOption(index) {
this.select.selectedIndex = index;
this.selected.textContent = this.select.options[index].text;
this.customSelect.classList.remove('open');
// Trigger change event
const event = new Event('change');
this.select.dispatchEvent(event);
}
}
// Custom file input
class CustomFileInput {
constructor(element) {
this.element = element;
this.input = element.querySelector('input[type="file"]');
this.createCustomUI();
}
createCustomUI() {
// Hide original input
this.input.style.display = 'none';
// Create custom button
this.button = document.createElement('button');
this.button.type = 'button';
this.button.className = 'custom-file-button';
this.button.textContent = 'Choose File';
// Create file name display
this.fileNameDisplay = document.createElement('span');
this.fileNameDisplay.className = 'file-name';
this.fileNameDisplay.textContent = 'No file chosen';
// Create preview container
this.previewContainer = document.createElement('div');
this.previewContainer.className = 'file-preview-container';
this.element.appendChild(this.button);
this.element.appendChild(this.fileNameDisplay);
this.element.appendChild(this.previewContainer);
// Handle button click
this.button.addEventListener('click', () => {
this.input.click();
});
// Handle file selection
this.input.addEventListener('change', (e) => {
this.handleFileSelect(e);
});
}
handleFileSelect(e) {
const files = e.target.files;
if (files.length > 0) {
this.fileNameDisplay.textContent =
files.length === 1 ? files[0].name : `${files.length} files selected`;
// Clear previous previews
this.previewContainer.innerHTML = '';
// Create previews
Array.from(files).forEach(file => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
const img = document.createElement('img');
img.src = e.target.result;
img.className = 'file-preview';
this.previewContainer.appendChild(img);
};
reader.readAsDataURL(file);
}
});
} else {
this.fileNameDisplay.textContent = 'No file chosen';
this.previewContainer.innerHTML = '';
}
}
}
Advanced Form Patterns
Multi-Step Forms
class MultiStepForm {
constructor(formElement) {
this.form = formElement;
this.steps = Array.from(formElement.querySelectorAll('.form-step'));
this.currentStep = 0;
this.progressBar = formElement.querySelector('.progress-bar');
this.stepIndicators = formElement.querySelectorAll('.step-indicator');
this.initialize();
}
initialize() {
// Hide all steps except first
this.steps.forEach((step, index) => {
step.style.display = index === 0 ? 'block' : 'none';
});
// Add navigation buttons
this.steps.forEach((step, index) => {
if (index > 0) {
const prevButton = document.createElement('button');
prevButton.type = 'button';
prevButton.textContent = 'Previous';
prevButton.className = 'prev-button';
prevButton.addEventListener('click', () => this.prevStep());
step.appendChild(prevButton);
}
if (index < this.steps.length - 1) {
const nextButton = document.createElement('button');
nextButton.type = 'button';
nextButton.textContent = 'Next';
nextButton.className = 'next-button';
nextButton.addEventListener('click', () => this.nextStep());
step.appendChild(nextButton);
} else {
const submitButton = document.createElement('button');
submitButton.type = 'submit';
submitButton.textContent = 'Submit';
submitButton.className = 'submit-button';
step.appendChild(submitButton);
}
});
// Form submission
this.form.addEventListener('submit', (e) => {
e.preventDefault();
this.submitForm();
});
this.updateProgress();
}
nextStep() {
if (this.validateCurrentStep()) {
if (this.currentStep < this.steps.length - 1) {
this.steps[this.currentStep].style.display = 'none';
this.currentStep++;
this.steps[this.currentStep].style.display = 'block';
this.updateProgress();
// Focus first input in new step
const firstInput = this.steps[this.currentStep].querySelector('input, select, textarea');
if (firstInput) firstInput.focus();
}
}
}
prevStep() {
if (this.currentStep > 0) {
this.steps[this.currentStep].style.display = 'none';
this.currentStep--;
this.steps[this.currentStep].style.display = 'block';
this.updateProgress();
}
}
validateCurrentStep() {
const currentStepElement = this.steps[this.currentStep];
const inputs = currentStepElement.querySelectorAll('input, select, textarea');
let isValid = true;
inputs.forEach(input => {
if (!input.checkValidity()) {
input.reportValidity();
isValid = false;
}
});
return isValid;
}
updateProgress() {
const progress = ((this.currentStep + 1) / this.steps.length) * 100;
if (this.progressBar) {
this.progressBar.style.width = `${progress}%`;
}
this.stepIndicators.forEach((indicator, index) => {
indicator.classList.toggle('active', index === this.currentStep);
indicator.classList.toggle('completed', index < this.currentStep);
});
}
async submitForm() {
const formData = new FormData(this.form);
try {
const response = await fetch(this.form.action, {
method: 'POST',
body: formData
});
if (response.ok) {
this.showSuccess();
} else {
this.showError();
}
} catch (error) {
this.showError();
}
}
showSuccess() {
// Show success message
const successMessage = document.createElement('div');
successMessage.className = 'success-message';
successMessage.textContent = 'Form submitted successfully!';
this.form.appendChild(successMessage);
// Hide form steps
this.steps.forEach(step => step.style.display = 'none');
}
showError() {
// Show error message
const errorMessage = document.createElement('div');
errorMessage.className = 'error-message';
errorMessage.textContent = 'An error occurred. Please try again.';
this.form.insertBefore(errorMessage, this.form.firstChild);
}
}
Auto-Save Forms
class AutoSaveForm {
constructor(form, options = {}) {
this.form = form;
this.options = {
saveInterval: 30000, // 30 seconds
storageKey: 'form-autosave',
debounceDelay: 1000,
...options
};
this.initialize();
}
initialize() {
// Load saved data
this.loadSavedData();
// Setup auto-save
this.setupAutoSave();
// Clear saved data on successful submission
this.form.addEventListener('submit', () => {
this.clearSavedData();
});
}
setupAutoSave() {
let saveTimeout;
// Save on input changes (debounced)
this.form.addEventListener('input', () => {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
this.saveFormData();
}, this.options.debounceDelay);
});
// Periodic save
setInterval(() => {
this.saveFormData();
}, this.options.saveInterval);
// Save before page unload
window.addEventListener('beforeunload', () => {
this.saveFormData();
});
}
saveFormData() {
const formData = new FormData(this.form);
const data = {};
for (let [key, value] of formData.entries()) {
data[key] = value;
}
localStorage.setItem(this.options.storageKey, JSON.stringify(data));
this.showSaveIndicator();
}
loadSavedData() {
const savedData = localStorage.getItem(this.options.storageKey);
if (savedData) {
const data = JSON.parse(savedData);
Object.entries(data).forEach(([key, value]) => {
const field = this.form.elements[key];
if (field) {
if (field.type === 'checkbox' || field.type === 'radio') {
field.checked = field.value === value;
} else {
field.value = value;
}
}
});
this.showRestoreMessage();
}
}
clearSavedData() {
localStorage.removeItem(this.options.storageKey);
}
showSaveIndicator() {
let indicator = this.form.querySelector('.save-indicator');
if (!indicator) {
indicator = document.createElement('div');
indicator.className = 'save-indicator';
this.form.appendChild(indicator);
}
indicator.textContent = 'Saved';
indicator.style.opacity = '1';
setTimeout(() => {
indicator.style.opacity = '0';
}, 2000);
}
showRestoreMessage() {
const message = document.createElement('div');
message.className = 'restore-message';
message.innerHTML = `
Restored previously saved data.
`;
this.form.insertBefore(message, this.form.firstChild);
message.querySelector('.clear-saved').addEventListener('click', () => {
this.clearSavedData();
this.form.reset();
message.remove();
});
}
}
Form Accessibility
Creating accessible forms ensures all users can interact with your application effectively.
// Accessible form patterns
class AccessibleForm {
constructor(form) {
this.form = form;
this.setupAccessibility();
}
setupAccessibility() {
// Ensure all inputs have labels
this.ensureLabels();
// Add ARIA attributes
this.addAriaAttributes();
// Setup keyboard navigation
this.setupKeyboardNav();
// Error announcements
this.setupErrorAnnouncements();
}
ensureLabels() {
const inputs = this.form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
if (!input.id) {
input.id = `field-${Math.random().toString(36).substr(2, 9)}`;
}
let label = this.form.querySelector(`label[for="${input.id}"]`);
if (!label && input.parentElement.tagName === 'LABEL') {
// Input is wrapped in label
return;
}
if (!label) {
// Create label from placeholder or name
label = document.createElement('label');
label.htmlFor = input.id;
label.textContent = input.placeholder || input.name;
input.parentNode.insertBefore(label, input);
}
});
}
addAriaAttributes() {
// Required fields
const requiredFields = this.form.querySelectorAll('[required]');
requiredFields.forEach(field => {
field.setAttribute('aria-required', 'true');
});
// Field descriptions
const descriptions = this.form.querySelectorAll('.field-description');
descriptions.forEach(desc => {
const field = desc.previousElementSibling;
if (field && field.tagName === 'INPUT') {
const id = `desc-${Math.random().toString(36).substr(2, 9)}`;
desc.id = id;
field.setAttribute('aria-describedby', id);
}
});
}
setupKeyboardNav() {
this.form.addEventListener('keydown', (e) => {
// Enter key submits form (except in textarea)
if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA') {
e.preventDefault();
const submitButton = this.form.querySelector('[type="submit"]');
if (submitButton) {
submitButton.click();
}
}
});
}
setupErrorAnnouncements() {
// Create live region for error announcements
const liveRegion = document.createElement('div');
liveRegion.setAttribute('aria-live', 'polite');
liveRegion.setAttribute('aria-atomic', 'true');
liveRegion.className = 'sr-only'; // Visually hidden
this.form.appendChild(liveRegion);
// Announce errors
this.form.addEventListener('invalid', (e) => {
e.preventDefault();
const field = e.target;
const errorMessage = field.validationMessage;
liveRegion.textContent = `${field.name}: ${errorMessage}`;
// Focus first invalid field
if (!this.form.querySelector('.invalid')) {
field.focus();
}
field.classList.add('invalid');
}, true);
}
}
// Screen reader friendly form messages
class FormMessages {
constructor(form) {
this.form = form;
this.messageContainer = this.createMessageContainer();
}
createMessageContainer() {
const container = document.createElement('div');
container.className = 'form-messages';
container.setAttribute('role', 'status');
container.setAttribute('aria-live', 'polite');
this.form.insertBefore(container, this.form.firstChild);
return container;
}
showMessage(message, type = 'info') {
const messageElement = document.createElement('div');
messageElement.className = `message ${type}`;
messageElement.textContent = message;
// Add appropriate role
if (type === 'error') {
messageElement.setAttribute('role', 'alert');
}
this.messageContainer.appendChild(messageElement);
// Auto-remove after delay
setTimeout(() => {
messageElement.remove();
}, 5000);
}
clearMessages() {
this.messageContainer.innerHTML = '';
}
}
Practice Exercises
- Create a registration form with real-time validation
- Build a multi-step order form with progress indicator
- Implement an auto-complete search input
- Create a file upload form with drag-and-drop support
Exercise Solutions (Try First!)
Click to see solutions
// 1. Registration Form with Real-time Validation
class RegistrationForm {
constructor(formId) {
this.form = document.getElementById(formId);
this.validators = {
username: [
{ test: value => value.length >= 3, message: 'Username must be at least 3 characters' },
{ test: value => /^[a-zA-Z0-9_]+$/.test(value), message: 'Username can only contain letters, numbers, and underscores' }
],
email: [
{ test: value => value.includes('@'), message: 'Please enter a valid email' },
{ test: value => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value), message: 'Please enter a valid email address' }
],
password: [
{ test: value => value.length >= 8, message: 'Password must be at least 8 characters' },
{ test: value => /[A-Z]/.test(value), message: 'Password must contain an uppercase letter' },
{ test: value => /[0-9]/.test(value), message: 'Password must contain a number' }
],
confirmPassword: [
{
test: value => value === this.form.elements.password.value,
message: 'Passwords do not match'
}
]
};
this.setupValidation();
}
setupValidation() {
Object.keys(this.validators).forEach(fieldName => {
const field = this.form.elements[fieldName];
field.addEventListener('input', () => this.validateField(fieldName));
field.addEventListener('blur', () => this.validateField(fieldName));
});
this.form.addEventListener('submit', (e) => {
e.preventDefault();
if (this.validateForm()) {
this.submitForm();
}
});
}
validateField(fieldName) {
const field = this.form.elements[fieldName];
const validators = this.validators[fieldName];
let isValid = true;
for (const validator of validators) {
if (!validator.test(field.value)) {
this.showError(field, validator.message);
isValid = false;
break;
}
}
if (isValid) {
this.clearError(field);
}
return isValid;
}
validateForm() {
let isValid = true;
Object.keys(this.validators).forEach(fieldName => {
if (!this.validateField(fieldName)) {
isValid = false;
}
});
return isValid;
}
showError(field, message) {
const errorId = `${field.name}-error`;
let errorElement = document.getElementById(errorId);
if (!errorElement) {
errorElement = document.createElement('div');
errorElement.id = errorId;
errorElement.className = 'error-message';
field.parentNode.insertBefore(errorElement, field.nextSibling);
}
errorElement.textContent = message;
field.classList.add('invalid');
field.setAttribute('aria-invalid', 'true');
field.setAttribute('aria-describedby', errorId);
}
clearError(field) {
const errorElement = document.getElementById(`${field.name}-error`);
if (errorElement) {
errorElement.remove();
}
field.classList.remove('invalid');
field.removeAttribute('aria-invalid');
field.removeAttribute('aria-describedby');
}
async submitForm() {
const formData = new FormData(this.form);
try {
const response = await fetch('/api/register', {
method: 'POST',
body: formData
});
if (response.ok) {
alert('Registration successful!');
this.form.reset();
} else {
alert('Registration failed. Please try again.');
}
} catch (error) {
console.error('Error:', error);
alert('An error occurred. Please try again.');
}
}
}
// 2. Multi-step Order Form
class OrderForm {
constructor(formId) {
this.form = document.getElementById(formId);
this.steps = [];
this.currentStep = 0;
this.initializeSteps();
this.createProgressIndicator();
this.setupNavigation();
}
initializeSteps() {
const stepElements = this.form.querySelectorAll('.form-step');
stepElements.forEach((element, index) => {
this.steps.push({
element: element,
title: element.dataset.title,
isValid: false
});
if (index !== 0) {
element.style.display = 'none';
}
});
}
createProgressIndicator() {
const progressBar = document.createElement('div');
progressBar.className = 'progress-bar';
this.steps.forEach((step, index) => {
const indicator = document.createElement('div');
indicator.className = 'step-indicator';
indicator.textContent = index + 1;
indicator.title = step.title;
if (index === 0) {
indicator.classList.add('active');
}
progressBar.appendChild(indicator);
});
this.form.insertBefore(progressBar, this.form.firstChild);
this.progressIndicators = progressBar.querySelectorAll('.step-indicator');
}
setupNavigation() {
const navigation = document.createElement('div');
navigation.className = 'form-navigation';
this.prevButton = document.createElement('button');
this.prevButton.type = 'button';
this.prevButton.textContent = 'Previous';
this.prevButton.addEventListener('click', () => this.previousStep());
this.nextButton = document.createElement('button');
this.nextButton.type = 'button';
this.nextButton.textContent = 'Next';
this.nextButton.addEventListener('click', () => this.nextStep());
this.submitButton = document.createElement('button');
this.submitButton.type = 'submit';
this.submitButton.textContent = 'Submit Order';
this.submitButton.style.display = 'none';
navigation.appendChild(this.prevButton);
navigation.appendChild(this.nextButton);
navigation.appendChild(this.submitButton);
this.form.appendChild(navigation);
this.updateNavigation();
this.form.addEventListener('submit', (e) => {
e.preventDefault();
this.submitOrder();
});
}
updateNavigation() {
this.prevButton.disabled = this.currentStep === 0;
if (this.currentStep === this.steps.length - 1) {
this.nextButton.style.display = 'none';
this.submitButton.style.display = 'inline-block';
} else {
this.nextButton.style.display = 'inline-block';
this.submitButton.style.display = 'none';
}
}
updateProgressIndicator() {
this.progressIndicators.forEach((indicator, index) => {
indicator.classList.remove('active', 'completed');
if (index === this.currentStep) {
indicator.classList.add('active');
} else if (index < this.currentStep) {
indicator.classList.add('completed');
}
});
}
validateCurrentStep() {
const currentStepElement = this.steps[this.currentStep].element;
const inputs = currentStepElement.querySelectorAll('input, select, textarea');
let isValid = true;
inputs.forEach(input => {
if (!input.checkValidity()) {
input.reportValidity();
isValid = false;
}
});
this.steps[this.currentStep].isValid = isValid;
return isValid;
}
nextStep() {
if (this.validateCurrentStep() && this.currentStep < this.steps.length - 1) {
this.steps[this.currentStep].element.style.display = 'none';
this.currentStep++;
this.steps[this.currentStep].element.style.display = 'block';
this.updateNavigation();
this.updateProgressIndicator();
// Focus first input in new step
const firstInput = this.steps[this.currentStep].element.querySelector('input, select, textarea');
if (firstInput) {
firstInput.focus();
}
}
}
previousStep() {
if (this.currentStep > 0) {
this.steps[this.currentStep].element.style.display = 'none';
this.currentStep--;
this.steps[this.currentStep].element.style.display = 'block';
this.updateNavigation();
this.updateProgressIndicator();
}
}
async submitOrder() {
if (!this.validateCurrentStep()) {
return;
}
const formData = new FormData(this.form);
try {
const response = await fetch('/api/orders', {
method: 'POST',
body: formData
});
if (response.ok) {
const order = await response.json();
alert(`Order placed successfully! Order ID: ${order.id}`);
this.form.reset();
// Reset to first step
this.steps[this.currentStep].element.style.display = 'none';
this.currentStep = 0;
this.steps[0].element.style.display = 'block';
this.updateNavigation();
this.updateProgressIndicator();
} else {
alert('Failed to place order. Please try again.');
}
} catch (error) {
console.error('Error:', error);
alert('An error occurred. Please try again.');
}
}
}
// 3. Auto-complete Search Input
class AutocompleteSearch {
constructor(inputId, options = {}) {
this.input = document.getElementById(inputId);
this.options = {
minChars: 2,
delay: 300,
maxResults: 10,
searchEndpoint: '/api/search',
...options
};
this.resultsContainer = this.createResultsContainer();
this.selectedIndex = -1;
this.results = [];
this.setupEventListeners();
}
createResultsContainer() {
const container = document.createElement('div');
container.className = 'autocomplete-results';
container.style.display = 'none';
this.input.parentNode.style.position = 'relative';
this.input.parentNode.appendChild(container);
return container;
}
setupEventListeners() {
let debounceTimer;
this.input.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
const query = e.target.value;
if (query.length < this.options.minChars) {
this.hideResults();
return;
}
debounceTimer = setTimeout(() => {
this.searchQuery(query);
}, this.options.delay);
});
this.input.addEventListener('keydown', (e) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this.selectNext();
break;
case 'ArrowUp':
e.preventDefault();
this.selectPrevious();
break;
case 'Enter':
if (this.selectedIndex >= 0) {
e.preventDefault();
this.selectResult(this.results[this.selectedIndex]);
}
break;
case 'Escape':
this.hideResults();
break;
}
});
this.input.addEventListener('blur', (e) => {
// Delay to allow click events on results
setTimeout(() => {
this.hideResults();
}, 200);
});
document.addEventListener('click', (e) => {
if (!this.input.parentNode.contains(e.target)) {
this.hideResults();
}
});
}
async searchQuery(query) {
try {
const response = await fetch(`${this.options.searchEndpoint}?q=${encodeURIComponent(query)}`);
const data = await response.json();
this.results = data.results.slice(0, this.options.maxResults);
this.displayResults(this.results);
} catch (error) {
console.error('Search error:', error);
}
}
displayResults(results) {
if (results.length === 0) {
this.hideResults();
return;
}
this.resultsContainer.innerHTML = '';
this.selectedIndex = -1;
results.forEach((result, index) => {
const item = document.createElement('div');
item.className = 'autocomplete-item';
item.textContent = result.text;
item.dataset.value = result.value;
item.addEventListener('click', () => {
this.selectResult(result);
});
item.addEventListener('mouseover', () => {
this.highlightItem(index);
});
this.resultsContainer.appendChild(item);
});
this.resultsContainer.style.display = 'block';
}
highlightItem(index) {
const items = this.resultsContainer.children;
Array.from(items).forEach((item, i) => {
item.classList.toggle('highlighted', i === index);
});
this.selectedIndex = index;
}
selectNext() {
const maxIndex = this.results.length - 1;
const nextIndex = this.selectedIndex < maxIndex ? this.selectedIndex + 1 : 0;
this.highlightItem(nextIndex);
}
selectPrevious() {
const maxIndex = this.results.length - 1;
const prevIndex = this.selectedIndex > 0 ? this.selectedIndex - 1 : maxIndex;
this.highlightItem(prevIndex);
}
selectResult(result) {
this.input.value = result.text;
this.input.dataset.value = result.value;
// Trigger change event
const event = new Event('change', { bubbles: true });
this.input.dispatchEvent(event);
this.hideResults();
}
hideResults() {
this.resultsContainer.style.display = 'none';
this.selectedIndex = -1;
this.results = [];
}
}
// 4. File Upload with Drag and Drop
class DragDropUploader {
constructor(containerId, options = {}) {
this.container = document.getElementById(containerId);
this.options = {
maxFiles: 5,
maxSize: 5 * 1024 * 1024, // 5MB
acceptedTypes: ['image/*', 'application/pdf'],
uploadEndpoint: '/api/upload',
...options
};
this.files = new Map();
this.setupUI();
this.setupEventListeners();
}
setupUI() {
this.container.innerHTML = `
Drag and drop files here or click to select
`;
this.dropZone = this.container.querySelector('.drop-zone');
this.fileInput = this.container.querySelector('.file-input');
this.fileList = this.container.querySelector('.file-list');
this.selectButton = this.container.querySelector('.select-files-btn');
this.uploadButton = this.container.querySelector('.upload-all-btn');
this.clearButton = this.container.querySelector('.clear-all-btn');
}
setupEventListeners() {
// File input change
this.fileInput.addEventListener('change', (e) => {
this.handleFiles(Array.from(e.target.files));
});
// Select files button
this.selectButton.addEventListener('click', () => {
this.fileInput.click();
});
// Drag and drop events
this.dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
this.dropZone.classList.add('drag-over');
});
this.dropZone.addEventListener('dragleave', () => {
this.dropZone.classList.remove('drag-over');
});
this.dropZone.addEventListener('drop', (e) => {
e.preventDefault();
this.dropZone.classList.remove('drag-over');
const files = Array.from(e.dataTransfer.files);
this.handleFiles(files);
});
// Upload and clear buttons
this.uploadButton.addEventListener('click', () => {
this.uploadAll();
});
this.clearButton.addEventListener('click', () => {
this.clearAll();
});
}
handleFiles(files) {
const validFiles = files.filter(file => this.validateFile(file));
validFiles.forEach(file => {
if (this.files.size < this.options.maxFiles) {
this.addFile(file);
} else {
alert(`Maximum ${this.options.maxFiles} files allowed`);
}
});
this.updateButtons();
}
validateFile(file) {
// Check file size
if (file.size > this.options.maxSize) {
alert(`File "${file.name}" is too large. Maximum size is ${this.formatFileSize(this.options.maxSize)}`);
return false;
}
// Check file type
const isValidType = this.options.acceptedTypes.some(type => {
if (type.endsWith('/*')) {
return file.type.startsWith(type.slice(0, -2));
}
return file.type === type;
});
if (!isValidType) {
alert(`File type "${file.type}" is not accepted`);
return false;
}
return true;
}
addFile(file) {
const id = Date.now() + Math.random();
this.files.set(id, {
file: file,
progress: 0,
status: 'pending'
});
this.renderFileItem(id, file);
}
renderFileItem(id, file) {
const fileItem = document.createElement('div');
fileItem.className = 'file-item';
fileItem.dataset.id = id;
fileItem.innerHTML = `
${file.name}
${this.formatFileSize(file.size)}
0%
`;
// Add remove handler
fileItem.querySelector('.remove-file-btn').addEventListener('click', () => {
this.removeFile(id);
});
this.fileList.appendChild(fileItem);
// Add preview for images
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
const preview = document.createElement('img');
preview.className = 'file-preview';
preview.src = e.target.result;
fileItem.insertBefore(preview, fileItem.firstChild);
};
reader.readAsDataURL(file);
}
}
removeFile(id) {
this.files.delete(id);
const fileItem = this.fileList.querySelector(`[data-id="${id}"]`);
if (fileItem) {
fileItem.remove();
}
this.updateButtons();
}
clearAll() {
this.files.clear();
this.fileList.innerHTML = '';
this.updateButtons();
}
updateButtons() {
const hasFiles = this.files.size > 0;
this.uploadButton.disabled = !hasFiles;
this.clearButton.disabled = !hasFiles;
}
async uploadAll() {
this.uploadButton.disabled = true;
for (const [id, fileData] of this.files) {
if (fileData.status === 'pending') {
await this.uploadFile(id, fileData);
}
}
this.uploadButton.disabled = false;
}
async uploadFile(id, fileData) {
const formData = new FormData();
formData.append('file', fileData.file);
try {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const progress = Math.round((e.loaded / e.total) * 100);
this.updateProgress(id, progress);
}
});
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
this.updateFileStatus(id, 'completed');
} else {
this.updateFileStatus(id, 'error');
}
});
xhr.addEventListener('error', () => {
this.updateFileStatus(id, 'error');
});
xhr.open('POST', this.options.uploadEndpoint);
xhr.send(formData);
this.updateFileStatus(id, 'uploading');
} catch (error) {
console.error('Upload error:', error);
this.updateFileStatus(id, 'error');
}
}
updateProgress(id, progress) {
const fileItem = this.fileList.querySelector(`[data-id="${id}"]`);
if (fileItem) {
const progressFill = fileItem.querySelector('.progress-fill');
const progressText = fileItem.querySelector('.progress-text');
progressFill.style.width = `${progress}%`;
progressText.textContent = `${progress}%`;
}
const fileData = this.files.get(id);
if (fileData) {
fileData.progress = progress;
}
}
updateFileStatus(id, status) {
const fileItem = this.fileList.querySelector(`[data-id="${id}"]`);
if (fileItem) {
fileItem.dataset.status = status;
if (status === 'completed') {
fileItem.classList.add('upload-complete');
const removeBtn = fileItem.querySelector('.remove-file-btn');
removeBtn.textContent = '✓';
removeBtn.disabled = true;
} else if (status === 'error') {
fileItem.classList.add('upload-error');
}
}
const fileData = this.files.get(id);
if (fileData) {
fileData.status = status;
}
}
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
}
Summary
Form events and validation are crucial for creating interactive, user-friendly web applications:
- Common form events include input, change, submit, focus, and blur
- Combine HTML5 validation attributes with custom JavaScript validation
- Provide real-time feedback for better user experience
- Handle different form controls appropriately (inputs, selects, checkboxes, etc.)
- Create accessible forms with proper ARIA attributes and keyboard navigation
- Use advanced patterns like multi-step forms and auto-save functionality
Key takeaways:
- Always prevent default form submission when handling forms with JavaScript
- Validate both client-side and server-side for security
- Provide clear, immediate feedback for validation errors
- Make forms accessible to all users
- Consider progressive enhancement for better user experience
Master form handling to create professional, user-friendly web applications!