added native reactive form to elements

This commit is contained in:
dswbx
2025-02-08 11:59:43 +01:00
parent f29641c702
commit bb964dc835
6 changed files with 396 additions and 1 deletions

View File

@@ -0,0 +1,215 @@
import {
type ChangeEvent,
type ComponentPropsWithoutRef,
type FormEvent,
useEffect,
useRef,
useState
} from "react";
import { useEvent } from "ui/hooks/use-event";
import {
type CleanOptions,
type InputElement,
cleanObject,
coerce,
getFormTarget,
getTargetsByName,
setPath
} from "./utils";
export type NativeFormProps = {
hiddenSubmit?: boolean;
validateOn?: "change" | "submit";
errorFieldSelector?: <K extends keyof HTMLElementTagNameMap>(name: string) => any | null;
reportValidity?: boolean;
onSubmit?: (data: any, ctx: { event: FormEvent<HTMLFormElement> }) => Promise<void> | void;
onSubmitInvalid?: (
errors: InputError[],
ctx: { event: FormEvent<HTMLFormElement> }
) => Promise<void> | void;
onError?: (errors: InputError[]) => void;
onChange?: (
data: any,
ctx: { event: ChangeEvent<HTMLFormElement>; key: string; value: any; errors: InputError[] }
) => Promise<void> | void;
clean?: CleanOptions | true;
} & Omit<ComponentPropsWithoutRef<"form">, "onChange" | "onSubmit">;
export type InputError = {
name: string;
message: string;
};
export function NativeForm({
children,
validateOn = "submit",
hiddenSubmit = false,
errorFieldSelector,
reportValidity,
onSubmit,
onSubmitInvalid,
onError,
clean,
...props
}: NativeFormProps) {
const formRef = useRef<HTMLFormElement>(null);
const [errors, setErrors] = useState<InputError[]>([]);
useEffect(() => {
if (!formRef.current || props.noValidate) return;
validate();
}, []);
useEffect(() => {
if (!formRef.current || props.noValidate) return;
// find submit buttons and disable them if there are errors
const invalid = errors.length > 0;
formRef.current.querySelectorAll("[type=submit]").forEach((submit) => {
if (!submit || !("type" in submit) || submit.type !== "submit") return;
// @ts-ignore
submit.disabled = invalid;
});
onError?.(errors);
}, [errors]);
const validateElement = useEvent((el: InputElement | null, opts?: { report?: boolean }) => {
if (props.noValidate || !el || !("name" in el)) return;
const errorElement = formRef.current?.querySelector(
errorFieldSelector?.(el.name) ?? `[data-role="input-error"][data-name="${el.name}"]`
);
if (!el.checkValidity()) {
const error = {
name: el.name,
message: el.validationMessage
};
setErrors((prev) => [...prev.filter((e) => e.name !== el.name), error]);
if (opts?.report) {
if (errorElement) {
errorElement.textContent = error.message;
} else if (reportValidity) {
el.reportValidity();
}
}
return error;
} else {
setErrors((prev) => prev.filter((e) => e.name !== el.name));
if (errorElement) {
errorElement.textContent = "";
}
}
return;
});
const validate = useEvent((opts?: { report?: boolean }) => {
if (!formRef.current || props.noValidate) return [];
const errors: InputError[] = [];
formRef.current.querySelectorAll("input, select, textarea").forEach((e) => {
const el = e as InputElement | null;
const error = validateElement(el, opts);
if (error) {
errors.push(error);
}
});
return errors;
});
const getFormValues = useEvent(() => {
if (!formRef.current) return {};
const formData = new FormData(formRef.current);
const obj: any = {};
formData.forEach((value, key) => {
const targets = getTargetsByName(formRef.current!, key);
if (targets.length === 0) {
console.warn(`No target found for key: ${key}`);
return;
}
const count = targets.length;
const multiple = count > 1;
targets.forEach((target, index) => {
let _key = key;
if (multiple) {
_key = `${key}[${index}]`;
}
setPath(obj, _key, coerce(target, target.value));
});
});
if (typeof clean === "undefined") return obj;
return cleanObject(obj, clean === true ? undefined : clean);
});
const handleChange = useEvent(async (e: ChangeEvent<HTMLFormElement>) => {
const form = formRef.current;
if (!form) return;
const target = getFormTarget(e);
if (!target) return;
if (validateOn === "change") {
validateElement(target, { report: true });
}
if (props.onChange) {
await props.onChange(getFormValues(), {
event: e,
key: target.name,
value: target.value,
errors
});
}
});
const handleSubmit = useEvent(async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = formRef.current;
if (!form) return;
const errors = validate({ report: true });
if (errors.length > 0) {
onSubmitInvalid?.(errors, { event: e });
return;
}
if (onSubmit) {
await onSubmit(getFormValues(), { event: e });
} else {
form.submit();
}
});
const handleKeyDown = useEvent((e: KeyboardEvent) => {
if (!formRef.current) return;
// if is enter key, submit is disabled, report errors
if (e.keyCode === 13) {
const invalid = errors.length > 0;
if (invalid && !props.noValidate && reportValidity) {
formRef.current.reportValidity();
}
}
});
return (
<form
{...props}
onChange={handleChange}
onSubmit={handleSubmit}
ref={formRef}
onKeyDown={handleKeyDown as any}
>
{children}
{hiddenSubmit && <input type="submit" style={{ visibility: "hidden" }} />}
</form>
);
}

View File

@@ -0,0 +1,137 @@
import type { FormEvent } from "react";
export type InputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
export function ignoreTarget(target: InputElement | Element | null, form?: HTMLFormElement) {
const tagName = target?.tagName.toLowerCase() ?? "";
const tagNames = ["input", "select", "textarea"];
return (
!target ||
!form?.contains(target) ||
!tagNames.includes(tagName) ||
!("name" in target) ||
target.hasAttribute("data-ignore") ||
target.closest("[data-ignore]")
);
}
export function getFormTarget(e: FormEvent<HTMLFormElement>): InputElement | null {
const form = e.currentTarget;
const target = e.target as InputElement | null;
return ignoreTarget(target, form) ? null : target;
}
export function getTargetsByName(form: HTMLFormElement, name: string): InputElement[] {
const query = form.querySelectorAll(`[name="${name}"]`);
return Array.from(query).filter((e) => ignoreTarget(e)) as InputElement[];
}
export function coerce(target: InputElement | null, value?: any) {
if (!target) return value;
const required = target.required;
if (!value && !required) return undefined;
if (target.type === "number") {
const num = Number(value);
if (Number.isNaN(num) && !required) return undefined;
const min = "min" in target && target.min.length > 0 ? Number(target.min) : undefined;
const max = "max" in target && target.max.length > 0 ? Number(target.max) : undefined;
const step = "step" in target && target.step.length > 0 ? Number(target.step) : undefined;
if (min && num < min) return min;
if (max && num > max) return max;
if (step && step !== 1) return Math.round(num / step) * step;
return num;
} else if (target.type === "text") {
const maxLength =
"maxLength" in target && target.maxLength > -1 ? Number(target.maxLength) : undefined;
const pattern = "pattern" in target ? new RegExp(target.pattern) : undefined;
if (maxLength && value.length > maxLength) return value.slice(0, maxLength);
if (pattern && !pattern.test(value)) return "";
return value;
} else if (target.type === "checkbox") {
if ("checked" in target) return !!target.checked;
return ["on", "1", "true", 1, true].includes(value);
} else {
return value;
}
}
export type CleanOptions = {
empty?: any[];
emptyInArray?: any[];
keepEmptyArray?: boolean;
};
export function cleanObject<Obj extends { [key: string]: any }>(
obj: Obj,
_opts?: CleanOptions
): Obj {
if (!obj) return obj;
const _empty = [null, undefined, ""];
const opts = {
empty: _opts?.empty ?? _empty,
emptyInArray: _opts?.emptyInArray ?? _empty,
keepEmptyArray: _opts?.keepEmptyArray ?? false
};
return Object.entries(obj).reduce((acc, [key, value]) => {
if (value && Array.isArray(value) && value.some((v) => typeof v === "object")) {
const nested = value.map((o) => cleanObject(o, opts));
if (nested.length > 0 || opts?.keepEmptyArray) {
acc[key] = nested;
}
} else if (value && typeof value === "object" && !Array.isArray(value)) {
const nested = cleanObject(value, opts);
if (Object.keys(nested).length > 0) {
acc[key] = nested;
}
} else if (Array.isArray(value)) {
const nested = value.filter((v) => !opts.emptyInArray.includes(v));
if (nested.length > 0 || opts?.keepEmptyArray) {
acc[key] = nested;
}
} else if (!opts.empty.includes(value)) {
acc[key] = value;
}
return acc;
}, {} as any);
}
export function setPath(object, _path, value) {
let path = _path;
// Optional string-path support.
// You can remove this `if` block if you don't need it.
if (typeof path === "string") {
const isQuoted = (str) => str[0] === '"' && str.at(-1) === '"';
path = path
.split(/[.\[\]]+/)
.filter((x) => x)
.map((x) => (!Number.isNaN(Number(x)) ? Number(x) : x))
.map((x) => (typeof x === "string" && isQuoted(x) ? x.slice(1, -1) : x));
}
if (path.length === 0) {
throw new Error("The path must have at least one entry in it");
}
const [head, ...tail] = path;
if (tail.length === 0) {
object[head] = value;
return object;
}
if (!(head in object)) {
object[head] = typeof tail[0] === "number" ? [] : {};
}
setPath(object[head], tail, value);
return object;
}