--- name: form-vanilla description: Framework-free form validation using HTML5 Constraint Validation API enhanced with Zod for complex rules. Use when building forms without React/Vue or for progressive enhancement. --- # Form Vanilla Framework-free form patterns using native browser APIs enhanced with Zod. ## Quick Start ```html
``` ## HTML5 Constraint Validation API ### Built-in Attributes ```html ``` ### Validity State Properties ```javascript const input = document.querySelector('input'); // Check individual constraints input.validity.valueMissing; // required but empty input.validity.typeMismatch; // email/url format wrong input.validity.patternMismatch; // regex failed input.validity.tooShort; // < minlength input.validity.tooLong; // > maxlength input.validity.rangeUnderflow; // < min input.validity.rangeOverflow; // > max input.validity.stepMismatch; // not divisible by step input.validity.badInput; // browser can't parse input.validity.customError; // setCustomValidity called // Check overall validity input.validity.valid; // all constraints pass input.checkValidity(); // returns boolean input.reportValidity(); // shows browser UI ``` ### Custom Error Messages ```javascript const input = document.querySelector('#email'); // Set custom validation message input.addEventListener('invalid', (e) => { if (input.validity.valueMissing) { input.setCustomValidity('Please enter your email address'); } else if (input.validity.typeMismatch) { input.setCustomValidity('Please enter a valid email (e.g., name@example.com)'); } }); // Clear custom message on input input.addEventListener('input', () => { input.setCustomValidity(''); }); ``` ## Zod Integration ### Vanilla Validator Class ```typescript // vanilla-validator.ts import { z } from 'zod'; export interface ValidationResult { valid: boolean; data?: T; errors: Record; } export interface ValidatorOptions { /** When to validate */ validateOn: 'blur' | 'input' | 'submit'; /** When to re-validate after error */ revalidateOn: 'input' | 'blur'; /** Debounce delay for input validation (ms) */ debounceMs?: number; } const defaultOptions: ValidatorOptions = { validateOn: 'blur', revalidateOn: 'input', debounceMs: 300 }; export function createFormValidator( form: HTMLFormElement, schema: T, options: Partial = {} ): FormValidator> { const opts = { ...defaultOptions, ...options }; const fieldErrors = new Map(); const touchedFields = new Set(); let debounceTimers = new Map>(); // Get all form fields const fields = Array.from(form.elements).filter( (el): el is HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement => el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement ); // Attach event listeners fields.forEach(field => { if (!field.name) return; // Blur handler (punish late) field.addEventListener('blur', () => { touchedFields.add(field.name); if (opts.validateOn === 'blur') { validateField(field.name); } }); // Input handler (real-time correction) field.addEventListener('input', () => { // Clear existing timer const timer = debounceTimers.get(field.name); if (timer) clearTimeout(timer); // Only validate if already has error (correction mode) if (fieldErrors.has(field.name) && opts.revalidateOn === 'input') { debounceTimers.set( field.name, setTimeout(() => validateField(field.name), opts.debounceMs) ); } }); }); function getFormData(): Record { const data: Record = {}; const formData = new FormData(form); formData.forEach((value, key) => { // Handle checkboxes const field = form.elements.namedItem(key); if (field instanceof HTMLInputElement && field.type === 'checkbox') { data[key] = field.checked; } else if (field instanceof HTMLInputElement && field.type === 'number') { data[key] = value === '' ? undefined : Number(value); } else { data[key] = value; } }); return data; } function validateField(name: string): string | undefined { const data = getFormData(); const result = schema.safeParse(data); if (result.success) { clearFieldError(name); return undefined; } const fieldError = result.error.errors.find(e => e.path[0] === name); if (fieldError) { setFieldError(name, fieldError.message); return fieldError.message; } else { clearFieldError(name); return undefined; } } function setFieldError(name: string, message: string): void { fieldErrors.set(name, message); const field = form.elements.namedItem(name) as HTMLInputElement | null; if (!field) return; // Set ARIA attributes field.setAttribute('aria-invalid', 'true'); // Find error element const fieldWrapper = field.closest('.form-field'); const errorEl = fieldWrapper?.querySelector('.error'); if (errorEl) { errorEl.textContent = message; field.setAttribute('aria-describedby', errorEl.id || ''); } // Add error class fieldWrapper?.classList.add('has-error'); fieldWrapper?.classList.remove('is-valid'); // Set custom validity for native UI field.setCustomValidity(message); } function clearFieldError(name: string): void { fieldErrors.delete(name); const field = form.elements.namedItem(name) as HTMLInputElement | null; if (!field) return; // Clear ARIA field.setAttribute('aria-invalid', 'false'); field.removeAttribute('aria-describedby'); // Clear error element const fieldWrapper = field.closest('.form-field'); const errorEl = fieldWrapper?.querySelector('.error'); if (errorEl) { errorEl.textContent = ''; } // Update classes fieldWrapper?.classList.remove('has-error'); if (touchedFields.has(name)) { fieldWrapper?.classList.add('is-valid'); } // Clear custom validity field.setCustomValidity(''); } function clearAllErrors(): void { fieldErrors.forEach((_, name) => clearFieldError(name)); } async function validate(): Promise>> { const data = getFormData(); const result = schema.safeParse(data); if (result.success) { clearAllErrors(); return { valid: true, data: result.data, errors: {} }; } // Set errors for all fields const errors: Record = {}; result.error.errors.forEach(err => { const name = String(err.path[0]); errors[name] = err.message; setFieldError(name, err.message); }); // Focus first error const firstErrorName = Object.keys(errors)[0]; if (firstErrorName) { const field = form.elements.namedItem(firstErrorName) as HTMLElement; field?.focus(); } return { valid: false, errors }; } function reset(): void { form.reset(); clearAllErrors(); touchedFields.clear(); debounceTimers.forEach(timer => clearTimeout(timer)); debounceTimers.clear(); } return { validate, validateField, setFieldError, clearFieldError, clearAllErrors, reset, getFormData }; } export interface FormValidator { validate(): Promise>; validateField(name: string): string | undefined; setFieldError(name: string, message: string): void; clearFieldError(name: string): void; clearAllErrors(): void; reset(): void; getFormData(): Record; } ``` ### Usage Example ```html
``` ## Progressive Enhancement ### Base HTML (Works Without JS) ```html
``` ### Enhanced With JS ```javascript // Only runs if JS is available const form = document.querySelector('form'); if (form) { // Disable native validation UI form.setAttribute('novalidate', ''); // Add ARIA live regions for errors form.querySelectorAll('.form-field').forEach(field => { const input = field.querySelector('input'); if (input && input.name) { const errorEl = document.createElement('span'); errorEl.className = 'error'; errorEl.id = `${input.name}-error`; errorEl.setAttribute('aria-live', 'polite'); field.appendChild(errorEl); } }); // Attach validator const validator = createFormValidator(form, schema); form.addEventListener('submit', async (e) => { e.preventDefault(); const result = await validator.validate(); if (result.valid) { form.submit(); // Native submit } }); } ``` ## Common Patterns ### Password Visibility Toggle ```html
``` ### Character Counter ```html
0/500
``` ### Form Submission with Fetch ```javascript const form = document.getElementById('my-form'); const submitBtn = form.querySelector('button[type="submit"]'); form.addEventListener('submit', async (e) => { e.preventDefault(); const result = await validator.validate(); if (!result.valid) return; // Disable button submitBtn.disabled = true; submitBtn.textContent = 'Sending...'; try { const response = await fetch(form.action, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': document.querySelector('[name="_csrf"]').value }, body: JSON.stringify(result.data) }); if (!response.ok) { const error = await response.json(); // Handle server errors if (error.field) { validator.setFieldError(error.field, error.message); } else { alert(error.message); } return; } // Success alert('Form submitted!'); validator.reset(); } catch (err) { alert('Network error. Please try again.'); } finally { submitBtn.disabled = false; submitBtn.textContent = 'Submit'; } }); ``` ## File Structure ``` form-vanilla/ ├── SKILL.md ├── references/ │ └── constraint-validation.md # HTML5 Constraint API reference └── scripts/ ├── vanilla-validator.ts # Main validator class ├── vanilla-validator.js # Compiled JS ├── progressive-enhance.js # Progressive enhancement utils └── examples/ ├── login-form.html ├── contact-form.html └── checkout-form.html ``` ## Reference - `references/constraint-validation.md` — HTML5 Constraint Validation API reference