Fixxed bugs Form Validation
This commit is contained in:
+124
-139
@@ -83,13 +83,14 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Client validation & submit hook (no zod import here) -->
|
<!-- Client validation & submit hook (no zod import here) -->
|
||||||
<script is:inline define:vars={{ formId, showSuccesInput }}>
|
<script type="module" is:inline define:vars={{ formId, showSuccesInput }}>
|
||||||
const start = () => {
|
const start = () => {
|
||||||
const INVALID_KLS = ["!border-danger", "focus-visible:ring-danger/40"];
|
const INVALID_KLS = ["!border-danger", "focus-visible:ring-danger/40"];
|
||||||
const VALID_KLS = ["!border-success", "focus-visible:ring-success/40"];
|
const VALID_KLS = ["!border-success", "focus-visible:ring-success/40"];
|
||||||
|
|
||||||
const form = document.getElementById(formId);
|
const form = document.getElementById(formId);
|
||||||
if (!form) throw new Error("Form element not found");
|
if (!form || form.dataset.__afInit) return;
|
||||||
|
form.dataset.__afInit = "1";
|
||||||
|
|
||||||
function getSchema() {
|
function getSchema() {
|
||||||
const name = form.dataset.schema;
|
const name = form.dataset.schema;
|
||||||
@@ -102,15 +103,45 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
|||||||
return typeof val === "function" ? val() : val;
|
return typeof val === "function" ? val() : val;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer explicit [data-control]; fall back to any input/select/textarea
|
function unwrapToObject(schema) {
|
||||||
|
let cur = schema;
|
||||||
|
while (cur && cur._def) {
|
||||||
|
const t = cur._def.typeName;
|
||||||
|
if (t === "ZodObject") return cur;
|
||||||
|
if (t === "ZodEffects") {
|
||||||
|
cur = cur._def.schema;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
t === "ZodOptional" ||
|
||||||
|
t === "ZodNullable" ||
|
||||||
|
t === "ZodDefault" ||
|
||||||
|
t === "ZodBranded" ||
|
||||||
|
t === "ZodCatch"
|
||||||
|
) {
|
||||||
|
cur = cur._def.innerType;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (t === "ZodPipeline") {
|
||||||
|
cur = cur._def.out;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function getObjectShape(obj) {
|
||||||
|
if (!obj) return null;
|
||||||
|
const s = obj.shape ?? obj._def?.shape;
|
||||||
|
return typeof s === "function" ? s() : s;
|
||||||
|
}
|
||||||
|
|
||||||
function fieldControls(root) {
|
function fieldControls(root) {
|
||||||
// Prefer explicit data-control, fallback to any control
|
|
||||||
let ctrls = root.querySelectorAll(
|
let ctrls = root.querySelectorAll(
|
||||||
"input[data-control], select[data-control], textarea[data-control]",
|
"input[data-control],select[data-control],textarea[data-control]",
|
||||||
);
|
);
|
||||||
if (ctrls.length === 0)
|
if (ctrls.length === 0)
|
||||||
ctrls = root.querySelectorAll("input,select,textarea");
|
ctrls = root.querySelectorAll("input,select,textarea");
|
||||||
|
|
||||||
const all = Array.from(ctrls);
|
const all = Array.from(ctrls);
|
||||||
const radios = all.filter(
|
const radios = all.filter(
|
||||||
(el) => el instanceof HTMLInputElement && el.type === "radio",
|
(el) => el instanceof HTMLInputElement && el.type === "radio",
|
||||||
@@ -119,17 +150,13 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
|||||||
(el) =>
|
(el) =>
|
||||||
el instanceof HTMLInputElement && el.type === "checkbox",
|
el instanceof HTMLInputElement && el.type === "checkbox",
|
||||||
);
|
);
|
||||||
|
|
||||||
const err = root.querySelector('[id$="-err"]');
|
const err = root.querySelector('[id$="-err"]');
|
||||||
const isRadioGroup = radios.length > 1;
|
|
||||||
const isMultiCheckbox = checks.length > 1;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ctrls: all, // includes the readonly summary input if present
|
ctrls: all,
|
||||||
err,
|
err,
|
||||||
isRadioGroup,
|
isRadioGroup: radios.length > 1,
|
||||||
isMultiCheckbox,
|
isMultiCheckbox: checks.length > 1,
|
||||||
checkboxes: checks, // handy for multi-select
|
checkboxes: checks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +164,6 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
|||||||
const { ctrls, err } = fieldControls(root);
|
const { ctrls, err } = fieldControls(root);
|
||||||
if (!ctrls.length) return;
|
if (!ctrls.length) return;
|
||||||
const first = ctrls[0];
|
const first = ctrls[0];
|
||||||
|
|
||||||
if (msg) {
|
if (msg) {
|
||||||
if (showSuccesInput) first.classList.remove(...VALID_KLS);
|
if (showSuccesInput) first.classList.remove(...VALID_KLS);
|
||||||
first.classList.add(...INVALID_KLS);
|
first.classList.add(...INVALID_KLS);
|
||||||
@@ -156,7 +182,6 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearState(root) {
|
function clearState(root) {
|
||||||
const { ctrls, err } = fieldControls(root);
|
const { ctrls, err } = fieldControls(root);
|
||||||
ctrls.forEach((c) =>
|
ctrls.forEach((c) =>
|
||||||
@@ -169,19 +194,75 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Robust data collector (radios, checkboxes, multi-select, files)
|
function validateField(name) {
|
||||||
|
const root = form.querySelector(
|
||||||
|
`[data-field="${CSS.escape(name)}"]`,
|
||||||
|
);
|
||||||
|
if (!root) return;
|
||||||
|
const { ctrls, isRadioGroup, isMultiCheckbox, checkboxes } =
|
||||||
|
fieldControls(root);
|
||||||
|
if (!ctrls.length) return;
|
||||||
|
|
||||||
|
let value;
|
||||||
|
if (isRadioGroup) {
|
||||||
|
const chosen = ctrls.find(
|
||||||
|
(el) =>
|
||||||
|
el instanceof HTMLInputElement &&
|
||||||
|
el.type === "radio" &&
|
||||||
|
el.checked,
|
||||||
|
);
|
||||||
|
value = chosen ? chosen.value : "";
|
||||||
|
} else if (isMultiCheckbox) {
|
||||||
|
value = checkboxes
|
||||||
|
.filter((c) => c.checked)
|
||||||
|
.map((c) => c.value || "on");
|
||||||
|
} else {
|
||||||
|
const ctrl = ctrls[0];
|
||||||
|
if (
|
||||||
|
ctrl instanceof HTMLInputElement &&
|
||||||
|
ctrl.type === "checkbox"
|
||||||
|
)
|
||||||
|
value = ctrl.checked;
|
||||||
|
else if (
|
||||||
|
ctrl instanceof HTMLInputElement &&
|
||||||
|
ctrl.type === "file"
|
||||||
|
)
|
||||||
|
value = ctrl.multiple
|
||||||
|
? Array.from(ctrl.files || [])
|
||||||
|
: (ctrl.files?.[0] ?? null);
|
||||||
|
else if (ctrl instanceof HTMLSelectElement && ctrl.multiple)
|
||||||
|
value = Array.from(ctrl.selectedOptions).map(
|
||||||
|
(o) => o.value,
|
||||||
|
);
|
||||||
|
else value = ctrl.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = getSchema();
|
||||||
|
const obj = unwrapToObject(schema);
|
||||||
|
const shape = getObjectShape(obj);
|
||||||
|
const fieldSchema = shape?.[name];
|
||||||
|
if (!fieldSchema) return;
|
||||||
|
|
||||||
|
const res = fieldSchema.safeParse(value);
|
||||||
|
setError(
|
||||||
|
root,
|
||||||
|
res.success
|
||||||
|
? ""
|
||||||
|
: res.error.issues[0]?.message || "Invalid value",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function collectData(formEl) {
|
function collectData(formEl) {
|
||||||
const names = new Set(
|
const names = new Set(
|
||||||
Array.from(
|
Array.from(
|
||||||
formEl.querySelectorAll(
|
formEl.querySelectorAll(
|
||||||
"input[name], select[name], textarea[name]",
|
"input[name],select[name],textarea[name]",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.map((el) => el.getAttribute("name"))
|
.map((el) => el.getAttribute("name"))
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
);
|
);
|
||||||
const data = {};
|
const data = {};
|
||||||
|
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
const els = Array.from(
|
const els = Array.from(
|
||||||
formEl.querySelectorAll(`[name="${CSS.escape(name)}"]`),
|
formEl.querySelectorAll(`[name="${CSS.escape(name)}"]`),
|
||||||
@@ -195,38 +276,30 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
|||||||
el instanceof HTMLInputElement &&
|
el instanceof HTMLInputElement &&
|
||||||
el.type === "checkbox",
|
el.type === "checkbox",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (radios.length) {
|
if (radios.length) {
|
||||||
const chosen = radios.find((el) => el.checked);
|
const chosen = radios.find((el) => el.checked);
|
||||||
data[name] = chosen ? chosen.value : "";
|
data[name] = chosen ? chosen.value : "";
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checks.length) {
|
if (checks.length) {
|
||||||
if (checks.length > 1) {
|
data[name] =
|
||||||
data[name] = checks
|
checks.length > 1
|
||||||
.filter((el) => el.checked)
|
? checks
|
||||||
.map((el) => el.value || "on");
|
.filter((el) => el.checked)
|
||||||
} else {
|
.map((el) => el.value || "on")
|
||||||
data[name] = checks[0].checked;
|
: checks[0].checked;
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const el = els[0];
|
const el = els[0];
|
||||||
|
if (el instanceof HTMLSelectElement && el.multiple)
|
||||||
if (el instanceof HTMLSelectElement && el.multiple) {
|
|
||||||
data[name] = Array.from(el.selectedOptions).map(
|
data[name] = Array.from(el.selectedOptions).map(
|
||||||
(o) => o.value,
|
(o) => o.value,
|
||||||
);
|
);
|
||||||
} else if (
|
else if (el instanceof HTMLInputElement && el.type === "file")
|
||||||
el instanceof HTMLInputElement &&
|
|
||||||
el.type === "file"
|
|
||||||
) {
|
|
||||||
data[name] = el.multiple
|
data[name] = el.multiple
|
||||||
? Array.from(el.files || [])
|
? Array.from(el.files || [])
|
||||||
: (el.files?.[0] ?? null);
|
: (el.files?.[0] ?? null);
|
||||||
} else {
|
else {
|
||||||
const fd = new FormData(formEl);
|
const fd = new FormData(formEl);
|
||||||
data[name] = fd.get(name);
|
data[name] = fd.get(name);
|
||||||
}
|
}
|
||||||
@@ -250,126 +323,41 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
|||||||
setError(root, issue.message);
|
setError(root, issue.message);
|
||||||
if (!firstRoot) firstRoot = root;
|
if (!firstRoot) firstRoot = root;
|
||||||
}
|
}
|
||||||
if (firstRoot) {
|
if (firstRoot) fieldControls(firstRoot).ctrls[0]?.focus();
|
||||||
const { ctrls } = fieldControls(firstRoot);
|
|
||||||
ctrls[0]?.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateField(name) {
|
|
||||||
console.log(name);
|
|
||||||
|
|
||||||
const schema = getSchema();
|
|
||||||
|
|
||||||
console.log(schema);
|
|
||||||
console.log(schema.shape);
|
|
||||||
console.log(schema.shape[name]);
|
|
||||||
|
|
||||||
if (!schema || !schema.shape || !schema.shape[name]) return;
|
|
||||||
|
|
||||||
const root = form.querySelector(
|
|
||||||
`[data-field="${CSS.escape(name)}"]`,
|
|
||||||
);
|
|
||||||
console.log(root);
|
|
||||||
if (!root) return;
|
|
||||||
|
|
||||||
const { ctrls, isRadioGroup, isMultiCheckbox, checkboxes } =
|
|
||||||
fieldControls(root);
|
|
||||||
if (!ctrls.length) return;
|
|
||||||
|
|
||||||
let value;
|
|
||||||
|
|
||||||
if (isRadioGroup) {
|
|
||||||
// one-of
|
|
||||||
const chosen = ctrls.find(
|
|
||||||
(el) =>
|
|
||||||
el instanceof HTMLInputElement &&
|
|
||||||
el.type === "radio" &&
|
|
||||||
el.checked,
|
|
||||||
);
|
|
||||||
value = chosen ? chosen.value : "";
|
|
||||||
} else if (isMultiCheckbox) {
|
|
||||||
// array<string> for MultiSelect / checkbox groups
|
|
||||||
value = checkboxes
|
|
||||||
.filter((c) => c.checked)
|
|
||||||
.map((c) => c.value || "on");
|
|
||||||
} else {
|
|
||||||
// single control
|
|
||||||
const ctrl = ctrls[0];
|
|
||||||
if (
|
|
||||||
ctrl instanceof HTMLInputElement &&
|
|
||||||
ctrl.type === "checkbox"
|
|
||||||
) {
|
|
||||||
value = ctrl.checked;
|
|
||||||
} else if (
|
|
||||||
ctrl instanceof HTMLInputElement &&
|
|
||||||
ctrl.type === "file"
|
|
||||||
) {
|
|
||||||
value = ctrl.multiple
|
|
||||||
? Array.from(ctrl.files || [])
|
|
||||||
: (ctrl.files?.[0] ?? null);
|
|
||||||
} else if (ctrl instanceof HTMLSelectElement && ctrl.multiple) {
|
|
||||||
value = Array.from(ctrl.selectedOptions).map(
|
|
||||||
(o) => o.value,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
value = ctrl.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(value);
|
|
||||||
const res = schema.shape[name].safeParse(value);
|
|
||||||
console.log(res);
|
|
||||||
setError(
|
|
||||||
root,
|
|
||||||
res.success
|
|
||||||
? ""
|
|
||||||
: res.error.issues[0]?.message || "Invalid value",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateOn = (form.dataset.validateon || "input,blur,change")
|
const validateOn = (form.dataset.validateon || "input,blur,change")
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
if (validateOn.includes("input"))
|
||||||
if (validateOn.includes("input")) {
|
|
||||||
form.addEventListener("input", (e) => {
|
form.addEventListener("input", (e) => {
|
||||||
const root = e.target?.closest?.("[data-field]");
|
const r = e.target?.closest?.("[data-field]");
|
||||||
if (!root) return;
|
const n = r?.getAttribute("data-field");
|
||||||
const name = root.getAttribute("data-field");
|
if (n) validateField(n);
|
||||||
if (name) validateField(name);
|
|
||||||
});
|
});
|
||||||
}
|
if (validateOn.includes("blur"))
|
||||||
if (validateOn.includes("blur")) {
|
|
||||||
form.addEventListener(
|
form.addEventListener(
|
||||||
"blur",
|
"blur",
|
||||||
(e) => {
|
(e) => {
|
||||||
const root = e.target?.closest?.("[data-field]");
|
const r = e.target?.closest?.("[data-field]");
|
||||||
if (!root) return;
|
const n = r?.getAttribute("data-field");
|
||||||
const name = root.getAttribute("data-field");
|
if (n) validateField(n);
|
||||||
if (name) validateField(name);
|
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
}
|
if (validateOn.includes("change"))
|
||||||
if (validateOn.includes("change")) {
|
|
||||||
form.addEventListener("change", (e) => {
|
form.addEventListener("change", (e) => {
|
||||||
const root = e.target?.closest?.("[data-field]");
|
const r = e.target?.closest?.("[data-field]");
|
||||||
if (!root) return;
|
const n = r?.getAttribute("data-field");
|
||||||
const name = root.getAttribute("data-field");
|
if (n) validateField(n);
|
||||||
if (name) validateField(name);
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener("submit", async (e) => {
|
form.addEventListener("submit", async (e) => {
|
||||||
const schema = getSchema();
|
const schema = getSchema();
|
||||||
const onSubmitName = form.dataset.onsubmit;
|
const onSubmitName = form.dataset.onsubmit;
|
||||||
|
if (!schema && !onSubmitName) return;
|
||||||
if (!schema && !onSubmitName) return; // native submit
|
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (schema) {
|
if (schema) {
|
||||||
const data = collectData(form);
|
const data = collectData(form);
|
||||||
const result = schema.safeParse(data);
|
const result = schema.safeParse(data);
|
||||||
@@ -378,12 +366,10 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
form.querySelectorAll("[data-field]").forEach(clearState);
|
form.querySelectorAll("[data-field]").forEach(clearState);
|
||||||
|
|
||||||
if (!onSubmitName) {
|
if (!onSubmitName) {
|
||||||
form.submit(); // native submit after validation
|
form.submit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const btn = form.querySelector('button[type="submit"]');
|
const btn = form.querySelector('button[type="submit"]');
|
||||||
btn && (btn.disabled = true);
|
btn && (btn.disabled = true);
|
||||||
try {
|
try {
|
||||||
@@ -424,6 +410,5 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
|||||||
} else {
|
} else {
|
||||||
start();
|
start();
|
||||||
}
|
}
|
||||||
// For Astro client routing / view transitions
|
|
||||||
document.addEventListener("astro:page-load", start);
|
document.addEventListener("astro:page-load", start);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import Base from "../layouts/Base.astro";
|
|||||||
title="Create Project"
|
title="Create Project"
|
||||||
description="Fields marked * are required."
|
description="Fields marked * are required."
|
||||||
columns={2}
|
columns={2}
|
||||||
schemaName="projectSchema"
|
schemaName="schema"
|
||||||
onSubmit="handleProjectSubmit"
|
onSubmit="submit"
|
||||||
submitLabel="Create Project"
|
submitLabel="Create Project"
|
||||||
submitIcon="mdi:check"
|
submitIcon="mdi:check"
|
||||||
resetOnSubmit
|
resetOnSubmit
|
||||||
@@ -74,7 +74,7 @@ import Base from "../layouts/Base.astro";
|
|||||||
<script>
|
<script>
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
window.projectSchema = z.object({
|
window.schema = z.object({
|
||||||
name: z.string().min(3, "Please enter at least 3 characters."),
|
name: z.string().min(3, "Please enter at least 3 characters."),
|
||||||
email: z.string().email("Enter a valid email address."),
|
email: z.string().email("Enter a valid email address."),
|
||||||
teamSize: z.coerce
|
teamSize: z.coerce
|
||||||
@@ -95,7 +95,7 @@ import Base from "../layouts/Base.astro";
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
window.handleProjectSubmit = async (data: any, form: any) => {
|
window.submit = async (data: any, form: any) => {
|
||||||
console.log("Validated data:", data);
|
console.log("Validated data:", data);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user