Updated the FieldGroup and Field Issue
This commit is contained in:
+193
-46
@@ -2,60 +2,207 @@
|
||||
import type { HTMLAttributes } from "astro/types";
|
||||
import { cn } from "../utils/cn";
|
||||
|
||||
interface Props extends HTMLAttributes<"fieldset"> {
|
||||
label?: string;
|
||||
description?: string;
|
||||
error?: string;
|
||||
columns?: 1 | 2 | 3 | 4;
|
||||
gap?: "sm" | "md" | "lg";
|
||||
type Radius = "none" | "md" | "lg" | "full";
|
||||
type Layout = "flex" | "grid";
|
||||
|
||||
interface Props extends HTMLAttributes<"div"> {
|
||||
connected?: boolean; // join inputs (no gaps, shared borders) on lg+
|
||||
hideLabels?: boolean; // hide labels/desc when connected (lg+)
|
||||
rounded?: Radius; // outer radius for the whole group (lg+)
|
||||
equal?: boolean; // equal widths for items (lg+)
|
||||
layout?: Layout; // "flex" (default) or "grid"
|
||||
cols?: number; // columns on lg when layout="grid"
|
||||
}
|
||||
|
||||
const {
|
||||
label,
|
||||
description,
|
||||
error,
|
||||
columns = 2,
|
||||
gap = "md",
|
||||
class: className,
|
||||
...rest
|
||||
connected = true,
|
||||
hideLabels = true,
|
||||
rounded = "lg",
|
||||
equal = true,
|
||||
layout = "flex",
|
||||
cols,
|
||||
class: className,
|
||||
...rest
|
||||
}: Props = Astro.props;
|
||||
|
||||
const gridCols =
|
||||
columns === 4
|
||||
? "wr:grid-cols-1 sm:wr:grid-cols-4"
|
||||
: columns === 3
|
||||
? "wr:grid-cols-1 sm:wr:grid-cols-3"
|
||||
: columns === 2
|
||||
? "wr:grid-cols-1 sm:wr:grid-cols-2"
|
||||
: "wr:grid-cols-1";
|
||||
const radiusMap: Record<Radius, string> = {
|
||||
none: "0px",
|
||||
md: "0.375rem",
|
||||
lg: "0.5rem",
|
||||
full: "9999px",
|
||||
};
|
||||
|
||||
const gaps = gap === "lg" ? "wr:gap-4" : gap === "sm" ? "wr:gap-2" : "wr:gap-3";
|
||||
const groupId = `jr-${Math.random().toString(36).slice(2)}`;
|
||||
const lgCols = Math.max(1, Number(cols || 0));
|
||||
---
|
||||
|
||||
<fieldset class={cn("wr:space-y-2", className)} {...rest}>
|
||||
{
|
||||
label && (
|
||||
<legend class="wr:text-sm wr:font-medium wr:text-foreground">
|
||||
{label}
|
||||
</legend>
|
||||
)
|
||||
}
|
||||
<div
|
||||
id={groupId}
|
||||
class={cn(
|
||||
// Small screens: stack
|
||||
"wr:w-full",
|
||||
layout === "grid" ? "wr:grid wr:grid-cols-1 wr:gap-4" : "wr:flex wr:flex-col wr:gap-4",
|
||||
|
||||
{
|
||||
description && (
|
||||
<p class="wr:text-xs wr:text-muted-foreground">{description}</p>
|
||||
)
|
||||
}
|
||||
// Large screens: switch to inline + remove gaps if connected
|
||||
layout === "grid"
|
||||
? "wr:lg:grid wr:lg:grid-rows-1"
|
||||
: "wr:lg:flex wr:lg:flex-row wr:lg:items-stretch",
|
||||
connected ? "wr:lg:gap-0" : "wr:lg:gap-4",
|
||||
|
||||
<div class={cn("wr:grid", gridCols, gaps)}>
|
||||
<slot />
|
||||
</div>
|
||||
// Equal widths on lg
|
||||
equal && layout === "flex" && "wr:lg:[&>*]:wr:flex-1",
|
||||
className
|
||||
)}
|
||||
style={
|
||||
layout === "grid"
|
||||
? `--lg-cols:${lgCols || 0}; --join-radius:${radiusMap[rounded]}`
|
||||
: `--join-radius:${radiusMap[rounded]}`
|
||||
}
|
||||
{...(layout === "grid"
|
||||
? { "data-join-grid": "1" }
|
||||
: { "data-join-flex": "1" })}
|
||||
data-hide-labels={hideLabels ? "1" : ""}
|
||||
data-connected={connected ? "1" : ""}
|
||||
{...rest}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
{
|
||||
error && (
|
||||
<p class="wr:mt-1 wr:bg-red-100 wr:border wr:border-red-200 wr:text-sm wr:text-red-800 wr:rounded-lg wr:p-3 wr:dark:bg-red-800/10 wr:dark:border-red-900 wr:dark:text-red-500">
|
||||
{error}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
</fieldset>
|
||||
<style is:inline>
|
||||
/* ===========================================================
|
||||
Base: do NOT connect on small screens (stacked, normal inputs)
|
||||
=========================================================== */
|
||||
#{groupId} > [data-field] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ===========================================================
|
||||
Labels/desc: only hide on lg when requested
|
||||
=========================================================== */
|
||||
@media (min-width: 1024px) {
|
||||
#{groupId}[data-hide-labels="1"] > [data-field] > label,
|
||||
#{groupId}[data-hide-labels="1"] > [data-field] legend,
|
||||
#{groupId}[data-hide-labels="1"] > [data-field] [id$="-desc"] {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================================================
|
||||
FLEX layout joining — ONLY on lg+
|
||||
=========================================================== */
|
||||
@media (min-width: 1024px) {
|
||||
#{groupId}[data-connected="1"][data-join-flex="1"] > [data-field] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
#{groupId}[data-connected="1"][data-join-flex="1"] > [data-field] :is(
|
||||
input[data-control],
|
||||
select[data-control],
|
||||
textarea[data-control],
|
||||
[role="combobox"]
|
||||
) {
|
||||
border-radius: 0 !important;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* raise focus item above neighbors */
|
||||
#{groupId}[data-connected="1"][data-join-flex="1"] :is(
|
||||
input[data-control],
|
||||
select[data-control],
|
||||
textarea[data-control],
|
||||
[role="combobox"]
|
||||
):is(:hover, :focus, :focus-visible) {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* collapse inner borders */
|
||||
#{groupId}[data-connected="1"][data-join-flex="1"] > [data-field] + [data-field]
|
||||
:is(input[data-control], select[data-control], textarea[data-control], [role="combobox"]) {
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
/* outer corners only */
|
||||
#{groupId}[data-connected="1"][data-join-flex="1"] > [data-field]:first-of-type
|
||||
:is(input[data-control], select[data-control], textarea[data-control], [role="combobox"]) {
|
||||
border-top-left-radius: var(--join-radius) !important;
|
||||
border-bottom-left-radius: var(--join-radius) !important;
|
||||
}
|
||||
#{groupId}[data-connected="1"][data-join-flex="1"] > [data-field]:last-of-type
|
||||
:is(input[data-control], select[data-control], textarea[data-control], [role="combobox"]) {
|
||||
border-top-right-radius: var(--join-radius) !important;
|
||||
border-bottom-right-radius: var(--join-radius) !important;
|
||||
}
|
||||
|
||||
/* Optional: join a non-field control (e.g., button) as last item */
|
||||
#{groupId}[data-connected="1"][data-join-flex="1"] > .join-as-control { display: flex; align-items: stretch; }
|
||||
#{groupId}[data-connected="1"][data-join-flex="1"] > .join-as-control > * { border-radius: 0 !important; }
|
||||
#{groupId}[data-connected="1"][data-join-flex="1"] > .join-as-control:last-of-type > * {
|
||||
border-top-right-radius: var(--join-radius) !important;
|
||||
border-bottom-right-radius: var(--join-radius) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================================================
|
||||
GRID layout joining — ONLY on lg+
|
||||
=========================================================== */
|
||||
@media (min-width: 1024px) {
|
||||
/* equal columns on lg */
|
||||
#{groupId}[data-join-grid="1"] {
|
||||
grid-template-columns: repeat(var(--lg-cols, 2), minmax(0, 1fr));
|
||||
}
|
||||
|
||||
#{groupId}[data-connected="1"][data-join-grid="1"] > [data-field] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
#{groupId}[data-connected="1"][data-join-grid="1"] > [data-field] :is(
|
||||
input[data-control],
|
||||
select[data-control],
|
||||
textarea[data-control],
|
||||
[role="combobox"]
|
||||
) {
|
||||
border-radius: 0 !important;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
#{groupId}[data-connected="1"][data-join-grid="1"] :is(
|
||||
input[data-control],
|
||||
select[data-control],
|
||||
textarea[data-control],
|
||||
[role="combobox"]
|
||||
):is(:hover, :focus, :focus-visible) {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* remove inner left borders for joined neighbors */
|
||||
#{groupId}[data-connected="1"][data-join-grid="1"] > [data-field]:not(:first-child)
|
||||
:is(input[data-control], select[data-control], textarea[data-control], [role="combobox"]) {
|
||||
border-left-width: 0 !important;
|
||||
}
|
||||
|
||||
/* outer rounding */
|
||||
#{groupId}[data-connected="1"][data-join-grid="1"] > [data-field]:first-child
|
||||
:is(input[data-control], select[data-control], textarea[data-control], [role="combobox"]) {
|
||||
border-top-left-radius: var(--join-radius) !important;
|
||||
border-bottom-left-radius: var(--join-radius) !important;
|
||||
}
|
||||
#{groupId}[data-connected="1"][data-join-grid="1"] > [data-field]:last-child
|
||||
:is(input[data-control], select[data-control], textarea[data-control], [role="combobox"]) {
|
||||
border-top-right-radius: var(--join-radius) !important;
|
||||
border-bottom-right-radius: var(--join-radius) !important;
|
||||
}
|
||||
|
||||
/* Optional: non-field join at end */
|
||||
#{groupId}[data-connected="1"][data-join-grid="1"] > .join-as-control > * { border-radius: 0 !important; }
|
||||
#{groupId}[data-connected="1"][data-join-grid="1"] > .join-as-control:last-child > * {
|
||||
border-top-right-radius: var(--join-radius) !important;
|
||||
border-bottom-right-radius: var(--join-radius) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+234
-110
@@ -111,10 +111,10 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
||||
];
|
||||
|
||||
const form = document.getElementById(formId);
|
||||
|
||||
if (!form || form.dataset.__afInit) return;
|
||||
form.dataset.__afInit = "1";
|
||||
|
||||
/* ---------------- schema helpers ---------------- */
|
||||
function getSchema() {
|
||||
const name = form.dataset.schema;
|
||||
if (!name) return null;
|
||||
@@ -125,14 +125,16 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
return typeof val === "function" ? val() : val;
|
||||
}
|
||||
|
||||
function unwrapToObject(schema) {
|
||||
let cur = schema;
|
||||
while (cur && cur._def) {
|
||||
const t = cur._def.typeName;
|
||||
if (t === "ZodObject") return cur;
|
||||
function zUnwrap(s) {
|
||||
// peel Optional/Nullable/Default/Branded/Catch/Effects/Pipeline wrappers
|
||||
while (s && s._def) {
|
||||
const t = s._def.typeName;
|
||||
if (t === "ZodEffects") {
|
||||
cur = cur._def.schema;
|
||||
s = s._def.schema;
|
||||
continue;
|
||||
}
|
||||
if (t === "ZodPipeline") {
|
||||
s = s._def.out;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
@@ -142,23 +144,24 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
||||
t === "ZodBranded" ||
|
||||
t === "ZodCatch"
|
||||
) {
|
||||
cur = cur._def.innerType;
|
||||
continue;
|
||||
}
|
||||
if (t === "ZodPipeline") {
|
||||
cur = cur._def.out;
|
||||
s =
|
||||
s._def.innerType ||
|
||||
s._def.type ||
|
||||
s._def.schema ||
|
||||
s._def.inner;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
return s;
|
||||
}
|
||||
function getObjectShape(obj) {
|
||||
function zObjectShape(obj) {
|
||||
if (!obj) return null;
|
||||
const s = obj.shape ?? obj._def?.shape;
|
||||
return typeof s === "function" ? s() : s;
|
||||
}
|
||||
|
||||
/* ---------------- UI helpers ---------------- */
|
||||
function fieldControls(root) {
|
||||
let ctrls = root.querySelectorAll(
|
||||
"input[data-control],select[data-control],textarea[data-control]",
|
||||
@@ -182,7 +185,6 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
||||
checkboxes: checks,
|
||||
};
|
||||
}
|
||||
|
||||
function setError(root, msg) {
|
||||
const { ctrls, err } = fieldControls(root);
|
||||
if (!ctrls.length) return;
|
||||
@@ -217,6 +219,161 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- path & data helpers ---------------- */
|
||||
// "addresses[0].line1" -> ["addresses", 0, "line1"]; "tags[]" -> ["tags", {__push__:"tags"}]
|
||||
function parsePath(path) {
|
||||
const out = [];
|
||||
const re = /([^[.\]]+)|\[(\d*)\]/g;
|
||||
let m,
|
||||
lastKey = null;
|
||||
while ((m = re.exec(path))) {
|
||||
if (m[1]) {
|
||||
out.push(m[1]);
|
||||
lastKey = m[1];
|
||||
} else if (m[2] !== undefined) {
|
||||
out.push(
|
||||
m[2] === "" ? { __push__: lastKey } : Number(m[2]),
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function pathToAttr(pathArr) {
|
||||
// Zod path (["address","line1"]) -> "address.line1"
|
||||
// Zod path (["addresses",0,"line1"]) -> "addresses[0].line1"
|
||||
let out = "";
|
||||
for (const seg of pathArr || []) {
|
||||
if (typeof seg === "number") out += `[${seg}]`;
|
||||
else out += (out ? "." : "") + String(seg);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function setDeep(obj, name, value) {
|
||||
const tokens = parsePath(name.replace(/\[\]$/, ""));
|
||||
let cur = obj;
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const seg = tokens[i];
|
||||
const last = i === tokens.length - 1;
|
||||
if (typeof seg === "string") {
|
||||
if (last) {
|
||||
cur[seg] = value;
|
||||
break;
|
||||
}
|
||||
if (cur[seg] == null || typeof cur[seg] !== "object") {
|
||||
const next = tokens[i + 1];
|
||||
cur[seg] = typeof next === "number" ? [] : {};
|
||||
}
|
||||
cur = cur[seg];
|
||||
} else if (typeof seg === "number") {
|
||||
if (!Array.isArray(cur)) return; // malformed path
|
||||
if (last) {
|
||||
cur[seg] = value;
|
||||
break;
|
||||
}
|
||||
if (cur[seg] == null || typeof cur[seg] !== "object") {
|
||||
const next = tokens[i + 1];
|
||||
cur[seg] = typeof next === "number" ? [] : {};
|
||||
}
|
||||
cur = cur[seg];
|
||||
} else if (seg && seg.__push__) {
|
||||
const key = seg.__push__;
|
||||
if (!Array.isArray(cur[key])) cur[key] = [];
|
||||
if (last) {
|
||||
if (Array.isArray(value)) cur[key] = value.slice();
|
||||
else cur[key].push(value);
|
||||
} else {
|
||||
const nextObj = {};
|
||||
cur[key].push(nextObj);
|
||||
cur = nextObj;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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",
|
||||
);
|
||||
let value;
|
||||
if (radios.length) {
|
||||
const chosen = radios.find((el) => el.checked);
|
||||
value = chosen ? chosen.value : "";
|
||||
} else if (checks.length) {
|
||||
value =
|
||||
checks.length > 1
|
||||
? checks
|
||||
.filter((el) => el.checked)
|
||||
.map((el) => el.value || "on")
|
||||
: checks[0].checked;
|
||||
} else {
|
||||
const el = els[0];
|
||||
if (el instanceof HTMLSelectElement && el.multiple) {
|
||||
value = Array.from(el.selectedOptions).map(
|
||||
(o) => o.value,
|
||||
);
|
||||
} else if (
|
||||
el instanceof HTMLInputElement &&
|
||||
el.type === "file"
|
||||
) {
|
||||
value = el.multiple
|
||||
? Array.from(el.files || [])
|
||||
: (el.files?.[0] ?? null);
|
||||
} else {
|
||||
const fd = new FormData(formEl);
|
||||
value = fd.get(name);
|
||||
}
|
||||
}
|
||||
setDeep(data, name, value);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/* ---------------- resolve schema for a nested path ---------------- */
|
||||
function schemaForPath(rootSchema, name) {
|
||||
let cur = zUnwrap(rootSchema);
|
||||
const tokens = parsePath(name.replace(/\[\]$/, ""));
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const seg = tokens[i];
|
||||
cur = zUnwrap(cur);
|
||||
if (typeof seg === "string") {
|
||||
if (cur?._def?.typeName !== "ZodObject") return null;
|
||||
const shape = zObjectShape(cur);
|
||||
cur = shape?.[seg];
|
||||
if (!cur) return null;
|
||||
} else if (typeof seg === "number") {
|
||||
if (cur?._def?.typeName !== "ZodArray") return null;
|
||||
cur = cur._def.type; // element type
|
||||
} else if (seg && seg.__push__) {
|
||||
// treat as object key; array itself will be validated via full submit
|
||||
if (cur?._def?.typeName !== "ZodObject") return null;
|
||||
const shape = zObjectShape(cur);
|
||||
cur = shape?.[seg.__push__];
|
||||
if (!cur) return null;
|
||||
}
|
||||
}
|
||||
return zUnwrap(cur);
|
||||
}
|
||||
|
||||
/* ---------------- per-field validation by nested name ---------------- */
|
||||
function validateField(name) {
|
||||
const root = form.querySelector(
|
||||
`[data-field="${CSS.escape(name)}"]`,
|
||||
@@ -260,89 +417,64 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
||||
else value = ctrl.value;
|
||||
}
|
||||
|
||||
const schema = getSchema();
|
||||
const obj = unwrapToObject(schema);
|
||||
const shape = getObjectShape(obj);
|
||||
const fieldSchema = shape?.[name];
|
||||
if (!fieldSchema) return;
|
||||
|
||||
const sc = getSchema();
|
||||
const fieldSchema = sc ? schemaForPath(sc, name) : null;
|
||||
if (!fieldSchema?.safeParse) return;
|
||||
const res = fieldSchema.safeParse(value);
|
||||
setError(
|
||||
root,
|
||||
res.success
|
||||
? ""
|
||||
: res.error.issues[0]?.message || "Invalid value",
|
||||
: res.error?.issues?.[0]?.message || "Invalid value",
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
);
|
||||
if (radios.length) {
|
||||
const chosen = radios.find((el) => el.checked);
|
||||
data[name] = chosen ? chosen.value : "";
|
||||
continue;
|
||||
}
|
||||
if (checks.length) {
|
||||
data[name] =
|
||||
checks.length > 1
|
||||
? checks
|
||||
.filter((el) => el.checked)
|
||||
.map((el) => el.value || "on")
|
||||
: checks[0].checked;
|
||||
continue;
|
||||
}
|
||||
const el = els[0];
|
||||
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);
|
||||
}
|
||||
/* ---------------- showErrors: map nested paths ---------------- */
|
||||
const formErrEl = form.querySelector("[data-form-error]");
|
||||
function setFormError(msg) {
|
||||
if (!formErrEl) return;
|
||||
if (msg && String(msg).trim()) {
|
||||
formErrEl.textContent = String(msg);
|
||||
formErrEl.hidden = false;
|
||||
} else {
|
||||
formErrEl.textContent = "";
|
||||
formErrEl.hidden = true;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function findFieldRoot(formEl, baseKey) {
|
||||
return (
|
||||
formEl.querySelector(`[data-field="${CSS.escape(baseKey)}"]`) ||
|
||||
formEl.querySelector(
|
||||
`[data-field="${CSS.escape(baseKey)}[]"]`,
|
||||
) ||
|
||||
// last-resort: if you only marked inputs (no container), climb from a control
|
||||
(() => {
|
||||
const c =
|
||||
formEl.querySelector(
|
||||
`[name="${CSS.escape(baseKey)}"]`,
|
||||
) ||
|
||||
formEl.querySelector(
|
||||
`[name="${CSS.escape(baseKey)}[]"]`,
|
||||
);
|
||||
return c?.closest?.("[data-field]") || null;
|
||||
})()
|
||||
);
|
||||
}
|
||||
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])
|
||||
issue.path && issue.path.length
|
||||
? pathToAttr(issue.path)
|
||||
: null;
|
||||
if (!key) continue;
|
||||
const root = formEl.querySelector(
|
||||
`[data-field="${CSS.escape(key)}"]`,
|
||||
);
|
||||
|
||||
// try base, then base[] (important for arrays like interests[], skills[])
|
||||
const root = findFieldRoot(formEl, key);
|
||||
if (!root) continue;
|
||||
|
||||
setError(root, issue.message);
|
||||
if (!firstRoot) firstRoot = root;
|
||||
}
|
||||
@@ -355,17 +487,21 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
||||
if (firstRoot) fieldControls(firstRoot).ctrls[0]?.focus();
|
||||
}
|
||||
|
||||
/* ---------------- wire events ---------------- */
|
||||
const validateOn = (form.dataset.validateon || "input,blur,change")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (validateOn.includes("input"))
|
||||
|
||||
if (validateOn.includes("input")) {
|
||||
form.addEventListener("input", (e) => {
|
||||
const r = e.target?.closest?.("[data-field]");
|
||||
const n = r?.getAttribute("data-field");
|
||||
if (n) validateField(n);
|
||||
setFormError(""); // typing clears form error
|
||||
});
|
||||
if (validateOn.includes("blur"))
|
||||
}
|
||||
if (validateOn.includes("blur")) {
|
||||
form.addEventListener(
|
||||
"blur",
|
||||
(e) => {
|
||||
@@ -375,31 +511,38 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
||||
},
|
||||
true,
|
||||
);
|
||||
if (validateOn.includes("change"))
|
||||
}
|
||||
if (validateOn.includes("change")) {
|
||||
form.addEventListener("change", (e) => {
|
||||
const r = e.target?.closest?.("[data-field]");
|
||||
const n = r?.getAttribute("data-field");
|
||||
if (n) validateField(n);
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
const schema = getSchema();
|
||||
const onSubmitName = form.dataset.onsubmit;
|
||||
if (!schema && !onSubmitName) return;
|
||||
if (!schema && !onSubmitName) return; // native submit
|
||||
e.preventDefault();
|
||||
|
||||
if (schema) {
|
||||
const data = collectData(form);
|
||||
console.log(data);
|
||||
const result = schema.safeParse(data);
|
||||
console.log(result);
|
||||
if (!result.success) {
|
||||
showErrors(form, result.error);
|
||||
return;
|
||||
}
|
||||
setFormError("");
|
||||
form.querySelectorAll("[data-field]").forEach(clearState);
|
||||
|
||||
if (!onSubmitName) {
|
||||
form.submit();
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
btn && (btn.disabled = true);
|
||||
try {
|
||||
@@ -429,46 +572,27 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize textareas that carried SSR value via data-initial (avoid \n issues)
|
||||
form.querySelectorAll("textarea[data-initial]").forEach((t) => {
|
||||
t.value = t.getAttribute("data-initial") || "";
|
||||
t.removeAttribute("data-initial");
|
||||
});
|
||||
|
||||
// External error APIs you already added (works with nested keys like "address.line1")
|
||||
form.addEventListener("wr:set-errors", (e) => {
|
||||
const map = e.detail || {};
|
||||
if ("_form" in map) setFormError(map._form);
|
||||
|
||||
for (const [key, val] of Object.entries(map)) {
|
||||
if (key === "_form") continue;
|
||||
const root = form.querySelector(
|
||||
`[data-field="${CSS.escape(key)}"]`,
|
||||
);
|
||||
if (root) {
|
||||
setError(root, val);
|
||||
}
|
||||
if (root) setError(root, val);
|
||||
}
|
||||
});
|
||||
|
||||
const formErrEl = form.querySelector("[data-form-error]");
|
||||
|
||||
function setFormError(msg) {
|
||||
if (!formErrEl) return;
|
||||
if (msg && String(msg).trim()) {
|
||||
formErrEl.textContent = String(msg);
|
||||
formErrEl.hidden = false;
|
||||
} else {
|
||||
formErrEl.textContent = "";
|
||||
formErrEl.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener("input", () => setFormError(""));
|
||||
|
||||
form.addEventListener("wr:set-form-error", (e) => {
|
||||
const msg = e.detail;
|
||||
setFormError(msg);
|
||||
});
|
||||
|
||||
form.addEventListener("wr:set-form-error", (e) =>
|
||||
setFormError(e.detail),
|
||||
);
|
||||
form.addEventListener("wr:clear-form-error", () => setFormError(""));
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user