Skip to main content

Form Validation

Core Forms provides real-time inline validation with accessible error messaging. Validation runs on the client side for immediate feedback and on the server side for security. Multi-step forms validate per step before allowing the user to advance.

Inline Validation on Blur

Fields are validated when the user leaves them (blur event). This gives immediate feedback without interrupting typing:

// Core Forms validates on blur for each field
field.addEventListener('blur', () => validateField(field));

On form submission, all fields are validated together. In multi-step forms, validation runs on the current step's fields when the user clicks Next.

Validation Rules

Required Fields

Fields with the required attribute must have a non-empty value. Core Forms handles different input types correctly:

<!-- Text input: value must not be empty or whitespace-only -->
<input type="text" name="name" required>

<!-- Select: must have a value (not the empty placeholder) -->
<select name="country" required>
  <option value="">Select a country</option>
  <option value="US">United States</option>
</select>

<!-- Checkboxes: at least one must be checked -->
<input type="checkbox" name="terms" value="yes" required>

<!-- Radio buttons: one must be selected -->
<input type="radio" name="plan" value="free" required>
<input type="radio" name="plan" value="pro">

Checkbox and radio validation: Core Forms checks the .checked property rather than .value. For checkbox groups sharing the same name, at least one must be checked.

The "0" Value Fix

A common bug in form validation is treating the string "0" as empty. Core Forms explicitly handles this -- a required field with a value of "0" passes validation:

// Core Forms required check (simplified)
function isFieldEmpty(field) {
    const value = field.value.trim();
    // "0" is a valid value for required fields
    if (value === '0') return false;
    return value === '';
}

This matters for fields like quantity selectors, rating scales, or any numeric input where zero is a legitimate answer.

Email Format Validation

Fields with type="email" are validated against a standard email pattern:

<input type="email" name="email" required>

The validation checks for: - At least one character before @ - A domain with at least one dot - At least two characters in the TLD

// Email pattern used by Core Forms
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;

HTML5 Constraint Validation

Core Forms respects all standard HTML5 validation attributes:

Attribute Applies To Description
min number, date, range Minimum allowed value
max number, date, range Maximum allowed value
minlength text, textarea, password Minimum character count
maxlength text, textarea, password Maximum character count
pattern text, email, tel, url Regex pattern the value must match
step number, range Valid value granularity
<!-- Minimum 8 characters, maximum 100 -->
<input type="password" name="password" required minlength="8" maxlength="100">

<!-- Number between 1 and 10 -->
<input type="number" name="rating" min="1" max="10" required>

<!-- US phone number pattern -->
<input type="tel" name="phone" pattern="\d{3}-\d{3}-\d{4}"
       title="Format: 123-456-7890">

<!-- Date within range -->
<input type="date" name="event_date" min="2026-01-01" max="2026-12-31">

The title attribute provides a custom hint for pattern validation failures.

Per-Step Validation in Multi-Step Forms

In multi-step forms, clicking "Next" triggers validation only for fields in the current step panel. Fields in other steps are not validated until the user reaches them.

// Simplified per-step validation
function validateCurrentStep(form) {
    const currentStep = form.querySelector('.cf-step-panel-active');
    const fields = currentStep.querySelectorAll('[required], [pattern], [min], [max]');
    let isValid = true;

    fields.forEach(field => {
        if (!validateField(field)) {
            isValid = false;
        }
    });

    if (!isValid) {
        // Focus the first invalid field
        const firstInvalid = currentStep.querySelector('.cf-invalid');
        if (firstInvalid) firstInvalid.focus();
    }

    return isValid;
}

Fields hidden by conditional logic are skipped during validation. See conditional-logic.md for details.

Accessible Error Messaging

Core Forms follows WCAG 2.2 guidelines for form error presentation. Every validation error includes proper ARIA attributes so screen readers announce errors immediately.

Error Markup

When a field fails validation, Core Forms:

  1. Adds aria-invalid="true" to the field.
  2. Adds the cf-invalid CSS class to the field.
  3. Inserts an error message element with role="alert".
  4. Links the error to the field via aria-describedby.
<!-- Before validation -->
<label for="email">Email</label>
<input type="email" name="email" id="email" required>

<!-- After failed validation -->
<label for="email">Email</label>
<input type="email" name="email" id="email" required
       class="cf-invalid"
       aria-invalid="true"
       aria-describedby="email-error">
<div id="email-error" class="cf-field-error" role="alert">
  Please enter a valid email address.
</div>

role="alert" Behavior

The role="alert" attribute causes screen readers to announce the error message as soon as it appears in the DOM, without requiring the user to navigate to it. This provides immediate feedback for assistive technology users.

aria-describedby

The aria-describedby attribute links the error message to its field. When a screen reader user focuses the field, the error is read along with the field label:

"Email, edit text, required. Please enter a valid email address."

Error Message Removal

When the user corrects an invalid field, Core Forms removes the error on the next blur or input event:

  1. Removes aria-invalid attribute.
  2. Removes the cf-invalid class.
  3. Removes the error message element from the DOM.
  4. Removes the aria-describedby reference.

CSS Classes

.cf-invalid

Applied to any field that fails validation. Default styling adds a red border:

.cf-invalid {
    border-color: #dc3545;
    box-shadow: 0 0 0 1px #dc3545;
}

.cf-invalid:focus {
    border-color: #dc3545;
    box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.25);
}

.cf-field-error

The error message element displayed below invalid fields:

.cf-field-error {
    color: #dc3545;
    font-size: 0.875em;
    margin-top: 0.25em;
    display: block;
}

Customizing Error Styles

Override the default styles in your theme:

/* Custom error styling */
.cf-form .cf-invalid {
    border-color: var(--color-error);
    background-color: var(--color-error-bg);
}

.cf-form .cf-field-error {
    color: var(--color-error);
    font-size: 0.8rem;
    font-weight: 500;
}

Default Error Messages

Core Forms provides sensible default messages for each validation type:

Rule Default Message
Required "This field is required."
Email "Please enter a valid email address."
Min (number) "Value must be at least {min}."
Max (number) "Value must be no more than {max}."
Minlength "Please enter at least {minlength} characters."
Maxlength "Please enter no more than {maxlength} characters."
Pattern Custom title attribute, or "Please match the requested format."

Custom Error Messages

Override messages per field using data-error-* attributes:

<input type="email" name="email" required
       data-error-required="We need your email to send the report."
       data-error-email="That doesn't look like a valid email.">

<input type="text" name="username" required minlength="3" maxlength="20"
       pattern="^[a-zA-Z0-9_]+$"
       data-error-required="Please choose a username."
       data-error-minlength="Username must be at least 3 characters."
       data-error-pattern="Only letters, numbers, and underscores allowed.">

Server-Side Validation

Client-side validation improves UX but is never trusted for security. Core Forms re-validates all fields on the server during Forms::process():

// Server-side validation runs these checks:
// 1. Honeypot field (must be empty)
// 2. Nonce verification
// 3. Required fields
// 4. Email format
// 5. Custom validation via cf_validate_form filter

// Add custom server-side validation
add_filter('cf_validate_form', function ($errors, $form, $data) {
    if ($form->slug === 'registration') {
        if (strlen($data['password']) < 8) {
            $errors[] = [
                'field'   => 'password',
                'message' => 'Password must be at least 8 characters.',
            ];
        }
        if ($data['password'] !== $data['password_confirm']) {
            $errors[] = [
                'field'   => 'password_confirm',
                'message' => 'Passwords do not match.',
            ];
        }
    }
    return $errors;
}, 10, 3);

When server-side validation fails, the JSON response includes field-level errors that the frontend maps back to individual fields:

{
  "success": false,
  "data": {
    "errors": [
      { "field": "password", "message": "Password must be at least 8 characters." },
      { "field": "password_confirm", "message": "Passwords do not match." }
    ]
  }
}

The frontend then applies cf-invalid and error messages to the corresponding fields, exactly as with client-side validation.