tsurih
Version:
Svelte form validation library
316 lines (251 loc) • 6.37 kB
text/typescript
import { Writable, get, writable } from "svelte/store";
import type {
TConfig,
TContext,
TElement,
TEventName,
TValidator,
} from "./types.js";
import {
debounce,
isArray,
isNumber,
isObject,
isString,
validators,
} from "./utils.js";
const scrollToElement = (el: TElement | undefined) => {
if (!el) {
return;
}
el.focus();
el.scrollIntoView({
block: "center",
behavior: "smooth",
});
};
const unwrapFromMap = (map: any) => {
const newObj = [...map.entries()].reduce<any>((acc, arr) => {
acc[arr[0]] = arr[1];
return acc;
}, {});
return newObj;
};
export const tsurih = <
TValues extends Record<string, unknown>,
TSchema extends Record<
keyof TValues,
TValidator<TValues, keyof TValues, TConfig<TValues, TSchema>>[]
>
>(
defaultValues: TValues
) => {
const keysOfValues = Object.keys(defaultValues);
type TCfg = TConfig<TValues, TSchema>;
let config: TCfg = {
form: undefined,
dirty: new Map(),
elements: new Set(),
strategy: "blur",
fields: new Map(),
values: writable({}),
errors: writable({}),
isSubmitting: writable(false),
defaultValues: {},
schema: undefined,
};
config.values.set(defaultValues as TValues);
for (const key of keysOfValues) {
config.fields.set(key, key);
config.dirty.set(key, false);
}
type TCtx<TFieldName extends keyof TValues> = TContext<
TValues,
TFieldName,
TCfg
>;
const validate = async (ctx: TCtx<any>) => {
if (ctx.isDirty && ctx.name && config.schema?.[ctx.name]) {
await checkValidators(ctx, config.schema[ctx.name]);
}
};
const checkValidators = async (
ctx: TCtx<any>,
validators: TValidator<TValues, TCtx<typeof ctx.name>["name"], TCfg>[]
) => {
if (!validators || !isArray(validators)) {
return;
}
for (const v of validators) {
const result = await v(ctx);
if (result) {
ctx.setError(ctx.name as string, result);
break;
} else {
ctx.setError(ctx.name as string, "");
}
}
};
const revalidate = async (fieldName: keyof TValues) => {
if (!config.dirty.get(fieldName)) {
config.dirty.set(fieldName, true);
}
const ctx = await createCtx(fieldName, get(config.values)[fieldName]);
if (config.schema?.[fieldName]) {
await checkValidators(ctx, config.schema?.[fieldName]);
}
};
const setError = (name: keyof TValues, message: string) => {
if (name) {
config.errors.update((state) => {
state[name] = message;
return {
...state,
};
});
}
};
const createCtx = <T extends keyof TValues>(name: T, value?: any) => {
const ctx: TCtx<T> = {
config,
name,
value,
setError,
isDirty: config.dirty.get(name) === true,
};
return ctx;
};
const onEvent = (type: "input" | "blur") => {
return async (e: Event) => {
const target: any = e.target || {};
if (target.name) {
const typedName = target?.name as keyof TValues;
const value =
target?.type === "checkbox" ? target?.checked : target?.value;
if (config.strategy === type && !config.dirty.get(typedName)) {
config.dirty.set(typedName, true);
}
const ctx = createCtx(typedName, value);
await validate(ctx);
}
};
};
const checkErrors = () => {
let firstErrorName = "";
const errors = get(config.errors);
for (const prop in errors) {
if (errors.hasOwnProperty(prop) && errors[prop]) {
firstErrorName = prop;
break;
}
}
if (firstErrorName) {
const firstErrorElement = [...config.elements.values()].find(
(el) => el.name === firstErrorName
);
scrollToElement(firstErrorElement);
return false;
}
return true;
};
const onSubmit = (
callback?:
| ((values: TValues) => void)
| ((values: TValues) => Promise<void>)
) => {
return async (e: Event) => {
e.preventDefault();
e.stopPropagation();
if (!validate) {
return;
}
fieldRegistration();
if (config.elements.size === 0) {
return;
}
config.isSubmitting.set(true);
let isValid = false;
const values = get(config.values);
for (const name in values) {
if (!values.hasOwnProperty(name)) {
continue;
}
if (!config.dirty.get(name)) {
config.dirty.set(name, true);
}
const ctx = createCtx(name, values[name]);
if (config.schema?.[name]) {
await checkValidators(ctx, config.schema?.[name]);
}
}
isValid = checkErrors();
if (isValid && callback) {
callback(values as TValues);
}
config.isSubmitting.set(false);
};
};
const reset = () => {
config.dirty.clear();
config.values.set(config.defaultValues);
config.elements.clear();
config.errors.set({});
};
const fieldRegistration = () => {
if (!config.form) {
return;
}
const els = config.form.querySelectorAll("[name]");
if (!els || els.length === 0) {
return;
}
Array.from(els).forEach((el) => {
const typedEl = el as TElement;
if (!config.elements.has(typedEl)) {
config.elements.add(typedEl);
}
});
config.elements.forEach((element) => {
element.oninput = onEvent("input");
element.onblur = onEvent("blur");
});
};
const setSchema = (schema: TSchema) => {
config.schema = schema;
return returnObject;
};
const setStrategy = (v: TEventName) => {
config.strategy = v;
return returnObject;
};
const returnObject = {
reset,
reg: (passedForm: HTMLFormElement) => {
config.form = passedForm;
fieldRegistration();
},
getFormEl: () => config.form,
errors: config.errors,
onSubmit,
isSubmitting: config.isSubmitting,
fields: unwrapFromMap(config.fields) as Partial<
Record<keyof TValues, keyof TValues>
>,
values: config.values as Writable<TValues>,
revalidate,
setSchema,
setStrategy,
};
return returnObject;
};
export {
TConfig,
TContext,
TEventName,
debounce,
isArray,
isNumber,
isObject,
isString,
validators,
};