UNPKG

svelte-simple-form

Version:

A lightweight, **type-safe**, and **reactive** form state management hook for **Svelte 5**, featuring:

625 lines (624 loc) 23.1 kB
// ===================== // type // ===================== // ===================== // helper // ===================== function setByPath(obj, path, value) { const parts = path.split('.'); let current = obj; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; if (/^\d+$/.test(part)) { const index = Number(part); if (!Array.isArray(current)) throw new Error(`Expected array at "${part}"`); if (!current[index]) current[index] = {}; current = current[index]; } else { if (current[part] === undefined) current[part] = {}; current = current[part]; } } const last = parts[parts.length - 1]; if (/^\d+$/.test(last)) current[Number(last)] = value; else current[last] = value; } function getValueByPath(obj, path) { const parts = path.split('.'); let current = obj; for (const part of parts) { const isIndex = /^\d+$/.test(part); if (isIndex) { if (!Array.isArray(current)) return undefined; current = current[Number(part)]; } else { if (current == null || !(part in current)) return undefined; current = current[part]; } } return current; } function arrayInsert(arr, index, value) { const clone = arr.slice(); clone.splice(index, 0, value); return clone; } function arrayRemove(arr, index) { const clone = arr.slice(); clone.splice(index, 1); return clone; } function arraySwap(arr, i, j) { const clone = arr.slice(); const tmp = clone[i]; clone[i] = clone[j]; clone[j] = tmp; return clone; } function arrayMove(arr, from, to) { const clone = arr.slice(); const item = clone.splice(from, 1)[0]; clone.splice(to, 0, item); return clone; } function shiftRecordKeys(record, path, shiftFn) { const prefix = path + '.'; const out = {}; for (const key of Object.keys(record)) { if (!key.startsWith(prefix)) { out[key] = record[key]; continue; } const remainder = key.slice(prefix.length); const match = remainder.match(/^(\d+)(.*)$/); if (!match) { out[key] = record[key]; continue; } const oldIndex = Number(match[1]); const tail = match[2]; const newIndex = shiftFn(oldIndex); if (newIndex === null) continue; const newKey = prefix + newIndex + tail; out[newKey] = record[key]; } return out; } // ===================== // core // ===================== import { tick, untrack } from 'svelte'; // ---------- useForm ---------- export function useForm(props) { const { initialValues, onSubmit, onReset, onSubmitError } = props; const form = $state({ initialValues, data: initialValues, isSubmitting: false, reset() { (async () => { form.data = structuredClone($state.snapshot(form.initialValues)); await tick(); form.isSubmitting = false; await tick(); onReset?.(); })(); }, async submit(callback) { form.isSubmitting = true; try { try { if (callback) await callback(form.data); else if (onSubmit) await onSubmit($state.snapshot(form.data)); } catch (e) { onSubmitError?.(e); } } finally { await tick(); form.isSubmitting = false; } }, handler(node) { node.addEventListener('submit', (e) => { e.preventDefault(); form.submit(); }); } }); return { form }; } // ---------- useFormControl ---------- export function useFormControl(props) { const { initialValues, validator, validateOn = ['change', 'blur', 'submit'], validateAfter = 'touched-and-dirty', validateDebounce = 100, onSubmit, onReset, onSubmitErrorValidation, onSubmitError } = props; // Track reset generation to prevent pending validations from overwriting reset state let resetGeneration = $state(0); // Track debounce timers to prevent API hammering on rapid keystrokes const debounceTimers = {}; let activeRequests = $state(0); const form = $state({ initialValues, data: initialValues, errors: {}, touched: {}, dirty: {}, isValid: true, isValidating: false, isSubmitting: false, isDirty: false, reset() { (async () => { // Increment resetGeneration to cancel pending validations resetGeneration++; form.data = structuredClone($state.snapshot(form.initialValues)); await tick(); form.errors = {}; form.touched = {}; form.dirty = {}; form.isValid = true; form.isDirty = false; form.isSubmitting = false; await tick(); onReset?.(); })(); }, resetField(path) { setByPath(form.data, path, getValueByPath(form.initialValues, path)); form.touched[path] = false; form.dirty[path] = false; }, async submit(callback) { if (form.isSubmitting) return; form.isSubmitting = true; try { if (validator && validateOn.includes('submit')) { // @ts-ignore if (!(await validator.validateForm(form))) { onSubmitErrorValidation?.(form.errors); return; } } // Guard: form.isValid is updated via $effect (async), // but form.errors is mutated synchronously via $state. // Check both to catch sync setError() + submit() patterns. const currentErrors = form.errors; const hasErrors = Object.values(currentErrors).some((e) => e != null && e.length > 0); if (hasErrors || !form.isValid) { if (hasErrors) onSubmitErrorValidation?.(form.errors); return; } try { if (callback) await callback(form.data); else if (onSubmit) await onSubmit($state.snapshot(form.data)); } catch (e) { onSubmitError?.(e, form.errors); } } finally { await tick(); form.isSubmitting = false; } }, setInitialValues: (values, props = {}) => { const { reset = false } = props; const v = structuredClone($state.snapshot({ ...values })); form.initialValues = v; if (reset) form.reset(); }, setData: createSetData(), setIsValid(isValid) { form.isValid = isValid; }, setIsValidating(isValidating) { form.isValidating = isValidating; }, setTouched(field, value = true) { form.touched[field] = value; }, removeTouched(field) { delete form.touched[field]; }, setDirty(field, value = true) { form.dirty[field] = value; }, removeDirty(field) { delete form.dirty[field]; }, arrayAdd(path, value, idx = undefined, opts = {}) { const { shouldTouch = true, shouldDirty = true, shouldValidate = true } = opts; const arr = (getValueByPath(form.data, path) || []); const index = idx !== undefined ? idx : arr.length; setByPath(form.data, path, arrayInsert(arr, index, value)); form.touched = shiftRecordKeys(form.touched, path, (old) => (old >= index ? old + 1 : old)); form.dirty = shiftRecordKeys(form.dirty, path, (old) => (old >= index ? old + 1 : old)); form.errors = shiftRecordKeys(form.errors, path, (old) => (old >= index ? old + 1 : old)); if (shouldTouch) setByPath(form.touched, path, true); if (shouldDirty) setByPath(form.dirty, path, true); if (validator && shouldValidate) safeValidateField(path); }, arrayRemove(path, index, opts = {}) { const { shouldTouch = true, shouldDirty = true, shouldValidate = true } = opts; const arr = getValueByPath(form.data, path); setByPath(form.data, path, arrayRemove(arr, index)); form.touched = shiftRecordKeys(form.touched, path, (old) => old === index ? null : old > index ? old - 1 : old); form.dirty = shiftRecordKeys(form.dirty, path, (old) => old === index ? null : old > index ? old - 1 : old); form.errors = shiftRecordKeys(form.errors, path, (old) => old === index ? null : old > index ? old - 1 : old); if (shouldTouch) setByPath(form.touched, path, true); if (shouldDirty) setByPath(form.dirty, path, true); if (validator && shouldValidate) safeValidateField(path); }, arraySwap(path, i, j, opts = {}) { const { shouldTouch = true, shouldDirty = true, shouldValidate = true } = opts; const arr = getValueByPath(form.data, path); setByPath(form.data, path, arraySwap(arr, i, j)); form.touched = shiftRecordKeys(form.touched, path, (old) => old === i ? j : old === j ? i : old); form.dirty = shiftRecordKeys(form.dirty, path, (old) => old === i ? j : old === j ? i : old); form.errors = shiftRecordKeys(form.errors, path, (old) => old === i ? j : old === j ? i : old); if (shouldTouch) setByPath(form.touched, path, true); if (shouldDirty) setByPath(form.dirty, path, true); if (validator && shouldValidate) safeValidateField(path); }, arrayMove(path, from, to, opts = {}) { if (from === to) return; const { shouldTouch = true, shouldDirty = true, shouldValidate = true } = opts; const arr = getValueByPath(form.data, path); setByPath(form.data, path, arrayMove(arr, from, to)); const shiftFn = (old) => { if (old === from) return to; if (from < to && old > from && old <= to) return old - 1; if (from > to && old >= to && old < from) return old + 1; return old; }; form.touched = shiftRecordKeys(form.touched, path, shiftFn); form.dirty = shiftRecordKeys(form.dirty, path, shiftFn); form.errors = shiftRecordKeys(form.errors, path, shiftFn); if (shouldTouch) setByPath(form.touched, path, true); if (shouldDirty) setByPath(form.dirty, path, true); if (validator && shouldValidate) safeValidateField(path); }, arrayRemoveBy(path, predicate, opts = {}) { const arr = (getValueByPath(form.data, path) || []); const index = arr.findIndex(predicate); if (index !== -1) { this.arrayRemove(path, index, opts); } else { console.warn(`arrayRemoveBy: No item found matching predicate at path "${path}"`); } }, arrayUpdateBy(path, predicate, value, opts = {}) { const { shouldTouch = true, shouldDirty = true, shouldValidate = true } = opts; const arr = (getValueByPath(form.data, path) || []); const index = arr.findIndex(predicate); if (index !== -1) { const currentItem = arr[index]; const newValue = typeof value === 'function' ? value(currentItem) : value; const newArr = arr.slice(); newArr[index] = newValue; setByPath(form.data, path, newArr); const itemPath = `${path}.${index}`; if (shouldTouch) form.touched[itemPath] = true; if (shouldDirty) updatePathDirty(itemPath, newValue); if (validator && shouldValidate) safeValidateField(path); } else { console.warn(`arrayUpdateBy: No item found matching predicate at path "${path}"`); } }, setErrors(errors) { form.errors = structuredClone(errors); }, setError(field, error) { form.errors[field] = Array.isArray(error) ? error : [error]; }, removeError(field) { delete form.errors[field]; }, async validateField(field, force = false) { if (validator) { return await validator.validateField(field, form, force, { validateOn, validateAfter, validateDebounce }); } return true; }, async validate() { if (validator) return await validator.validateForm(form); return true; }, handler(node) { node.addEventListener('submit', (e) => { e.preventDefault(); form.submit(); }); } }); // ===================== // internal helper // ===================== function createSetData() { function setData(arg1, arg2, arg3) { if (typeof arg1 === 'object') { const { shouldValidate = false } = (arg2 || {}); form.data = structuredClone($state.snapshot({ ...arg1 })); if (validator && shouldValidate) validator.validateForm(form); } else { const { shouldTouch = true, shouldDirty = true, shouldValidate = true } = (arg3 || {}); // setByPath(form.data, arg1, arg2); if (shouldTouch) setByPath(form.touched, arg1, true); if (shouldDirty) updatePathDirty(arg1, arg2); if (validator && shouldValidate) safeValidateField(arg1, true); } } return setData; } function readValue(el, path) { const type = el.type; const tag = el.tagName.toLowerCase(); if (type === 'file') { if (el.multiple) return Array.from(el.files || []); return el.files?.[0] ?? null; } if (tag === 'select') { if (el.multiple) { return Array.from(el.selectedOptions).map((o) => o.value); } return el.value; } if (type === 'checkbox') { const val = getValueByPath(form.data, path); if (Array.isArray(val)) { if (el.checked) { return val.includes(el.value) ? val : [...val, el.value]; } else { return val.filter((v) => v !== el.value); } } return el.checked; } if (type === 'radio') { return el.checked ? el.value : getValueByPath(form.data, path); } if (tag === 'div' && el.isContentEditable) { return el.innerText; } return el.value; } function writeValue(el, value) { const type = el.type; const tag = el.tagName.toLowerCase(); if (type === 'file') return; if (tag === 'select') { if (el.multiple && Array.isArray(value)) { Array.from(el.options).forEach((opt) => { opt.selected = value.includes(opt.value); }); } else { el.value = value ?? ''; } return; } if (type === 'checkbox') { if (Array.isArray(value)) el.checked = value.includes(el.value); else el.checked = Boolean(value); return; } if (type === 'radio') { el.checked = el.value === value; return; } if (tag === 'div' && el.isContentEditable) { if (el.innerText !== value) { el.innerText = value ?? ''; } return; } el.value = value ?? ''; } function updatePathDirty(path, value) { const initial = getValueByPath(form.initialValues, path); const isPathDirty = JSON.stringify(initial) !== JSON.stringify(value); if (isPathDirty) form.dirty[path] = true; else delete form.dirty[path]; } function safeValidateField(path, force = false) { if (!validator) return; const rules = { dirty: () => form.dirty[path], touched: () => form.touched[path], 'touched-or-dirty': () => form.touched[path] || form.dirty[path], 'touched-and-dirty': () => form.touched[path] && form.dirty[path] }; const rule = rules[validateAfter]; const shouldValidate = force || (rule && rule()); if (shouldValidate) { // Fix: Debounce validation if triggered by change event const isChangeEvent = validateOn.includes('change') && !force; const shouldDebounce = validateDebounce > 0 && isChangeEvent; if (shouldDebounce) { // Clear existing debounce timer for this field if (debounceTimers[path]) { clearTimeout(debounceTimers[path]); } // Set new debounce timer debounceTimers[path] = setTimeout(() => { executeValidation(path); delete debounceTimers[path]; }, validateDebounce); } else { // Validate immediately (no debounce) executeValidation(path); } } } function executeValidation(path) { if (!validator) return; const currentResetGen = resetGeneration; activeRequests++; form.isValidating = true; Promise.resolve(validator.validateField(path, form, true, { validateOn, validateAfter, validateDebounce })).then(() => { activeRequests = Math.max(0, activeRequests - 1); if (resetGeneration !== currentResetGen) { form.errors = {}; } form.isValidating = activeRequests > 0; }); } const control = (node, data) => { const { field: path, valueAsNumber = false, setValueAs = undefined } = (typeof data === 'string' ? { field: data } : data); if (!path) return; const handleOnInput = async () => { let value = readValue(node, path); if (valueAsNumber) { const str = String(value).trim(); if (str === '' || str === '-') { value = str; } else if (/^-?\d+$/.test(str)) { value = Number(str); } else { value = getValueByPath(form.data, path); } writeValue(node, value); } if (setValueAs) { setValueAs(value); await tick(); value = getValueByPath(form.data, path); } else { setByPath(form.data, path, value); } tick().then(async () => { updatePathDirty(path, value); await tick(); if (node.type === 'radio' || node.type === 'checkbox' || node.type === 'select' || node.type === 'file') { form.touched[path] = true; } await tick(); if (getValueByPath(form.data, path) === getValueByPath(form.initialValues, path)) { delete form.errors[path]; } if (validator && validateOn.includes('change')) { safeValidateField(path); } }); }; const handleOnBlur = () => { form.touched[path] = true; tick().then(() => { if (validator && validateOn.includes('blur')) { safeValidateField(path); } }); }; const addListeners = () => { const tag = node.tagName?.toLowerCase(); const type = (node.type ?? '').toLowerCase(); const useChange = tag === 'select' || type === 'file' || type === 'checkbox' || type === 'radio'; const useInput = tag === 'input' || tag === 'textarea' || (!useChange && tag === 'div' && node.isContentEditable); if (useInput) node.addEventListener('input', handleOnInput); if (useChange) node.addEventListener('change', handleOnInput); node.addEventListener('blur', handleOnBlur); return () => { if (useInput) node.removeEventListener('input', handleOnInput); if (useChange) node.removeEventListener('change', handleOnInput); node.removeEventListener('blur', handleOnBlur); }; }; const cleanup = addListeners(); $effect(() => { const value = getValueByPath(form.data, path); untrack(() => { writeValue(node, value); }); }); return { destroy() { cleanup(); } }; }; $effect(() => { JSON.stringify(form.dirty); untrack(() => { form.isDirty = Object.values(form.dirty).some((v) => v === true); }); }); $effect(() => { JSON.stringify(form.errors); untrack(() => { form.isValid = Object.values(form.errors).every((v) => !v); }); }); return { form, control }; }