Fixxed bugs Form Validation
This commit is contained in:
+305
-276
@@ -83,308 +83,337 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
||||
</form>
|
||||
|
||||
<!-- Client validation & submit hook (no zod import here) -->
|
||||
<script type="module" define:vars={{ formId, showSuccesInput }}>
|
||||
const INVALID_KLS = ["!border-danger", "focus-visible:ring-danger/40"];
|
||||
const VALID_KLS = ["!border-success", "focus-visible:ring-success/40"];
|
||||
<script type="module" is:inline define:vars={{ formId, showSuccesInput }}>
|
||||
const start = () => {
|
||||
const INVALID_KLS = ["!border-danger", "focus-visible:ring-danger/40"];
|
||||
const VALID_KLS = ["!border-success", "focus-visible:ring-success/40"];
|
||||
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) throw new Error("Form element not found");
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) throw new Error("Form element not found");
|
||||
|
||||
function getSchema() {
|
||||
const name = form.dataset.schema;
|
||||
if (!name) return null;
|
||||
const val = window[name];
|
||||
if (!val) {
|
||||
console.warn("Schema not found:", name);
|
||||
return null;
|
||||
}
|
||||
return typeof val === "function" ? val() : val;
|
||||
}
|
||||
|
||||
// Prefer explicit [data-control]; fall back to any input/select/textarea
|
||||
function fieldControls(root) {
|
||||
// Prefer explicit data-control, fallback to any control
|
||||
let ctrls = root.querySelectorAll(
|
||||
"input[data-control], select[data-control], textarea[data-control]",
|
||||
);
|
||||
if (ctrls.length === 0)
|
||||
ctrls = root.querySelectorAll("input,select,textarea");
|
||||
|
||||
const all = Array.from(ctrls);
|
||||
const radios = all.filter(
|
||||
(el) => el instanceof HTMLInputElement && el.type === "radio",
|
||||
);
|
||||
const checks = all.filter(
|
||||
(el) => el instanceof HTMLInputElement && el.type === "checkbox",
|
||||
);
|
||||
|
||||
const err = root.querySelector('[id$="-err"]');
|
||||
const isRadioGroup = radios.length > 1;
|
||||
const isMultiCheckbox = checks.length > 1;
|
||||
|
||||
return {
|
||||
ctrls: all, // includes the readonly summary input if present
|
||||
err,
|
||||
isRadioGroup,
|
||||
isMultiCheckbox,
|
||||
checkboxes: checks, // handy for multi-select
|
||||
};
|
||||
}
|
||||
|
||||
function setError(root, msg) {
|
||||
const { ctrls, err } = fieldControls(root);
|
||||
if (!ctrls.length) return;
|
||||
const first = ctrls[0];
|
||||
|
||||
if (msg) {
|
||||
if (showSuccesInput) first.classList.remove(...VALID_KLS);
|
||||
first.classList.add(...INVALID_KLS);
|
||||
first.setAttribute("aria-invalid", "true");
|
||||
if (err) {
|
||||
err.textContent = msg;
|
||||
err.hidden = false;
|
||||
function getSchema() {
|
||||
const name = form.dataset.schema;
|
||||
if (!name) return null;
|
||||
const val = window[name];
|
||||
if (!val) {
|
||||
console.warn("Schema not found:", name);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
first.classList.remove(...INVALID_KLS);
|
||||
if (showSuccesInput) first.classList.add(...VALID_KLS);
|
||||
first.setAttribute("aria-invalid", "false");
|
||||
return typeof val === "function" ? val() : val;
|
||||
}
|
||||
|
||||
// Prefer explicit [data-control]; fall back to any input/select/textarea
|
||||
function fieldControls(root) {
|
||||
// Prefer explicit data-control, fallback to any control
|
||||
let ctrls = root.querySelectorAll(
|
||||
"input[data-control], select[data-control], textarea[data-control]",
|
||||
);
|
||||
if (ctrls.length === 0)
|
||||
ctrls = root.querySelectorAll("input,select,textarea");
|
||||
|
||||
const all = Array.from(ctrls);
|
||||
const radios = all.filter(
|
||||
(el) => el instanceof HTMLInputElement && el.type === "radio",
|
||||
);
|
||||
const checks = all.filter(
|
||||
(el) =>
|
||||
el instanceof HTMLInputElement && el.type === "checkbox",
|
||||
);
|
||||
|
||||
const err = root.querySelector('[id$="-err"]');
|
||||
const isRadioGroup = radios.length > 1;
|
||||
const isMultiCheckbox = checks.length > 1;
|
||||
|
||||
return {
|
||||
ctrls: all, // includes the readonly summary input if present
|
||||
err,
|
||||
isRadioGroup,
|
||||
isMultiCheckbox,
|
||||
checkboxes: checks, // handy for multi-select
|
||||
};
|
||||
}
|
||||
|
||||
function setError(root, msg) {
|
||||
const { ctrls, err } = fieldControls(root);
|
||||
if (!ctrls.length) return;
|
||||
const first = ctrls[0];
|
||||
|
||||
if (msg) {
|
||||
if (showSuccesInput) first.classList.remove(...VALID_KLS);
|
||||
first.classList.add(...INVALID_KLS);
|
||||
first.setAttribute("aria-invalid", "true");
|
||||
if (err) {
|
||||
err.textContent = msg;
|
||||
err.hidden = false;
|
||||
}
|
||||
} else {
|
||||
first.classList.remove(...INVALID_KLS);
|
||||
if (showSuccesInput) first.classList.add(...VALID_KLS);
|
||||
first.setAttribute("aria-invalid", "false");
|
||||
if (err) {
|
||||
err.textContent = "";
|
||||
err.hidden = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearState(root) {
|
||||
const { ctrls, err } = fieldControls(root);
|
||||
ctrls.forEach((c) =>
|
||||
c.classList.remove(...INVALID_KLS, ...VALID_KLS),
|
||||
);
|
||||
ctrls[0]?.removeAttribute("aria-invalid");
|
||||
if (err) {
|
||||
err.textContent = "";
|
||||
err.hidden = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearState(root) {
|
||||
const { ctrls, err } = fieldControls(root);
|
||||
ctrls.forEach((c) => c.classList.remove(...INVALID_KLS, ...VALID_KLS));
|
||||
ctrls[0]?.removeAttribute("aria-invalid");
|
||||
if (err) {
|
||||
err.textContent = "";
|
||||
err.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Robust data collector (radios, checkboxes, multi-select, files)
|
||||
function collectData(formEl) {
|
||||
const names = new Set(
|
||||
Array.from(
|
||||
formEl.querySelectorAll(
|
||||
"input[name], select[name], textarea[name]",
|
||||
),
|
||||
)
|
||||
.map((el) => el.getAttribute("name"))
|
||||
.filter(Boolean),
|
||||
);
|
||||
const data = {};
|
||||
|
||||
for (const name of names) {
|
||||
const els = Array.from(
|
||||
formEl.querySelectorAll(`[name="${CSS.escape(name)}"]`),
|
||||
);
|
||||
const radios = els.filter(
|
||||
(el) => el instanceof HTMLInputElement && el.type === "radio",
|
||||
);
|
||||
const checks = els.filter(
|
||||
(el) =>
|
||||
el instanceof HTMLInputElement && el.type === "checkbox",
|
||||
// Robust data collector (radios, checkboxes, multi-select, files)
|
||||
function collectData(formEl) {
|
||||
const names = new Set(
|
||||
Array.from(
|
||||
formEl.querySelectorAll(
|
||||
"input[name], select[name], textarea[name]",
|
||||
),
|
||||
)
|
||||
.map((el) => el.getAttribute("name"))
|
||||
.filter(Boolean),
|
||||
);
|
||||
const data = {};
|
||||
|
||||
if (radios.length) {
|
||||
const chosen = radios.find((el) => el.checked);
|
||||
data[name] = chosen ? chosen.value : "";
|
||||
continue;
|
||||
}
|
||||
for (const name of names) {
|
||||
const els = Array.from(
|
||||
formEl.querySelectorAll(`[name="${CSS.escape(name)}"]`),
|
||||
);
|
||||
const radios = els.filter(
|
||||
(el) =>
|
||||
el instanceof HTMLInputElement && el.type === "radio",
|
||||
);
|
||||
const checks = els.filter(
|
||||
(el) =>
|
||||
el instanceof HTMLInputElement &&
|
||||
el.type === "checkbox",
|
||||
);
|
||||
|
||||
if (checks.length) {
|
||||
if (checks.length > 1) {
|
||||
data[name] = checks
|
||||
.filter((el) => el.checked)
|
||||
.map((el) => el.value || "on");
|
||||
} else {
|
||||
data[name] = checks[0].checked;
|
||||
if (radios.length) {
|
||||
const chosen = radios.find((el) => el.checked);
|
||||
data[name] = chosen ? chosen.value : "";
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const el = els[0];
|
||||
if (checks.length) {
|
||||
if (checks.length > 1) {
|
||||
data[name] = checks
|
||||
.filter((el) => el.checked)
|
||||
.map((el) => el.value || "on");
|
||||
} else {
|
||||
data[name] = checks[0].checked;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (el instanceof HTMLSelectElement && el.multiple) {
|
||||
data[name] = Array.from(el.selectedOptions).map((o) => o.value);
|
||||
} else if (el instanceof HTMLInputElement && el.type === "file") {
|
||||
data[name] = el.multiple
|
||||
? Array.from(el.files || [])
|
||||
: (el.files?.[0] ?? null);
|
||||
} else {
|
||||
const fd = new FormData(formEl);
|
||||
data[name] = fd.get(name);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
const el = els[0];
|
||||
|
||||
function showErrors(formEl, zodError) {
|
||||
formEl.querySelectorAll("[data-field]").forEach(clearState);
|
||||
let firstRoot = null;
|
||||
for (const issue of zodError.issues ?? []) {
|
||||
const key =
|
||||
Array.isArray(issue.path) && issue.path.length
|
||||
? String(issue.path[0])
|
||||
: null;
|
||||
if (!key) continue;
|
||||
const root = formEl.querySelector(
|
||||
`[data-field="${CSS.escape(key)}"]`,
|
||||
);
|
||||
if (!root) continue;
|
||||
setError(root, issue.message);
|
||||
if (!firstRoot) firstRoot = root;
|
||||
}
|
||||
if (firstRoot) {
|
||||
const { ctrls } = fieldControls(firstRoot);
|
||||
ctrls[0]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function validateField(name) {
|
||||
const schema = getSchema();
|
||||
if (!schema || !schema.shape || !schema.shape[name]) return;
|
||||
|
||||
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) {
|
||||
// one-of
|
||||
const chosen = ctrls.find(
|
||||
(el) =>
|
||||
if (el instanceof HTMLSelectElement && el.multiple) {
|
||||
data[name] = Array.from(el.selectedOptions).map(
|
||||
(o) => o.value,
|
||||
);
|
||||
} else if (
|
||||
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;
|
||||
el.type === "file"
|
||||
) {
|
||||
data[name] = el.multiple
|
||||
? Array.from(el.files || [])
|
||||
: (el.files?.[0] ?? null);
|
||||
} else {
|
||||
const fd = new FormData(formEl);
|
||||
data[name] = fd.get(name);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function showErrors(formEl, zodError) {
|
||||
formEl.querySelectorAll("[data-field]").forEach(clearState);
|
||||
let firstRoot = null;
|
||||
for (const issue of zodError.issues ?? []) {
|
||||
const key =
|
||||
Array.isArray(issue.path) && issue.path.length
|
||||
? String(issue.path[0])
|
||||
: null;
|
||||
if (!key) continue;
|
||||
const root = formEl.querySelector(
|
||||
`[data-field="${CSS.escape(key)}"]`,
|
||||
);
|
||||
if (!root) continue;
|
||||
setError(root, issue.message);
|
||||
if (!firstRoot) firstRoot = root;
|
||||
}
|
||||
if (firstRoot) {
|
||||
const { ctrls } = fieldControls(firstRoot);
|
||||
ctrls[0]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const res = schema.shape[name].safeParse(value);
|
||||
setError(
|
||||
root,
|
||||
res.success ? "" : res.error.issues[0]?.message || "Invalid value",
|
||||
);
|
||||
}
|
||||
function validateField(name) {
|
||||
const schema = getSchema();
|
||||
if (!schema || !schema.shape || !schema.shape[name]) return;
|
||||
|
||||
const validateOn = (form.dataset.validateon || "input,blur,change")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (validateOn.includes("input")) {
|
||||
form.addEventListener("input", (e) => {
|
||||
const root = e.target?.closest?.("[data-field]");
|
||||
const root = form.querySelector(
|
||||
`[data-field="${CSS.escape(name)}"]`,
|
||||
);
|
||||
if (!root) return;
|
||||
const name = root.getAttribute("data-field");
|
||||
if (name) validateField(name);
|
||||
});
|
||||
}
|
||||
if (validateOn.includes("blur")) {
|
||||
form.addEventListener(
|
||||
"blur",
|
||||
(e) => {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const res = schema.shape[name].safeParse(value);
|
||||
setError(
|
||||
root,
|
||||
res.success
|
||||
? ""
|
||||
: res.error.issues[0]?.message || "Invalid value",
|
||||
);
|
||||
}
|
||||
|
||||
const validateOn = (form.dataset.validateon || "input,blur,change")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (validateOn.includes("input")) {
|
||||
form.addEventListener("input", (e) => {
|
||||
const root = e.target?.closest?.("[data-field]");
|
||||
if (!root) return;
|
||||
const name = root.getAttribute("data-field");
|
||||
if (name) validateField(name);
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
if (validateOn.includes("change")) {
|
||||
form.addEventListener("change", (e) => {
|
||||
const root = e.target?.closest?.("[data-field]");
|
||||
if (!root) return;
|
||||
const name = root.getAttribute("data-field");
|
||||
if (name) validateField(name);
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
const schema = getSchema();
|
||||
const onSubmitName = form.dataset.onsubmit;
|
||||
|
||||
if (!schema && !onSubmitName) return; // native submit
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (schema) {
|
||||
const data = collectData(form);
|
||||
const result = schema.safeParse(data);
|
||||
if (!result.success) {
|
||||
showErrors(form, result.error);
|
||||
return;
|
||||
}
|
||||
form.querySelectorAll("[data-field]").forEach(clearState);
|
||||
|
||||
if (!onSubmitName) {
|
||||
form.submit(); // native submit after validation
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
btn && (btn.disabled = true);
|
||||
try {
|
||||
const fn = window[onSubmitName];
|
||||
const res =
|
||||
typeof fn === "function"
|
||||
? await fn(result.data, form)
|
||||
: null;
|
||||
if (res !== false && form.dataset.reset) form.reset();
|
||||
} catch (err) {
|
||||
console.error("onSubmit error:", err);
|
||||
} finally {
|
||||
btn && (btn.disabled = false);
|
||||
}
|
||||
} else if (onSubmitName) {
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
btn && (btn.disabled = true);
|
||||
try {
|
||||
const data = collectData(form);
|
||||
const fn = window[onSubmitName];
|
||||
const res =
|
||||
typeof fn === "function" ? await fn(data, form) : null;
|
||||
if (res !== false && form.dataset.reset) form.reset();
|
||||
} finally {
|
||||
btn && (btn.disabled = false);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (validateOn.includes("blur")) {
|
||||
form.addEventListener(
|
||||
"blur",
|
||||
(e) => {
|
||||
const root = e.target?.closest?.("[data-field]");
|
||||
if (!root) return;
|
||||
const name = root.getAttribute("data-field");
|
||||
if (name) validateField(name);
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
if (validateOn.includes("change")) {
|
||||
form.addEventListener("change", (e) => {
|
||||
const root = e.target?.closest?.("[data-field]");
|
||||
if (!root) return;
|
||||
const name = root.getAttribute("data-field");
|
||||
if (name) validateField(name);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
form.querySelectorAll("textarea[data-initial]").forEach((t) => {
|
||||
t.value = t.getAttribute("data-initial") || "";
|
||||
t.removeAttribute("data-initial");
|
||||
});
|
||||
form.addEventListener("submit", async (e) => {
|
||||
const schema = getSchema();
|
||||
const onSubmitName = form.dataset.onsubmit;
|
||||
|
||||
if (!schema && !onSubmitName) return; // native submit
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (schema) {
|
||||
const data = collectData(form);
|
||||
const result = schema.safeParse(data);
|
||||
if (!result.success) {
|
||||
showErrors(form, result.error);
|
||||
return;
|
||||
}
|
||||
form.querySelectorAll("[data-field]").forEach(clearState);
|
||||
|
||||
if (!onSubmitName) {
|
||||
form.submit(); // native submit after validation
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
btn && (btn.disabled = true);
|
||||
try {
|
||||
const fn = window[onSubmitName];
|
||||
const res =
|
||||
typeof fn === "function"
|
||||
? await fn(result.data, form)
|
||||
: null;
|
||||
if (res !== false && form.dataset.reset) form.reset();
|
||||
} catch (err) {
|
||||
console.error("onSubmit error:", err);
|
||||
} finally {
|
||||
btn && (btn.disabled = false);
|
||||
}
|
||||
} else if (onSubmitName) {
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
btn && (btn.disabled = true);
|
||||
try {
|
||||
const data = collectData(form);
|
||||
const fn = window[onSubmitName];
|
||||
const res =
|
||||
typeof fn === "function" ? await fn(data, form) : null;
|
||||
if (res !== false && form.dataset.reset) form.reset();
|
||||
} finally {
|
||||
btn && (btn.disabled = false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
form.querySelectorAll("textarea[data-initial]").forEach((t) => {
|
||||
t.value = t.getAttribute("data-initial") || "";
|
||||
t.removeAttribute("data-initial");
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", start, { once: true });
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
// For Astro client routing / view transitions
|
||||
document.addEventListener("astro:page-load", start);
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user