Updated the FieldGroup and Field Issue

This commit is contained in:
2025-08-18 15:18:07 +05:30
parent df3d047dcb
commit f4337c5a13
7 changed files with 985 additions and 158 deletions
+193 -46
View File
@@ -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
View File
@@ -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(""));
};
+6
View File
@@ -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>
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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.")
+280
View File
@@ -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 couldnt 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>
+270
View File
@@ -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 couldnt 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>