import { uid } from '../shared/dom'; import { FORM_CHANGE_EVENT, FORM_INVALID_EVENT, FORM_RESET_EVENT, FORM_SUBMIT_EVENT, type BuiltInRule, type FieldErrors, type FormChangeDetail, type FormInvalidDetail, type FormOptions, type FormState, type FormSubmitDetail, type ValidationMode, type Validator, } from './form.types'; const SELECTORS = { field: '[data-c42-field]', control: '[data-c42-field-control]', error: '[data-c42-field-error]', } as const; const NATIVE_CONTROL = 'input, select, textarea'; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const URL_RE = /^https?:\/\/[^\s]+\.[^\s]+$/i; const DEFAULT_MESSAGES: Record = { required: 'This field is required.', email: 'Enter a valid email address.', url: 'Enter a valid URL.', number: 'Enter a valid number.', integer: 'Enter a whole number.', minlength: 'Too short.', maxlength: 'Too long.', min: 'Value is too small.', max: 'Value is too large.', pattern: 'Invalid format.', }; type NativeControl = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; interface Field { /** The `[data-c42-field]` wrapper. */ el: HTMLElement; name: string; /** Every control sharing this field's `name` (radio groups have many). */ controls: NativeControl[]; /** The control used for ARIA wiring and constraint attributes. */ primary: NativeControl; errorEl: HTMLElement | null; /** Rule names declared in `data-c42-validate`. */ rules: string[]; touched: boolean; } function capitalize(value: string): string { return value.charAt(0).toUpperCase() + value.slice(1); } function isNativeControl(el: Element | null): el is NativeControl { return ( el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement ); } /** * Headless form/field orchestrator. Attaches to an existing `
` (progressive * enhancement), discovers fields via `[data-c42-field]`, runs validation, wires * ARIA (`aria-invalid`, `aria-describedby`) and reflects `data-state="valid|invalid"` * on each field wrapper so CSS can react — it never applies visual styles itself. * * Markup: * ```html * *
* * * *
* *
* ``` */ export class Form { private readonly root: HTMLElement; private readonly mode: ValidationMode; private readonly messages: Record; private readonly customValidators: Record; private fields: Field[] = []; private errors: FieldErrors = {}; private submitted = false; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: FormOptions = {}) { this.root = root; this.mode = options.mode ?? 'submit'; this.messages = { ...DEFAULT_MESSAGES, ...options.messages }; this.customValidators = {}; for (const [name, v] of Object.entries(options.validators ?? {})) { this.customValidators[name] = Array.isArray(v) ? v : [v]; } this.fields = this.collectFields(); if (this.fields.length === 0) { throw new Error('[42/form] No [data-c42-field] elements found.'); } // We do our own validation; disable native bubbles on a
root. if (root instanceof HTMLFormElement) { root.noValidate = true; } const onSubmit = (event: Event): void => { event.preventDefault(); this.submit(); }; const onInput = (event: Event): void => this.onInteraction(event, 'input'); const onChange = (event: Event): void => this.onInteraction(event, 'change'); const onBlur = (event: Event): void => this.onInteraction(event, 'blur'); root.addEventListener('submit', onSubmit); root.addEventListener('input', onInput); root.addEventListener('change', onChange); // `blur` does not bubble; use the capturing `focusout` instead. root.addEventListener('focusout', onBlur); this.cleanups.push( () => root.removeEventListener('submit', onSubmit), () => root.removeEventListener('input', onInput), () => root.removeEventListener('change', onChange), () => root.removeEventListener('focusout', onBlur), ); } private collectFields(): Field[] { const wrappers = Array.from(this.root.querySelectorAll(SELECTORS.field)); const fields: Field[] = []; for (const el of wrappers) { const explicit = el.querySelector(SELECTORS.control); const candidates = Array.from(el.querySelectorAll(NATIVE_CONTROL)).filter( isNativeControl, ); const primary = isNativeControl(explicit) ? explicit : candidates[0]; if (!primary) { continue; } const name = primary.name || el.dataset.c42Field || ''; if (!name) { continue; } const controls = candidates.filter((c) => c.name === name || c === primary); const errorEl = el.querySelector(SELECTORS.error); const rules = (primary.dataset.c42Validate ?? '').split(/\s+/).filter(Boolean); // Wire aria-describedby ↔ error element so SR users hear the message. if (errorEl) { if (!errorEl.id) { errorEl.id = uid('c42-field-error'); } const described = (primary.getAttribute('aria-describedby') ?? '') .split(/\s+/) .filter(Boolean); if (!described.includes(errorEl.id)) { described.push(errorEl.id); primary.setAttribute('aria-describedby', described.join(' ')); } } fields.push({ el, name, controls, primary, errorEl, rules, touched: false }); } return fields; } private fieldByName(name: string): Field | undefined { return this.fields.find((f) => f.name === name); } private valueOf(field: Field): string { const { controls, primary } = field; const radios = controls.filter( (c): c is HTMLInputElement => c instanceof HTMLInputElement && c.type === 'radio', ); if (radios.length > 0) { const checked = radios.find((r) => r.checked); return checked ? checked.value : ''; } if (primary instanceof HTMLInputElement && primary.type === 'checkbox') { return primary.checked ? primary.value || 'on' : ''; } return primary.value; } private messageFor(field: Field, rule: BuiltInRule): string { const override = field.primary.dataset[`c42Error${capitalize(rule)}`]; return override ?? this.messages[rule]; } private constraint(field: Field, rule: 'minlength' | 'maxlength' | 'min' | 'max'): number | null { const ds = field.primary.dataset[`c42${capitalize(rule)}`]; const attr = field.primary.getAttribute(rule); const raw = ds ?? attr; if (raw == null || raw === '') { return null; } const n = Number(raw); return Number.isNaN(n) ? null : n; } private patternFor(field: Field): string | null { return field.primary.dataset.c42Pattern ?? field.primary.getAttribute('pattern'); } private validateValue(field: Field, value: string, values: Record): string | null { const rules = field.rules; const empty = value.trim() === ''; if (rules.includes('required') && empty) { return this.messageFor(field, 'required'); } if (!empty) { if (rules.includes('email') && !EMAIL_RE.test(value)) { return this.messageFor(field, 'email'); } if (rules.includes('url') && !URL_RE.test(value)) { return this.messageFor(field, 'url'); } if (rules.includes('number') && Number.isNaN(Number(value))) { return this.messageFor(field, 'number'); } if (rules.includes('integer') && !Number.isInteger(Number(value))) { return this.messageFor(field, 'integer'); } const minlength = this.constraint(field, 'minlength'); if (minlength != null && value.length < minlength) { return this.messageFor(field, 'minlength'); } const maxlength = this.constraint(field, 'maxlength'); if (maxlength != null && value.length > maxlength) { return this.messageFor(field, 'maxlength'); } const min = this.constraint(field, 'min'); if (min != null && Number(value) < min) { return this.messageFor(field, 'min'); } const max = this.constraint(field, 'max'); if (max != null && Number(value) > max) { return this.messageFor(field, 'max'); } const pattern = this.patternFor(field); if (pattern != null && pattern !== '' && !new RegExp(`^(?:${pattern})$`).test(value)) { return this.messageFor(field, 'pattern'); } } for (const fn of this.customValidators[field.name] ?? []) { const result = fn(value, values); if (result) { return result; } } return null; } private applyFieldState(field: Field, error: string | null): void { if (error) { this.errors[field.name] = error; field.el.dataset.state = 'invalid'; field.primary.setAttribute('aria-invalid', 'true'); if (field.errorEl) { field.errorEl.textContent = error; field.errorEl.hidden = false; } } else { delete this.errors[field.name]; field.el.dataset.state = 'valid'; field.primary.removeAttribute('aria-invalid'); if (field.errorEl) { field.errorEl.textContent = ''; field.errorEl.hidden = true; } } } private runField(field: Field, values: Record): string | null { const error = this.validateValue(field, this.valueOf(field), values); this.applyFieldState(field, error); return error; } private onInteraction(event: Event, kind: ValidationMode): void { const target = event.target as Element | null; if (!isNativeControl(target)) { return; } const field = this.fields.find((f) => f.controls.includes(target)); if (!field) { return; } if (kind === 'blur') { field.touched = true; } if (kind === 'change' || kind === 'input') { const value = this.valueOf(field); this.emit(FORM_CHANGE_EVENT, { name: field.name, value, values: this.getValues(), }); } // Validate live when the mode matches, or once the field is touched / the form // was already submitted (re-validate to clear stale errors as the user fixes them). const liveByMode = this.mode === kind; const liveAfterSubmit = (this.submitted || field.touched) && kind === 'input'; if (liveByMode || liveAfterSubmit) { this.runField(field, this.getValues()); } } private emit(name: string, detail: D): void { this.root.dispatchEvent(new CustomEvent(name, { detail, bubbles: true })); } /** Current `name` → value map for every discovered field. */ getValues(): Record { const values: Record = {}; for (const field of this.fields) { values[field.name] = this.valueOf(field); } return values; } /** Set values by field name. Radios/checkboxes are toggled to match. */ setValues(values: Record): void { for (const [name, value] of Object.entries(values)) { const field = this.fieldByName(name); if (!field) { continue; } const radios = field.controls.filter( (c): c is HTMLInputElement => c instanceof HTMLInputElement && c.type === 'radio', ); if (radios.length > 0) { radios.forEach((r) => (r.checked = r.value === value)); } else if (field.primary instanceof HTMLInputElement && field.primary.type === 'checkbox') { field.primary.checked = value !== '' && value !== 'false'; } else { field.primary.value = value; } } } /** Validate every field. Returns `true` when all pass. */ validate(): boolean { const values = this.getValues(); let ok = true; for (const field of this.fields) { const error = this.runField(field, values); if (error) { ok = false; } } return ok; } /** Validate a single field by name. Returns its error message or `null`. */ validateField(name: string): string | null { const field = this.fieldByName(name); if (!field) { return null; } return this.runField(field, this.getValues()); } /** Programmatically set an error on a field (e.g. server-side validation). */ setError(name: string, message: string): void { const field = this.fieldByName(name); if (field) { this.applyFieldState(field, message); } } /** Clear all error state without touching values. */ clearErrors(): void { this.errors = {}; for (const field of this.fields) { delete field.el.dataset.state; field.primary.removeAttribute('aria-invalid'); if (field.errorEl) { field.errorEl.textContent = ''; field.errorEl.hidden = true; } } } /** Reset the underlying form and clear all error/touched state. */ reset(): void { if (this.root instanceof HTMLFormElement) { this.root.reset(); } this.clearErrors(); this.submitted = false; this.fields.forEach((f) => (f.touched = false)); this.emit>(FORM_RESET_EVENT, {}); } /** * Validate everything and emit `form:submit` (valid) or `form:invalid`. * Focuses the first invalid control. Returns whether the form was valid. */ submit(): boolean { this.submitted = true; const valid = this.validate(); if (valid) { this.emit(FORM_SUBMIT_EVENT, { values: this.getValues() }); return true; } this.emit(FORM_INVALID_EVENT, { errors: { ...this.errors } }); const firstInvalid = this.fields.find((f) => this.errors[f.name] != null); firstInvalid?.primary.focus(); return false; } /** Read the current state (values + errors + validity). */ getState(): FormState { return { values: this.getValues(), errors: { ...this.errors }, valid: Object.keys(this.errors).length === 0, }; } /** Subscribe to a DOM event on the root element. Returns an unsubscribe fn. */ on(event: string, handler: (event: E) => void): () => void { const listener = handler as EventListener; this.root.addEventListener(event, listener); const off = (): void => this.root.removeEventListener(event, listener); this.cleanups.push(off); return off; } destroy(): void { this.cleanups.forEach((fn) => fn()); this.cleanups = []; } }