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(""));
|
||||
};
|
||||
|
||||
|
||||
@@ -32,6 +32,12 @@ import Base from "./Base.astro";
|
||||
<li class="list-item">
|
||||
<a href="/forms2">Forms 2</a>
|
||||
</li>
|
||||
<li class="list-item">
|
||||
<a href="/forms3">Forms 3</a>
|
||||
</li>
|
||||
<li class="list-item">
|
||||
<a href="/forms4">Forms 4</a>
|
||||
</li>
|
||||
<li class="list-item">
|
||||
<a href="/theme">Theme</a>
|
||||
</li>
|
||||
|
||||
@@ -18,7 +18,7 @@ import ComponentLayout from "../layouts/ComponentLayout.astro";
|
||||
<Field
|
||||
name="name"
|
||||
label="Project name"
|
||||
value="Test"
|
||||
value="Te"
|
||||
required
|
||||
placeholder="Acme CRM"
|
||||
prefixText="Prefix"
|
||||
|
||||
@@ -205,7 +205,7 @@ import ComponentLayout from "../layouts/ComponentLayout.astro";
|
||||
.number()
|
||||
.positive("Enter a positive number.")
|
||||
.optional(),
|
||||
resume: resumeSchema,
|
||||
// resume: resumeSchema,
|
||||
coverLetter: z
|
||||
.string()
|
||||
.max(1000, "Keep it under 1000 characters.")
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
---
|
||||
import Form from "../components/Form.astro";
|
||||
import Field from "../components/Field.astro";
|
||||
import ComponentLayout from "../layouts/ComponentLayout.astro";
|
||||
import MultiSelect from "../components/MultiSelect.astro";
|
||||
---
|
||||
|
||||
<ComponentLayout class="wr:p-4">
|
||||
<Form
|
||||
title="Create Account"
|
||||
description="Shows nested paths, groups, arrays & server errors."
|
||||
schemaName="schema"
|
||||
onSubmit="submit"
|
||||
resetOnSubmit={true}
|
||||
validateOn="input,blur,change"
|
||||
showSuccesInput={true}
|
||||
class="wr:max-w-3xl wr:mx-auto wr:space-y-6"
|
||||
>
|
||||
<!-- Row: name group (2 fields) -->
|
||||
<div class="wr:col-span-2 wr:grid wr:grid-cols-2 wr:gap-4">
|
||||
<Field name="user.firstName" label="First name" placeholder="Ada" />
|
||||
<Field
|
||||
name="user.lastName"
|
||||
label="Last name"
|
||||
placeholder="Lovelace"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Row: email + phone -->
|
||||
<Field
|
||||
name="email"
|
||||
label="Email"
|
||||
kind="email"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
<Field
|
||||
name="phone"
|
||||
label="Phone"
|
||||
kind="text"
|
||||
placeholder="+1 555 0100"
|
||||
/>
|
||||
|
||||
<!-- Row: address group (nested object) -->
|
||||
<div class="wr:col-span-2 wr:grid wr:grid-cols-3 wr:gap-4">
|
||||
<Field
|
||||
name="address.line1"
|
||||
label="Address line 1"
|
||||
placeholder="123 Any St"
|
||||
/>
|
||||
<Field
|
||||
name="address.line2"
|
||||
label="Address line 2"
|
||||
placeholder="Suite / Apt"
|
||||
/>
|
||||
<Field name="address.pincode" label="Pincode" placeholder="94105" />
|
||||
</div>
|
||||
|
||||
<!-- Array of objects (two addresses) -->
|
||||
<div
|
||||
class="wr:col-span-2 wr:rounded-lg wr:border wr:border-border wr:p-4 wr:space-y-3"
|
||||
>
|
||||
<div class="wr:text-sm wr:font-medium">
|
||||
Additional addresses (array of objects)
|
||||
</div>
|
||||
<div class="wr:grid wr:grid-cols-3 wr:gap-3">
|
||||
<Field name="addresses[0].line1" label="Line 1" />
|
||||
<Field name="addresses[0].line2" label="Line 2" />
|
||||
<Field name="addresses[0].pincode" label="Pincode" />
|
||||
</div>
|
||||
<div class="wr:grid wr:grid-cols-3 wr:gap-3">
|
||||
<Field name="addresses[1].line1" label="Line 1" />
|
||||
<Field name="addresses[1].line2" label="Line 2" />
|
||||
<Field name="addresses[1].pincode" label="Pincode" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mixed group: select + text -->
|
||||
<div class="wr:grid wr:grid-cols-2 wr:gap-4">
|
||||
<Field
|
||||
name="contact.method"
|
||||
label="Preferred contact"
|
||||
kind="select"
|
||||
options={[
|
||||
{ value: "email", label: "Email" },
|
||||
{ value: "sms", label: "SMS" },
|
||||
{ value: "phone", label: "Phone call" },
|
||||
]}
|
||||
placeholder="Choose a method"
|
||||
/>
|
||||
<Field
|
||||
name="contact.handle"
|
||||
label="Handle / Number"
|
||||
placeholder="@you or +1 555…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Multi checkbox group (array of strings) -->
|
||||
<div
|
||||
class="wr:rounded-lg wr:border wr:border-border wr:p-4"
|
||||
data-field="interests[]"
|
||||
>
|
||||
<div class="wr:text-sm wr:font-medium wr:mb-2">
|
||||
Interests (checkbox array)
|
||||
</div>
|
||||
<div class="wr:flex wr:flex-wrap wr:gap-4">
|
||||
<label class="wr:inline-flex wr:items-center wr:gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="interests[]"
|
||||
value="ai"
|
||||
data-control
|
||||
/>
|
||||
<span class="wr:text-sm">AI</span>
|
||||
</label>
|
||||
<label class="wr:inline-flex wr:items-center wr:gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="interests[]"
|
||||
value="design"
|
||||
data-control
|
||||
/>
|
||||
<span class="wr:text-sm">Design</span>
|
||||
</label>
|
||||
<label class="wr:inline-flex wr:items-center wr:gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="interests[]"
|
||||
value="security"
|
||||
data-control
|
||||
/>
|
||||
<span class="wr:text-sm">Security</span>
|
||||
</label>
|
||||
</div>
|
||||
<p
|
||||
id="interests-err"
|
||||
class="wr:text-xs wr:text-danger wr:mt-1"
|
||||
hidden
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- MultiSelect (also array of strings) -->
|
||||
<MultiSelect
|
||||
name="skills[]"
|
||||
label="Skills (MultiSelect)"
|
||||
placeholder="Pick your skills"
|
||||
options={[
|
||||
{ value: "ts", label: "TypeScript" },
|
||||
{ value: "go", label: "Go" },
|
||||
{ value: "rust", label: "Rust" },
|
||||
{ value: "design", label: "Design" },
|
||||
]}
|
||||
clearable={true}
|
||||
searchable={true}
|
||||
maxSelections={3}
|
||||
display="chips"
|
||||
/>
|
||||
|
||||
<!-- Radio group (plan) -->
|
||||
<div
|
||||
class="wr:rounded-lg wr:border wr:border-border wr:p-4"
|
||||
data-field="plan"
|
||||
>
|
||||
<div class="wr:text-sm wr:font-medium wr:mb-2">Plan</div>
|
||||
<div class="wr:flex wr:gap-4">
|
||||
<label class="wr:inline-flex wr:items-center wr:gap-2">
|
||||
<input type="radio" name="plan" value="free" data-control />
|
||||
<span class="wr:text-sm">Free</span>
|
||||
</label>
|
||||
<label class="wr:inline-flex wr:items-center wr:gap-2">
|
||||
<input type="radio" name="plan" value="pro" data-control />
|
||||
<span class="wr:text-sm">Pro</span>
|
||||
</label>
|
||||
<label class="wr:inline-flex wr:items-center wr:gap-2">
|
||||
<input type="radio" name="plan" value="team" data-control />
|
||||
<span class="wr:text-sm">Team</span>
|
||||
</label>
|
||||
</div>
|
||||
<p id="plan-err" class="wr:text-xs wr:text-danger wr:mt-1" hidden>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- File (single) -->
|
||||
<Field name="resume" label="Resume (PDF)" kind="file" />
|
||||
|
||||
<!-- Extra actions -->
|
||||
<div slot="actions" class="wr:flex wr:items-center wr:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="wr:text-sm wr:text-muted-foreground wr:hover:underline"
|
||||
onclick={`document.getElementById('${"f-6q84jp4q8p7"}').dispatchEvent(new CustomEvent('wr:clear-form-error', {bubbles:true}))`}
|
||||
>
|
||||
Clear form error
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
</ComponentLayout>
|
||||
|
||||
<script>
|
||||
import { z } from "zod";
|
||||
|
||||
window.schema = z.object({
|
||||
user: z.object({
|
||||
firstName: z.string().min(1, "First name is required"),
|
||||
lastName: z.string().min(1, "Last name is required"),
|
||||
}),
|
||||
email: z.string().email("Invalid email"),
|
||||
phone: z.string().min(7, "Invalid phone"),
|
||||
address: z.object({
|
||||
line1: z.string().min(1, "Address line 1 is required"),
|
||||
line2: z.string().optional(),
|
||||
pincode: z.string().min(4, "Pincode must be 4+ chars"),
|
||||
}),
|
||||
addresses: z
|
||||
.array(
|
||||
z.object({
|
||||
line1: z
|
||||
.string()
|
||||
.min(1, "Line 1 required")
|
||||
.optional()
|
||||
.or(z.literal(""))
|
||||
.transform((v) => v ?? ""),
|
||||
line2: z
|
||||
.string()
|
||||
.optional()
|
||||
.or(z.literal(""))
|
||||
.transform((v) => v ?? ""),
|
||||
pincode: z
|
||||
.string()
|
||||
.optional()
|
||||
.or(z.literal(""))
|
||||
.transform((v) => v ?? ""),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
contact: z.object({
|
||||
method: z.enum(["email", "sms", "phone"], {
|
||||
message: "Pick a contact method",
|
||||
}),
|
||||
handle: z.string().min(2, "Handle/number required"),
|
||||
}),
|
||||
interests: z.array(z.string()).min(1, "Pick at least one interest"),
|
||||
skills: z.array(z.string()).min(1, "Pick at least one skill"),
|
||||
plan: z.enum(["free", "pro", "team"], { message: "Choose a plan" }),
|
||||
// resume: z
|
||||
// .any()
|
||||
// .refine((f) => !f || f instanceof File, "Invalid file")
|
||||
// .refine(
|
||||
// (f) =>
|
||||
// !f ||
|
||||
// (f.type === "application/pdf" && f.size <= 2 * 1024 * 1024),
|
||||
// "PDF only, max 2MB",
|
||||
// )
|
||||
// .optional(),
|
||||
});
|
||||
|
||||
window.submit = async (data: any, form: any) => {
|
||||
if (String(data.email).includes("fail")) {
|
||||
// Map server errors to fields (nested ok) + form-level error
|
||||
const serverErrors = {
|
||||
_form: "We couldn’t create your account. Try again.",
|
||||
"user.firstName": "Looks taken — try a different first name",
|
||||
"address.pincode": "Service not available for this area",
|
||||
};
|
||||
form.dispatchEvent(
|
||||
new CustomEvent("wr:set-errors", {
|
||||
detail: serverErrors,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
return false; // DON'T reset on failure
|
||||
}
|
||||
|
||||
// Pretend success
|
||||
console.log("SUBMIT DATA:", data);
|
||||
alert("Success! Form will reset.");
|
||||
// allow reset (Form has data-reset)
|
||||
// return; // (undefined) -> resets
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,270 @@
|
||||
---
|
||||
import Form from "../components/Form.astro";
|
||||
import Field from "../components/Field.astro";
|
||||
import FieldRow from "../components/FieldRow.astro";
|
||||
import ComponentLayout from "../layouts/ComponentLayout.astro";
|
||||
import MultiSelect from "../components/MultiSelect.astro";
|
||||
|
||||
// simple option lists
|
||||
const INTERESTS = [
|
||||
{ value: "sports", label: "Sports" },
|
||||
{ value: "music", label: "Music" },
|
||||
{ value: "coding", label: "Coding" },
|
||||
{ value: "travel", label: "Travel" },
|
||||
];
|
||||
|
||||
const SKILLS = [
|
||||
{ value: "js", label: "JavaScript" },
|
||||
{ value: "ts", label: "TypeScript" },
|
||||
{ value: "react", label: "React" },
|
||||
{ value: "node", label: "Node.js" },
|
||||
];
|
||||
|
||||
const PLANS = [
|
||||
{ value: "free", label: "Free" },
|
||||
{ value: "pro", label: "Pro" },
|
||||
{ value: "business", label: "Business" },
|
||||
];
|
||||
---
|
||||
|
||||
<ComponentLayout class="wr:p-4">
|
||||
<section class="wr:max-w-3xl wr:mx-auto wr:py-10 wr:space-y-8">
|
||||
<h1 class="wr:text-2xl wr:font-semibold">Create Account</h1>
|
||||
|
||||
<Form
|
||||
title="Account details"
|
||||
description="Fill in your basic information to get started."
|
||||
schemaName="schema"
|
||||
onSubmit="submit"
|
||||
columns={1}
|
||||
actionsAlign="end"
|
||||
resetOnSubmit={true}
|
||||
validateOn="input,blur,change"
|
||||
showSuccesInput={true}
|
||||
>
|
||||
<!-- Row: name -->
|
||||
<FieldRow layout="grid" cols={2} connected rounded="lg" hideLabels>
|
||||
<Field name="firstName" label="First name" placeholder="Jane" />
|
||||
<Field name="lastName" label="Last name" placeholder="Doe" />
|
||||
</FieldRow>
|
||||
|
||||
<!-- Row: email / phone -->
|
||||
<FieldRow layout="grid" cols={2} connected rounded="lg" hideLabels>
|
||||
<Field
|
||||
name="email"
|
||||
label="Email"
|
||||
kind="email"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
<Field
|
||||
name="phone"
|
||||
label="Phone"
|
||||
kind="text"
|
||||
placeholder="+1 555 123 4567"
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
<!-- Row: password / confirm -->
|
||||
<FieldRow layout="grid" cols={2} connected rounded="lg" hideLabels>
|
||||
<Field
|
||||
name="password"
|
||||
label="Password"
|
||||
kind="password"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<Field
|
||||
name="confirmPassword"
|
||||
label="Confirm password"
|
||||
kind="password"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
<!-- Address (nested object) -->
|
||||
<FieldRow
|
||||
title="Address"
|
||||
layout="grid"
|
||||
cols={4}
|
||||
connected
|
||||
rounded="lg"
|
||||
hideLabels
|
||||
>
|
||||
<Field
|
||||
name="address.line1"
|
||||
label="Address line 1"
|
||||
placeholder="123 Main St"
|
||||
/>
|
||||
<Field
|
||||
name="address.line2"
|
||||
label="Address line 2"
|
||||
placeholder="Apt 4B"
|
||||
/>
|
||||
<Field
|
||||
name="address.city"
|
||||
label="City"
|
||||
placeholder="San Francisco"
|
||||
/>
|
||||
<Field
|
||||
name="address.pincode"
|
||||
label="Postal / ZIP"
|
||||
placeholder="94105"
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
<!-- Arrays -->
|
||||
<FieldRow layout="grid" cols={2} connected rounded="lg" hideLabels>
|
||||
<!-- Interests as checkbox group (array<string>) -->
|
||||
<div data-field="interests" class="wr:space-y-2">
|
||||
<label class="wr:text-sm wr:font-medium">Interests</label>
|
||||
<div class="wr:grid wr:grid-cols-2 wr:gap-2">
|
||||
{
|
||||
INTERESTS.map((opt) => (
|
||||
<label class="wr:inline-flex wr:items-center wr:gap-2 wr:text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="interests"
|
||||
value={opt.value}
|
||||
data-control
|
||||
class="wr:size-4 wr:rounded wr:border-border"
|
||||
/>
|
||||
<span>{opt.label}</span>
|
||||
</label>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<p
|
||||
id="interests-err"
|
||||
class="wr:text-sm wr:text-red-600 wr:mt-1"
|
||||
hidden
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Skills with MultiSelect (array<string>) -->
|
||||
<MultiSelect
|
||||
name="skills"
|
||||
label="Skills"
|
||||
placeholder="Pick your skills"
|
||||
options={SKILLS}
|
||||
searchable={true}
|
||||
clearable={true}
|
||||
value={[]}
|
||||
display="chips"
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
<!-- Plan / Avatar -->
|
||||
<Field
|
||||
kind="select"
|
||||
name="plan"
|
||||
label="Plan"
|
||||
options={PLANS}
|
||||
placeholder="Choose a plan"
|
||||
/>
|
||||
<Field
|
||||
kind="file"
|
||||
name="avatar"
|
||||
label="Avatar"
|
||||
description="PNG/JPG up to 2 MB"
|
||||
/>
|
||||
|
||||
<!-- Terms -->
|
||||
<Field
|
||||
kind="checkbox"
|
||||
name="acceptTerms"
|
||||
label="I agree to the Terms & Privacy"
|
||||
/>
|
||||
</Form>
|
||||
</section>
|
||||
</ComponentLayout>
|
||||
|
||||
<script>
|
||||
import { z } from "zod";
|
||||
|
||||
const MAX_FILE_MB = 2;
|
||||
const maxBytes = MAX_FILE_MB * 1024 * 1024;
|
||||
|
||||
window.schema = z
|
||||
.object({
|
||||
firstName: z
|
||||
.string()
|
||||
.min(2, "First name must be at least 2 characters"),
|
||||
lastName: z
|
||||
.string()
|
||||
.min(2, "Last name must be at least 2 characters"),
|
||||
email: z.string().email("Provide a valid email"),
|
||||
phone: z.string().optional(),
|
||||
|
||||
password: z
|
||||
.string()
|
||||
.min(6, "Password must be at least 6 characters"),
|
||||
confirmPassword: z.string(),
|
||||
|
||||
address: z.object({
|
||||
line1: z.string().min(1, "Address line 1 is required"),
|
||||
line2: z.string().optional(),
|
||||
city: z.string().min(1, "City is required"),
|
||||
pincode: z
|
||||
.string()
|
||||
.regex(/^[0-9]{4,8}$/, "Provide a valid postal code"),
|
||||
}),
|
||||
|
||||
// Arrays
|
||||
interests: z.array(z.string()).min(1, "Pick at least one interest"),
|
||||
skills: z.array(z.string()).min(1, "Pick at least one skill"),
|
||||
|
||||
plan: z.enum(["free", "pro", "business"], {
|
||||
required_error: "Choose a plan",
|
||||
}),
|
||||
|
||||
// File (File | null)
|
||||
// avatar: z
|
||||
// .any()
|
||||
// .refine(
|
||||
// (f) =>
|
||||
// f == null ||
|
||||
// (f &&
|
||||
// typeof f === "object" &&
|
||||
// "size" in f &&
|
||||
// f.size <= maxBytes),
|
||||
// `Avatar must be ≤ ${MAX_FILE_MB} MB`,
|
||||
// ),
|
||||
|
||||
acceptTerms: z.literal(true, {
|
||||
errorMap: () => ({ message: "You must accept the terms" }),
|
||||
}),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.password !== data.confirmPassword) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["confirmPassword"],
|
||||
message: "Passwords do not match",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
window.submit = async (data: any, form: any) => {
|
||||
if (String(data.email).includes("fail")) {
|
||||
// Map server errors to fields (nested ok) + form-level error
|
||||
const serverErrors = {
|
||||
_form: "We couldn’t create your account. Try again.",
|
||||
"user.firstName": "Looks taken — try a different first name",
|
||||
"address.pincode": "Service not available for this area",
|
||||
};
|
||||
form.dispatchEvent(
|
||||
new CustomEvent("wr:set-errors", {
|
||||
detail: serverErrors,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
return false; // DON'T reset on failure
|
||||
}
|
||||
|
||||
// Pretend success
|
||||
console.log("SUBMIT DATA:", data);
|
||||
alert("Success! Form will reset.");
|
||||
// allow reset (Form has data-reset)
|
||||
// return; // (undefined) -> resets
|
||||
};
|
||||
</script>
|
||||
Reference in New Issue
Block a user