JavaScript Essentials

Form Handling & Validation

45 min Lesson 26 of 60

Accessing Form Elements in JavaScript

Before you can validate or process a form, you need to know how to access its elements. JavaScript provides multiple ways to reference forms and their inputs. The most reliable approach is using the document.forms collection, element IDs, or the elements property of a form object. Each method has its use case, and understanding all of them makes you a more versatile developer.

Example: Different Ways to Access Form Elements

<form id="signup-form" name="signup">
    <input type="text" id="username" name="username">
    <input type="email" id="email" name="email">
    <input type="password" id="password" name="password">
    <button type="submit">Sign Up</button>
</form>

<script>
// Method 1: By ID (most common and reliable)
const form = document.getElementById('signup-form');

// Method 2: Using document.forms collection
const formByName = document.forms['signup'];    // by name attribute
const formByIndex = document.forms[0];           // by index

// Access individual inputs through the form.elements collection
const usernameInput = form.elements['username']; // by name attribute
const emailInput = form.elements['email'];
const passwordInput = form.elements['password'];

// Access by ID directly
const usernameById = document.getElementById('username');

// The elements property also supports index access
const firstInput = form.elements[0];  // first input in the form

// Get total number of form controls
console.log('Form has', form.elements.length, 'elements');

// Loop through all form elements
for (let i = 0; i < form.elements.length; i++) {
    const el = form.elements[i];
    console.log(el.name, el.type, el.value);
}
</script>
Pro Tip: Using form.elements['name'] is preferred over document.getElementById() when working with forms because it scopes the lookup to that specific form. This prevents conflicts when a page contains multiple forms with similarly named fields.

Form Submission: The submit Event

The submit event fires when a user submits a form, either by clicking a submit button or pressing Enter inside a text input. This event fires on the <form> element, not on the submit button. The most important thing to understand about form submission is that the browser will attempt to navigate to the form's action URL by default. To handle submission with JavaScript, you must call event.preventDefault() to stop this default behavior.

Example: Handling Form Submission

<form id="contact-form" action="/api/contact" method="POST">
    <label for="name">Name:</label>
    <input type="text" id="name" name="name" required>

    <label for="message">Message:</label>
    <textarea id="message" name="message" required></textarea>

    <button type="submit">Send</button>
</form>

<script>
const form = document.getElementById('contact-form');

form.addEventListener('submit', function(e) {
    // Prevent the browser from navigating to the action URL
    e.preventDefault();

    // Now you can process the form data with JavaScript
    const name = form.elements['name'].value;
    const message = form.elements['message'].value;

    console.log('Name:', name);
    console.log('Message:', message);

    // Send data via fetch API instead
    fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name: name, message: message })
    })
    .then(function(response) { return response.json(); })
    .then(function(data) {
        console.log('Success:', data);
        form.reset(); // Clear the form after successful submission
    })
    .catch(function(error) {
        console.error('Error:', error);
    });
});
</script>
Important: Always call e.preventDefault() at the very beginning of your submit handler, before any validation logic. If your validation code throws an error before reaching preventDefault(), the form will submit normally and navigate away from the page, losing any error messages you intended to display.

The FormData API

The FormData API provides a convenient way to capture all form values at once without manually reading each input. It automatically handles different input types including file uploads, checkboxes, and select elements. FormData objects can be sent directly with the Fetch API or XMLHttpRequest.

Example: Using FormData to Collect Form Values

<form id="profile-form">
    <input type="text" name="firstName" value="John">
    <input type="text" name="lastName" value="Doe">
    <input type="email" name="email" value="john@example.com">
    <select name="role">
        <option value="developer" selected>Developer</option>
        <option value="designer">Designer</option>
    </select>
    <input type="file" name="avatar">
    <button type="submit">Save Profile</button>
</form>

<script>
document.getElementById('profile-form').addEventListener('submit', function(e) {
    e.preventDefault();

    // Create FormData from the form element
    const formData = new FormData(this);

    // Read individual values
    console.log('First Name:', formData.get('firstName'));
    console.log('Email:', formData.get('email'));

    // Check if a field exists
    console.log('Has role:', formData.has('role'));

    // Iterate over all entries
    for (const [key, value] of formData.entries()) {
        console.log(key + ': ' + value);
    }

    // Convert to a plain object (excluding files)
    const dataObject = Object.fromEntries(formData.entries());
    console.log('Form data as object:', dataObject);

    // Append additional data not in the form
    formData.append('submittedAt', new Date().toISOString());

    // Send with fetch -- FormData sets Content-Type automatically
    fetch('/api/profile', {
        method: 'POST',
        body: formData  // Do NOT set Content-Type header manually
    });
});
</script>
Note: When sending FormData with fetch(), do not manually set the Content-Type header. The browser will automatically set it to multipart/form-data with the correct boundary string needed for file uploads. Setting it manually will break the request.

Input Types and Their Values

Different HTML input types return their values in different formats. Understanding these differences is essential for proper form handling and validation.

Example: Reading Values from Different Input Types

<form id="demo-form">
    <input type="text" name="username" value="johndoe">
    <input type="number" name="age" value="25">
    <input type="range" name="volume" min="0" max="100" value="75">
    <input type="date" name="birthday" value="1998-05-15">
    <input type="time" name="alarm" value="07:30">
    <input type="color" name="favorite" value="#3498db">
    <input type="url" name="website" value="https://example.com">
    <input type="hidden" name="userId" value="12345">
    <textarea name="bio">Hello world</textarea>
</form>

<script>
const form = document.getElementById('demo-form');

// All .value properties return strings, even for number and range
const age = form.elements['age'].value;       // "25" (string!)
const volume = form.elements['volume'].value;  // "75" (string!)

// Convert to numbers when needed
const ageNumber = parseInt(form.elements['age'].value, 10);     // 25
const volumeNumber = parseFloat(form.elements['volume'].value); // 75

// Alternative: use valueAsNumber for number, range, and date inputs
const ageFromProp = form.elements['age'].valueAsNumber;     // 25
const dateFromProp = form.elements['birthday'].valueAsDate;  // Date object

// Textarea value works the same as text input
const bio = form.elements['bio'].value;  // "Hello world"

// Hidden inputs are accessible just like visible ones
const userId = form.elements['userId'].value;  // "12345"

console.log(typeof age);        // "string"
console.log(typeof ageNumber);  // "number"
</script>

Checkboxes and Radio Buttons

Checkboxes and radio buttons require special handling because their value does not change -- only their checked state does. You must use the checked property (a boolean) rather than the value property to determine the user's selection.

Example: Working with Checkboxes and Radio Buttons

<form id="preferences-form">
    <fieldset>
        <legend>Notifications</legend>
        <label>
            <input type="checkbox" name="notifications" value="email" checked> Email
        </label>
        <label>
            <input type="checkbox" name="notifications" value="sms"> SMS
        </label>
        <label>
            <input type="checkbox" name="notifications" value="push" checked> Push
        </label>
    </fieldset>

    <fieldset>
        <legend>Theme</legend>
        <label>
            <input type="radio" name="theme" value="light" checked> Light
        </label>
        <label>
            <input type="radio" name="theme" value="dark"> Dark
        </label>
        <label>
            <input type="radio" name="theme" value="auto"> Auto
        </label>
    </fieldset>

    <label>
        <input type="checkbox" name="terms" value="accepted"> I agree to the terms
    </label>

    <button type="submit">Save</button>
</form>

<script>
document.getElementById('preferences-form').addEventListener('submit', function(e) {
    e.preventDefault();

    // Single checkbox: check the boolean .checked property
    const termsAccepted = form.elements['terms'].checked;  // true or false
    console.log('Terms accepted:', termsAccepted);

    // Multiple checkboxes with the same name: get all checked values
    const checkboxes = form.querySelectorAll('input[name="notifications"]:checked');
    const selectedNotifications = Array.from(checkboxes).map(function(cb) {
        return cb.value;
    });
    console.log('Notifications:', selectedNotifications); // ["email", "push"]

    // Radio buttons: find the selected one
    const selectedTheme = form.elements['theme'].value; // value of checked radio
    console.log('Theme:', selectedTheme); // "light"

    // Alternative for radio: use querySelector
    const checkedRadio = form.querySelector('input[name="theme"]:checked');
    console.log('Theme (alt):', checkedRadio ? checkedRadio.value : 'none');
});

// Listen for changes on individual checkboxes
const termsCheckbox = document.querySelector('input[name="terms"]');
termsCheckbox.addEventListener('change', function() {
    console.log('Terms checkbox changed to:', this.checked);
});
</script>

Select Elements

Select elements can be single-selection or multiple-selection. Single selects return a string value, while multiple selects require iterating through the selected options.

Example: Working with Select Elements

<form id="order-form">
    <label for="country">Country:</label>
    <select id="country" name="country">
        <option value="">-- Select --</option>
        <option value="us">United States</option>
        <option value="uk" selected>United Kingdom</option>
        <option value="ca">Canada</option>
    </select>

    <label for="languages">Languages:</label>
    <select id="languages" name="languages" multiple size="4">
        <option value="js" selected>JavaScript</option>
        <option value="py">Python</option>
        <option value="rb" selected>Ruby</option>
        <option value="go">Go</option>
    </select>
</form>

<script>
const form = document.getElementById('order-form');
const countrySelect = form.elements['country'];
const languagesSelect = form.elements['languages'];

// Single select: .value returns the selected option's value
console.log('Country:', countrySelect.value); // "uk"

// Get the display text of the selected option
const selectedIndex = countrySelect.selectedIndex;
const selectedText = countrySelect.options[selectedIndex].text;
console.log('Country name:', selectedText); // "United Kingdom"

// Multiple select: iterate through options to find selected ones
const selectedLanguages = Array.from(languagesSelect.selectedOptions).map(function(opt) {
    return opt.value;
});
console.log('Languages:', selectedLanguages); // ["js", "rb"]

// Listen for changes
countrySelect.addEventListener('change', function() {
    console.log('Country changed to:', this.value);
    console.log('Display text:', this.options[this.selectedIndex].text);
});

// Dynamically add options
const newOption = document.createElement('option');
newOption.value = 'de';
newOption.textContent = 'Germany';
countrySelect.appendChild(newOption);

// Programmatically set value
countrySelect.value = 'ca'; // Selects Canada
</script>

Real-Time Validation with input and change Events

Users expect immediate feedback as they fill out forms. The input event fires every time the value changes (on each keystroke for text inputs), while the change event fires when the user finishes editing and moves away from the field. Using these events together gives you fine-grained control over when to validate and display feedback.

Example: Real-Time Validation Feedback

<form id="realtime-form">
    <div class="field-group">
        <label for="rt-username">Username:</label>
        <input type="text" id="rt-username" name="username"
               minlength="3" maxlength="20" required>
        <span class="error-message" id="username-error"></span>
        <span class="char-count" id="username-count">0/20</span>
    </div>

    <div class="field-group">
        <label for="rt-email">Email:</label>
        <input type="email" id="rt-email" name="email" required>
        <span class="error-message" id="email-error"></span>
    </div>

    <div class="field-group">
        <label for="rt-password">Password:</label>
        <input type="password" id="rt-password" name="password"
               minlength="8" required>
        <div class="password-strength" id="password-strength"></div>
        <span class="error-message" id="password-error"></span>
    </div>
</form>

<script>
const usernameInput = document.getElementById('rt-username');
const emailInput = document.getElementById('rt-email');
const passwordInput = document.getElementById('rt-password');

// input event: fires on EVERY keystroke (real-time)
usernameInput.addEventListener('input', function() {
    const count = this.value.length;
    document.getElementById('username-count').textContent = count + '/20';

    // Visual feedback based on length
    if (count > 0 && count < 3) {
        showError('username-error', 'Username must be at least 3 characters');
        this.classList.add('invalid');
        this.classList.remove('valid');
    } else if (count >= 3) {
        clearError('username-error');
        this.classList.add('valid');
        this.classList.remove('invalid');
    } else {
        clearError('username-error');
        this.classList.remove('valid', 'invalid');
    }
});

// change event: fires when user leaves the field (on blur)
emailInput.addEventListener('change', function() {
    if (this.value && !isValidEmail(this.value)) {
        showError('email-error', 'Please enter a valid email address');
        this.classList.add('invalid');
    } else if (this.value) {
        clearError('email-error');
        this.classList.add('valid');
        this.classList.remove('invalid');
    }
});

// Combine input event for password strength meter
passwordInput.addEventListener('input', function() {
    const strength = calculatePasswordStrength(this.value);
    const strengthEl = document.getElementById('password-strength');
    strengthEl.textContent = 'Strength: ' + strength.label;
    strengthEl.className = 'password-strength ' + strength.level;
});

function calculatePasswordStrength(password) {
    let score = 0;
    if (password.length >= 8) score++;
    if (password.length >= 12) score++;
    if (/[A-Z]/.test(password)) score++;
    if (/[0-9]/.test(password)) score++;
    if (/[^A-Za-z0-9]/.test(password)) score++;

    if (score <= 1) return { label: 'Weak', level: 'weak' };
    if (score <= 3) return { label: 'Medium', level: 'medium' };
    return { label: 'Strong', level: 'strong' };
}

function isValidEmail(email) {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

function showError(elementId, message) {
    document.getElementById(elementId).textContent = message;
}

function clearError(elementId) {
    document.getElementById(elementId).textContent = '';
}
</script>
Pro Tip: Use the input event for character counters, search-as-you-type, and password strength indicators where immediate feedback improves the experience. Use the change event for validation that should not interrupt the user while they are still typing, such as email format checks or API-based uniqueness checks.

The Constraint Validation API

Modern browsers provide a built-in Constraint Validation API that works with HTML5 validation attributes like required, minlength, pattern, and type. This API gives you programmatic access to the validation state of each input and the ability to customize error messages. The key properties and methods are validity, checkValidity(), setCustomValidity(), and reportValidity().

The validity Property

Every form input has a validity property that returns a ValidityState object. This object contains boolean flags for every type of validation error. Checking these flags tells you exactly what is wrong with the input.

Example: Inspecting the ValidityState Object

<form id="validity-demo">
    <input type="email" id="demo-email" required minlength="5" maxlength="50">
    <input type="number" id="demo-age" min="18" max="120" step="1">
    <input type="text" id="demo-zip" pattern="[0-9]{5}" title="5-digit ZIP code">
    <button type="submit">Check</button>
</form>

<script>
const emailField = document.getElementById('demo-email');

// The validity property contains these boolean flags:
// valueMissing    -- fails 'required' constraint
// typeMismatch    -- fails 'type' constraint (e.g., not a valid email)
// patternMismatch -- fails 'pattern' constraint
// tooLong         -- exceeds 'maxlength'
// tooShort        -- below 'minlength'
// rangeOverflow   -- exceeds 'max' for number/date
// rangeUnderflow  -- below 'min' for number/date
// stepMismatch    -- does not match 'step' value
// badInput        -- browser cannot convert the input
// customError     -- setCustomValidity() has been called
// valid           -- true if ALL constraints pass

emailField.addEventListener('input', function() {
    const v = this.validity;

    console.log('Valid:', v.valid);
    console.log('Value Missing:', v.valueMissing);
    console.log('Type Mismatch:', v.typeMismatch);
    console.log('Too Short:', v.tooShort);

    // Use validationMessage for the browser's default error message
    if (!v.valid) {
        console.log('Error:', this.validationMessage);
    }
});
</script>

checkValidity() and reportValidity()

The checkValidity() method returns true if the element satisfies all constraints, or false otherwise. It also fires an invalid event on elements that fail validation. The reportValidity() method does the same but additionally displays the browser's built-in error tooltip to the user.

Example: Using checkValidity and reportValidity

<form id="check-form">
    <input type="text" id="check-name" required minlength="2">
    <input type="email" id="check-email" required>
    <button type="button" id="validate-btn">Validate</button>
    <button type="submit">Submit</button>
</form>

<script>
const checkForm = document.getElementById('check-form');

// checkValidity on individual field -- returns boolean silently
document.getElementById('validate-btn').addEventListener('click', function() {
    const nameField = document.getElementById('check-name');
    const emailField = document.getElementById('check-email');

    // Check individual fields
    const nameValid = nameField.checkValidity();
    const emailValid = emailField.checkValidity();
    console.log('Name valid:', nameValid, 'Email valid:', emailValid);

    // Check entire form at once
    const formValid = checkForm.checkValidity();
    console.log('Form valid:', formValid);

    // reportValidity shows the browser tooltip on first invalid field
    if (!formValid) {
        checkForm.reportValidity(); // Shows tooltip on first invalid input
    }
});

// Listen for the invalid event (fired by checkValidity on failing elements)
document.getElementById('check-name').addEventListener('invalid', function(e) {
    console.log('Name field is invalid:', this.validationMessage);
    // Prevent the default browser tooltip if you want custom UI
    // e.preventDefault();
});

checkForm.addEventListener('submit', function(e) {
    e.preventDefault();
    if (this.checkValidity()) {
        console.log('Form is valid -- submitting');
    } else {
        console.log('Form has errors');
        this.reportValidity();
    }
});
</script>

setCustomValidity()

The setCustomValidity() method allows you to define custom error messages that replace the browser's default messages. When you set a non-empty string, the field is marked as invalid. You must set it back to an empty string to clear the custom error and allow the field to be valid again.

Example: Custom Validation Messages

<form id="custom-form">
    <label for="custom-pw">Password:</label>
    <input type="password" id="custom-pw" name="password" required minlength="8">

    <label for="custom-confirm">Confirm Password:</label>
    <input type="password" id="custom-confirm" name="confirmPassword" required>

    <button type="submit">Register</button>
</form>

<script>
const password = document.getElementById('custom-pw');
const confirm = document.getElementById('custom-confirm');

// Custom message for the password field
password.addEventListener('input', function() {
    if (this.value.length > 0 && this.value.length < 8) {
        this.setCustomValidity('Password must be at least 8 characters long.');
    } else if (this.value && !/[A-Z]/.test(this.value)) {
        this.setCustomValidity('Password must contain at least one uppercase letter.');
    } else if (this.value && !/[0-9]/.test(this.value)) {
        this.setCustomValidity('Password must contain at least one number.');
    } else {
        this.setCustomValidity(''); // Clear the error -- field is valid
    }
});

// Password confirmation matching
confirm.addEventListener('input', function() {
    if (this.value !== password.value) {
        this.setCustomValidity('Passwords do not match.');
    } else {
        this.setCustomValidity('');
    }
});

// Also re-check confirm when password changes
password.addEventListener('input', function() {
    if (confirm.value && confirm.value !== this.value) {
        confirm.setCustomValidity('Passwords do not match.');
    } else {
        confirm.setCustomValidity('');
    }
});

document.getElementById('custom-form').addEventListener('submit', function(e) {
    e.preventDefault();
    if (this.checkValidity()) {
        console.log('Registration successful');
    } else {
        this.reportValidity();
    }
});
</script>
Critical: You must call setCustomValidity('') with an empty string to clear a custom error. If you forget this step, the field will remain permanently invalid even if the user corrects their input. Always clear the custom validity before re-checking in your event handler.

Regex Validation

Regular expressions provide powerful pattern matching for form validation. While HTML5 has a pattern attribute that accepts regex, JavaScript gives you more flexibility for complex validation rules and custom error messages.

Example: Regex Patterns for Common Validations

<form id="regex-form">
    <input type="text" id="phone" name="phone" placeholder="(555) 123-4567">
    <input type="text" id="zipcode" name="zipcode" placeholder="12345 or 12345-6789">
    <input type="text" id="website" name="website" placeholder="https://example.com">
    <input type="text" id="slug" name="slug" placeholder="my-page-slug">
    <button type="submit">Validate</button>
</form>

<script>
const validationRules = {
    phone: {
        // Matches: (555) 123-4567, 555-123-4567, 5551234567
        pattern: /^(\(\d{3}\)\s?|\d{3}[-.]?)\d{3}[-.]?\d{4}$/,
        message: 'Enter a valid US phone number'
    },
    zipcode: {
        // Matches: 12345 or 12345-6789
        pattern: /^\d{5}(-\d{4})?$/,
        message: 'Enter a valid ZIP code (12345 or 12345-6789)'
    },
    website: {
        // Matches: http:// or https:// followed by domain
        pattern: /^https?:\/\/[a-zA-Z0-9][\w.-]*\.[a-zA-Z]{2,}(\/\S*)?$/,
        message: 'Enter a valid URL starting with http:// or https://'
    },
    slug: {
        // Matches: lowercase letters, numbers, and hyphens
        pattern: /^[a-z0-9]+(-[a-z0-9]+)*$/,
        message: 'Only lowercase letters, numbers, and hyphens allowed'
    }
};

document.getElementById('regex-form').addEventListener('submit', function(e) {
    e.preventDefault();
    let isValid = true;

    Object.keys(validationRules).forEach(function(fieldName) {
        const input = document.getElementById(fieldName);
        const rule = validationRules[fieldName];

        if (input.value && !rule.pattern.test(input.value)) {
            input.setCustomValidity(rule.message);
            isValid = false;
        } else {
            input.setCustomValidity('');
        }
    });

    if (!isValid) {
        this.reportValidity();
    } else {
        console.log('All fields valid');
    }
});

// Clear custom validity on input so user can fix errors
document.querySelectorAll('#regex-form input').forEach(function(input) {
    input.addEventListener('input', function() {
        this.setCustomValidity('');
    });
});
</script>

Building a Complete Registration Form with Validation

Now let us bring everything together into a complete, production-quality registration form. This example combines real-time validation, the Constraint Validation API, regex patterns, custom error messages, and accessible error display. It demonstrates the full pattern you would use in a real application.

Example: Complete Registration Form

<style>
.form-group { margin-bottom: 16px; }
.form-group label { display: block; margin-bottom: 4px; font-weight: bold; }
.form-group input, .form-group select { width: 100%; padding: 8px; border: 2px solid #ccc; }
.form-group input.valid { border-color: #27ae60; }
.form-group input.invalid { border-color: #e74c3c; }
.error-text { color: #e74c3c; font-size: 14px; margin-top: 4px; display: block; }
.error-text:empty { display: none; }
.form-success { background: #d4edda; color: #155724; padding: 16px; display: none; }
</style>

<div id="success-message" class="form-success" role="alert">
    Registration successful! Welcome aboard.
</div>

<form id="registration-form" novalidate>
    <div class="form-group">
        <label for="reg-name">Full Name *</label>
        <input type="text" id="reg-name" name="fullName"
               required minlength="2" maxlength="100"
               autocomplete="name"
               aria-describedby="name-error">
        <span class="error-text" id="name-error" role="alert"></span>
    </div>

    <div class="form-group">
        <label for="reg-email">Email Address *</label>
        <input type="email" id="reg-email" name="email"
               required autocomplete="email"
               aria-describedby="email-error">
        <span class="error-text" id="email-error" role="alert"></span>
    </div>

    <div class="form-group">
        <label for="reg-phone">Phone Number</label>
        <input type="tel" id="reg-phone" name="phone"
               autocomplete="tel"
               aria-describedby="phone-error">
        <span class="error-text" id="phone-error" role="alert"></span>
    </div>

    <div class="form-group">
        <label for="reg-password">Password *</label>
        <input type="password" id="reg-password" name="password"
               required minlength="8" autocomplete="new-password"
               aria-describedby="password-error password-hint">
        <small id="password-hint">Minimum 8 characters with uppercase, number, and symbol.</small>
        <span class="error-text" id="password-error" role="alert"></span>
    </div>

    <div class="form-group">
        <label for="reg-confirm">Confirm Password *</label>
        <input type="password" id="reg-confirm" name="confirmPassword"
               required autocomplete="new-password"
               aria-describedby="confirm-error">
        <span class="error-text" id="confirm-error" role="alert"></span>
    </div>

    <div class="form-group">
        <label for="reg-country">Country *</label>
        <select id="reg-country" name="country" required
                aria-describedby="country-error">
            <option value="">-- Select your country --</option>
            <option value="us">United States</option>
            <option value="uk">United Kingdom</option>
            <option value="ca">Canada</option>
            <option value="au">Australia</option>
        </select>
        <span class="error-text" id="country-error" role="alert"></span>
    </div>

    <div class="form-group">
        <label>
            <input type="checkbox" id="reg-terms" name="terms" required
                   aria-describedby="terms-error">
            I agree to the Terms of Service *
        </label>
        <span class="error-text" id="terms-error" role="alert"></span>
    </div>

    <button type="submit" id="submit-btn">Create Account</button>
</form>

<script>
const regForm = document.getElementById('registration-form');

// Validation rules for each field
const validators = {
    fullName: function(value) {
        if (!value.trim()) return 'Full name is required.';
        if (value.trim().length < 2) return 'Name must be at least 2 characters.';
        if (!/^[a-zA-Z\s'-]+$/.test(value)) return 'Name can only contain letters, spaces, hyphens, and apostrophes.';
        return '';
    },
    email: function(value) {
        if (!value.trim()) return 'Email address is required.';
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return 'Please enter a valid email address.';
        return '';
    },
    phone: function(value) {
        if (!value) return ''; // Optional field
        if (!/^[\d\s()+-]{7,20}$/.test(value)) return 'Please enter a valid phone number.';
        return '';
    },
    password: function(value) {
        if (!value) return 'Password is required.';
        if (value.length < 8) return 'Password must be at least 8 characters.';
        if (!/[A-Z]/.test(value)) return 'Password must contain at least one uppercase letter.';
        if (!/[0-9]/.test(value)) return 'Password must contain at least one number.';
        if (!/[^A-Za-z0-9]/.test(value)) return 'Password must contain at least one special character.';
        return '';
    },
    confirmPassword: function(value) {
        if (!value) return 'Please confirm your password.';
        const pw = document.getElementById('reg-password').value;
        if (value !== pw) return 'Passwords do not match.';
        return '';
    },
    country: function(value) {
        if (!value) return 'Please select your country.';
        return '';
    },
    terms: function(value, element) {
        if (!element.checked) return 'You must agree to the Terms of Service.';
        return '';
    }
};

// Map field names to error element IDs
const errorMap = {
    fullName: 'name-error',
    email: 'email-error',
    phone: 'phone-error',
    password: 'password-error',
    confirmPassword: 'confirm-error',
    country: 'country-error',
    terms: 'terms-error'
};

// Map field names to input IDs
const inputMap = {
    fullName: 'reg-name',
    email: 'reg-email',
    phone: 'reg-phone',
    password: 'reg-password',
    confirmPassword: 'reg-confirm',
    country: 'reg-country',
    terms: 'reg-terms'
};

function validateField(fieldName) {
    const input = document.getElementById(inputMap[fieldName]);
    const errorEl = document.getElementById(errorMap[fieldName]);
    const errorMsg = validators[fieldName](input.value, input);

    errorEl.textContent = errorMsg;

    if (errorMsg) {
        input.classList.add('invalid');
        input.classList.remove('valid');
        input.setAttribute('aria-invalid', 'true');
    } else if (input.value || input.checked) {
        input.classList.add('valid');
        input.classList.remove('invalid');
        input.setAttribute('aria-invalid', 'false');
    } else {
        input.classList.remove('valid', 'invalid');
        input.removeAttribute('aria-invalid');
    }

    return errorMsg === '';
}

// Attach real-time validation to each field
Object.keys(inputMap).forEach(function(fieldName) {
    const input = document.getElementById(inputMap[fieldName]);
    const eventType = (input.type === 'checkbox' || input.tagName === 'SELECT')
                      ? 'change' : 'input';

    input.addEventListener(eventType, function() {
        validateField(fieldName);

        // Re-validate confirm when password changes
        if (fieldName === 'password') {
            const confirmVal = document.getElementById('reg-confirm').value;
            if (confirmVal) validateField('confirmPassword');
        }
    });

    // Also validate on blur for fields the user skips
    if (eventType === 'input') {
        input.addEventListener('blur', function() {
            if (this.value) validateField(fieldName);
        });
    }
});

// Handle form submission
regForm.addEventListener('submit', function(e) {
    e.preventDefault();

    // Validate all fields
    let formIsValid = true;
    let firstInvalidField = null;

    Object.keys(validators).forEach(function(fieldName) {
        const isValid = validateField(fieldName);
        if (!isValid && !firstInvalidField) {
            firstInvalidField = document.getElementById(inputMap[fieldName]);
        }
        if (!isValid) formIsValid = false;
    });

    if (!formIsValid) {
        // Focus the first invalid field for accessibility
        if (firstInvalidField) firstInvalidField.focus();
        return;
    }

    // Collect all form data
    const formData = new FormData(this);
    const data = Object.fromEntries(formData.entries());
    delete data.confirmPassword; // Do not send confirmation field
    delete data.terms;           // Do not send checkbox value

    console.log('Submitting:', data);

    // Show success message
    regForm.style.display = 'none';
    document.getElementById('success-message').style.display = 'block';
});
</script>

Accessible Error Messages

Form validation must be accessible to all users, including those using screen readers. There are several key techniques that make your error messages accessible and help users understand and fix validation issues efficiently.

  • Use aria-describedby -- Link each input to its error message element using aria-describedby. When the input receives focus, the screen reader will announce the error message along with the input label.
  • Use aria-invalid -- Set aria-invalid="true" on inputs that fail validation. This tells screen readers the field contains an error. Remove it or set it to "false" when the error is corrected.
  • Use role="alert" -- Adding role="alert" to error message elements causes screen readers to announce the message immediately when it changes, without the user needing to navigate to it.
  • Focus management -- When form submission fails, move focus to the first invalid field. This orients the user and lets them start correcting errors immediately.
  • Visible error messages -- Never rely solely on color to indicate errors. Always include a text message that describes the problem and how to fix it.

Example: Accessible Error Message Pattern

<!-- The complete accessible pattern for a form field -->
<div class="form-group">
    <label for="acc-email">
        Email Address
        <span aria-hidden="true">*</span>
        <span class="sr-only">(required)</span>
    </label>

    <input type="email" id="acc-email" name="email"
           required
           aria-required="true"
           aria-invalid="false"
           aria-describedby="email-help email-err">

    <small id="email-help">We will never share your email.</small>

    <!-- role="alert" makes screen readers announce changes immediately -->
    <span id="email-err" class="error-text" role="alert"></span>
</div>

<script>
function setFieldError(inputId, errorId, message) {
    const input = document.getElementById(inputId);
    const errorEl = document.getElementById(errorId);

    if (message) {
        errorEl.textContent = message;
        input.setAttribute('aria-invalid', 'true');
        input.classList.add('invalid');
    } else {
        errorEl.textContent = '';
        input.setAttribute('aria-invalid', 'false');
        input.classList.remove('invalid');
    }
}

// When setting error, screen reader announces it immediately
// because the error element has role="alert"
setFieldError('acc-email', 'email-err', 'Please enter a valid email address.');

// When clearing error
setFieldError('acc-email', 'email-err', '');
</script>
Note: The novalidate attribute on the <form> element disables the browser's built-in validation tooltips, giving you full control over the error display. However, the Constraint Validation API (validity, checkValidity()) still works -- only the automatic tooltip display is suppressed. Use novalidate when you want custom-styled error messages instead of browser defaults.

Practice Exercise

Build a multi-step registration form with three steps. Step 1 collects personal info (full name, email, phone). Step 2 collects account info (username with real-time uniqueness check against a local array, password with strength meter, and confirm password). Step 3 collects preferences (country select, notification checkboxes for email, SMS, and push, and a terms agreement checkbox). Each step should be a <fieldset> that shows and hides as the user progresses. Include "Next" and "Back" buttons to navigate between steps. Validate each step before allowing the user to proceed to the next one. Use the Constraint Validation API with setCustomValidity() for password matching and custom rules. Use regex to validate the phone number format and username format (lowercase letters and numbers only). Display all errors inline with aria-describedby, aria-invalid, and role="alert" for screen reader accessibility. Add a character counter for the username field that updates on every keystroke. On the final step, show a summary of all entered data before the user confirms submission. Use the FormData API to collect all values and log them to the console on submission.