svelte-simple-form
Version:
A lightweight, **type-safe**, and **reactive** form state management hook for **Svelte 5**, featuring:
161 lines (160 loc) • 6.09 kB
JavaScript
import { tick, untrack } from 'svelte';
import { z } from 'zod';
import { checkPath, getByPath, getChangedPaths, setByPath } from './helper.js';
import { zodValidator } from './validation/zod.js';
export default function useForm({ initialValues, validation, onSubmit, onChange, onReset }) {
if (Object.keys(initialValues || {}).some((k) => k.includes('.'))) {
throw new Error('Nested paths are not supported');
}
const form = $state({
initialValues,
data: initialValues,
errors: {},
isValid: true,
isSubmitting: false,
isDirty: false,
touched: {},
setInitialValues: (values, { reset = false } = {}) => {
const v = structuredClone($state.snapshot({ ...values }));
form.initialValues = v;
if (reset)
form.reset();
},
setIsDirty: (dirty = true) => {
form.isDirty = dirty;
},
setIsSubmitting: (submitting = true) => {
form.isSubmitting = submitting;
},
reset: () => {
form.data = structuredClone($state.snapshot(form.initialValues));
tick().then(() => {
form.errors = {};
form.touched = {};
form.isValid = true;
form.isSubmitting = false;
form.isDirty = false;
onReset?.();
});
},
resetField: (field) => {
if (checkPath(form.initialValues, field)) {
const val = getByPath(structuredClone($state.snapshot(form.initialValues)), field);
setByPath(form.data, field, val);
}
else {
console.warn(`Field ${field} does not exist on initial values`);
setByPath(form.data, field, undefined);
}
tick().then(() => {
Object.keys(form.touched).forEach((key) => {
// @ts-ignore
if (key.includes('.') && key.startsWith(field))
form.touched[key] = undefined;
});
});
},
setError: (field, error) => {
form.errors[field] = Array.isArray(error) ? error : [error];
},
validate: (field) => {
if (validation?.zod) {
let fields = Array.isArray(field) ? field : [field];
const related = validation.relatedFields || {};
const extraFields = fields.map((f) => related[f] || []).flat();
fields = [...new Set([...fields, ...extraFields])];
// Clear current errors for those fields
for (const f of fields) {
// @ts-ignore
delete form.errors[f];
}
const errors = zodValidator(validation.zod, form.data, field ? fields : undefined);
form.errors = {
...form.errors,
...errors
};
}
return (Object.keys(form.errors).length === 0 ||
// @ts-ignore
Object.keys(form.errors).every((key) => (form.errors[key]?.length || 0) === 0));
},
submit: async (callback) => {
if (form.isSubmitting)
return;
if (validation) {
form.validate();
await tick();
}
if (!form.isValid)
return;
form.isSubmitting = true;
if (callback)
await callback(form.data);
// @ts-ignore
else if (onSubmit)
await onSubmit($state.snapshot(form.data));
await tick();
form.isSubmitting = false;
},
handler: (node) => {
node.addEventListener('submit', (event) => {
event.preventDefault();
form.submit();
});
}
});
$effect(() => {
const equal = Object.keys(form.data).every((key) => {
// @ts-ignore
const eq = JSON.stringify(form.data[key]) === JSON.stringify(form.initialValues[key]);
return eq;
});
form.isDirty = !equal;
});
let prevData = $state.snapshot(form.data);
$effect(() => {
JSON.stringify(form.data);
untrack(async () => {
const currentData = structuredClone($state.snapshot(form.data));
const changedFields = getChangedPaths(prevData, currentData);
// debug
// console.log('Changed fields:', changedFields);
for (const path of changedFields) {
form.touched[path] = true;
const v = $state.snapshot(getByPath(form.data, path));
if (onChange) {
onChange(path, v);
}
//
if (validation) {
if (!checkPath(form.data, path)) {
Object.keys(form.errors).forEach((key) => {
// @ts-ignore
if (key.includes('.') && key.startsWith(path))
form.errors[key] = undefined;
});
Object.keys(form.touched).forEach((key) => {
// @ts-ignore
if (key.includes('.') && key.startsWith(path))
form.touched[key] = undefined;
});
}
}
}
if (validation) {
form.validate(changedFields);
}
prevData = currentData;
});
});
$effect(() => {
JSON.stringify(form.errors);
untrack(() => {
form.isValid =
Object.keys(form.errors).length === 0 ||
// @ts-ignore
Object.keys(form.errors).every((key) => (form.errors[key]?.length || 0) === 0);
});
});
return { form };
}