Updated the FieldGroup and Field Issue
This commit is contained in:
+188
-41
@@ -2,60 +2,207 @@
|
|||||||
import type { HTMLAttributes } from "astro/types";
|
import type { HTMLAttributes } from "astro/types";
|
||||||
import { cn } from "../utils/cn";
|
import { cn } from "../utils/cn";
|
||||||
|
|
||||||
interface Props extends HTMLAttributes<"fieldset"> {
|
type Radius = "none" | "md" | "lg" | "full";
|
||||||
label?: string;
|
type Layout = "flex" | "grid";
|
||||||
description?: string;
|
|
||||||
error?: string;
|
interface Props extends HTMLAttributes<"div"> {
|
||||||
columns?: 1 | 2 | 3 | 4;
|
connected?: boolean; // join inputs (no gaps, shared borders) on lg+
|
||||||
gap?: "sm" | "md" | "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 {
|
const {
|
||||||
label,
|
connected = true,
|
||||||
description,
|
hideLabels = true,
|
||||||
error,
|
rounded = "lg",
|
||||||
columns = 2,
|
equal = true,
|
||||||
gap = "md",
|
layout = "flex",
|
||||||
|
cols,
|
||||||
class: className,
|
class: className,
|
||||||
...rest
|
...rest
|
||||||
}: Props = Astro.props;
|
}: Props = Astro.props;
|
||||||
|
|
||||||
const gridCols =
|
const radiusMap: Record<Radius, string> = {
|
||||||
columns === 4
|
none: "0px",
|
||||||
? "wr:grid-cols-1 sm:wr:grid-cols-4"
|
md: "0.375rem",
|
||||||
: columns === 3
|
lg: "0.5rem",
|
||||||
? "wr:grid-cols-1 sm:wr:grid-cols-3"
|
full: "9999px",
|
||||||
: columns === 2
|
};
|
||||||
? "wr:grid-cols-1 sm:wr:grid-cols-2"
|
|
||||||
: "wr:grid-cols-1";
|
|
||||||
|
|
||||||
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}>
|
<div
|
||||||
{
|
id={groupId}
|
||||||
label && (
|
class={cn(
|
||||||
<legend class="wr:text-sm wr:font-medium wr:text-foreground">
|
// Small screens: stack
|
||||||
{label}
|
"wr:w-full",
|
||||||
</legend>
|
layout === "grid" ? "wr:grid wr:grid-cols-1 wr:gap-4" : "wr:flex wr:flex-col wr:gap-4",
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
// Large screens: switch to inline + remove gaps if connected
|
||||||
description && (
|
layout === "grid"
|
||||||
<p class="wr:text-xs wr:text-muted-foreground">{description}</p>
|
? "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)}>
|
// 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 />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
<style is:inline>
|
||||||
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">
|
Base: do NOT connect on small screens (stacked, normal inputs)
|
||||||
{error}
|
=========================================================== */
|
||||||
</p>
|
#{groupId} > [data-field] {
|
||||||
)
|
min-width: 0;
|
||||||
}
|
}
|
||||||
</fieldset>
|
|
||||||
|
/* ===========================================================
|
||||||
|
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>
|
||||||
|
|||||||
+233
-109
@@ -111,10 +111,10 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
|||||||
];
|
];
|
||||||
|
|
||||||
const form = document.getElementById(formId);
|
const form = document.getElementById(formId);
|
||||||
|
|
||||||
if (!form || form.dataset.__afInit) return;
|
if (!form || form.dataset.__afInit) return;
|
||||||
form.dataset.__afInit = "1";
|
form.dataset.__afInit = "1";
|
||||||
|
|
||||||
|
/* ---------------- schema helpers ---------------- */
|
||||||
function getSchema() {
|
function getSchema() {
|
||||||
const name = form.dataset.schema;
|
const name = form.dataset.schema;
|
||||||
if (!name) return null;
|
if (!name) return null;
|
||||||
@@ -125,14 +125,16 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
|||||||
}
|
}
|
||||||
return typeof val === "function" ? val() : val;
|
return typeof val === "function" ? val() : val;
|
||||||
}
|
}
|
||||||
|
function zUnwrap(s) {
|
||||||
function unwrapToObject(schema) {
|
// peel Optional/Nullable/Default/Branded/Catch/Effects/Pipeline wrappers
|
||||||
let cur = schema;
|
while (s && s._def) {
|
||||||
while (cur && cur._def) {
|
const t = s._def.typeName;
|
||||||
const t = cur._def.typeName;
|
|
||||||
if (t === "ZodObject") return cur;
|
|
||||||
if (t === "ZodEffects") {
|
if (t === "ZodEffects") {
|
||||||
cur = cur._def.schema;
|
s = s._def.schema;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (t === "ZodPipeline") {
|
||||||
|
s = s._def.out;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -142,23 +144,24 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
|||||||
t === "ZodBranded" ||
|
t === "ZodBranded" ||
|
||||||
t === "ZodCatch"
|
t === "ZodCatch"
|
||||||
) {
|
) {
|
||||||
cur = cur._def.innerType;
|
s =
|
||||||
continue;
|
s._def.innerType ||
|
||||||
}
|
s._def.type ||
|
||||||
if (t === "ZodPipeline") {
|
s._def.schema ||
|
||||||
cur = cur._def.out;
|
s._def.inner;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return null;
|
return s;
|
||||||
}
|
}
|
||||||
function getObjectShape(obj) {
|
function zObjectShape(obj) {
|
||||||
if (!obj) return null;
|
if (!obj) return null;
|
||||||
const s = obj.shape ?? obj._def?.shape;
|
const s = obj.shape ?? obj._def?.shape;
|
||||||
return typeof s === "function" ? s() : s;
|
return typeof s === "function" ? s() : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- UI helpers ---------------- */
|
||||||
function fieldControls(root) {
|
function fieldControls(root) {
|
||||||
let ctrls = root.querySelectorAll(
|
let ctrls = root.querySelectorAll(
|
||||||
"input[data-control],select[data-control],textarea[data-control]",
|
"input[data-control],select[data-control],textarea[data-control]",
|
||||||
@@ -182,7 +185,6 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
|||||||
checkboxes: checks,
|
checkboxes: checks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function setError(root, msg) {
|
function setError(root, msg) {
|
||||||
const { ctrls, err } = fieldControls(root);
|
const { ctrls, err } = fieldControls(root);
|
||||||
if (!ctrls.length) return;
|
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) {
|
function validateField(name) {
|
||||||
const root = form.querySelector(
|
const root = form.querySelector(
|
||||||
`[data-field="${CSS.escape(name)}"]`,
|
`[data-field="${CSS.escape(name)}"]`,
|
||||||
@@ -260,89 +417,64 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
|||||||
else value = ctrl.value;
|
else value = ctrl.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const schema = getSchema();
|
const sc = getSchema();
|
||||||
const obj = unwrapToObject(schema);
|
const fieldSchema = sc ? schemaForPath(sc, name) : null;
|
||||||
const shape = getObjectShape(obj);
|
if (!fieldSchema?.safeParse) return;
|
||||||
const fieldSchema = shape?.[name];
|
|
||||||
if (!fieldSchema) return;
|
|
||||||
|
|
||||||
const res = fieldSchema.safeParse(value);
|
const res = fieldSchema.safeParse(value);
|
||||||
setError(
|
setError(
|
||||||
root,
|
root,
|
||||||
res.success
|
res.success
|
||||||
? ""
|
? ""
|
||||||
: res.error.issues[0]?.message || "Invalid value",
|
: res.error?.issues?.[0]?.message || "Invalid value",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectData(formEl) {
|
/* ---------------- showErrors: map nested paths ---------------- */
|
||||||
const names = new Set(
|
const formErrEl = form.querySelector("[data-form-error]");
|
||||||
Array.from(
|
function setFormError(msg) {
|
||||||
formEl.querySelectorAll(
|
if (!formErrEl) return;
|
||||||
"input[name],select[name],textarea[name]",
|
if (msg && String(msg).trim()) {
|
||||||
),
|
formErrEl.textContent = String(msg);
|
||||||
)
|
formErrEl.hidden = false;
|
||||||
.map((el) => el.getAttribute("name"))
|
} else {
|
||||||
.filter(Boolean),
|
formErrEl.textContent = "";
|
||||||
);
|
formErrEl.hidden = true;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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) {
|
function showErrors(formEl, zodError) {
|
||||||
formEl.querySelectorAll("[data-field]").forEach(clearState);
|
formEl.querySelectorAll("[data-field]").forEach(clearState);
|
||||||
let firstRoot = null;
|
let firstRoot = null;
|
||||||
|
|
||||||
for (const issue of zodError.issues ?? []) {
|
for (const issue of zodError.issues ?? []) {
|
||||||
const key =
|
const key =
|
||||||
Array.isArray(issue.path) && issue.path.length
|
issue.path && issue.path.length
|
||||||
? String(issue.path[0])
|
? pathToAttr(issue.path)
|
||||||
: null;
|
: null;
|
||||||
if (!key) continue;
|
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;
|
if (!root) continue;
|
||||||
|
|
||||||
setError(root, issue.message);
|
setError(root, issue.message);
|
||||||
if (!firstRoot) firstRoot = root;
|
if (!firstRoot) firstRoot = root;
|
||||||
}
|
}
|
||||||
@@ -355,17 +487,21 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
|||||||
if (firstRoot) fieldControls(firstRoot).ctrls[0]?.focus();
|
if (firstRoot) fieldControls(firstRoot).ctrls[0]?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- wire events ---------------- */
|
||||||
const validateOn = (form.dataset.validateon || "input,blur,change")
|
const validateOn = (form.dataset.validateon || "input,blur,change")
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
if (validateOn.includes("input"))
|
|
||||||
|
if (validateOn.includes("input")) {
|
||||||
form.addEventListener("input", (e) => {
|
form.addEventListener("input", (e) => {
|
||||||
const r = e.target?.closest?.("[data-field]");
|
const r = e.target?.closest?.("[data-field]");
|
||||||
const n = r?.getAttribute("data-field");
|
const n = r?.getAttribute("data-field");
|
||||||
if (n) validateField(n);
|
if (n) validateField(n);
|
||||||
|
setFormError(""); // typing clears form error
|
||||||
});
|
});
|
||||||
if (validateOn.includes("blur"))
|
}
|
||||||
|
if (validateOn.includes("blur")) {
|
||||||
form.addEventListener(
|
form.addEventListener(
|
||||||
"blur",
|
"blur",
|
||||||
(e) => {
|
(e) => {
|
||||||
@@ -375,31 +511,38 @@ const formId = `f-${Math.random().toString(36).slice(2)}`;
|
|||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
if (validateOn.includes("change"))
|
}
|
||||||
|
if (validateOn.includes("change")) {
|
||||||
form.addEventListener("change", (e) => {
|
form.addEventListener("change", (e) => {
|
||||||
const r = e.target?.closest?.("[data-field]");
|
const r = e.target?.closest?.("[data-field]");
|
||||||
const n = r?.getAttribute("data-field");
|
const n = r?.getAttribute("data-field");
|
||||||
if (n) validateField(n);
|
if (n) validateField(n);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
form.addEventListener("submit", async (e) => {
|
form.addEventListener("submit", async (e) => {
|
||||||
const schema = getSchema();
|
const schema = getSchema();
|
||||||
const onSubmitName = form.dataset.onsubmit;
|
const onSubmitName = form.dataset.onsubmit;
|
||||||
if (!schema && !onSubmitName) return;
|
if (!schema && !onSubmitName) return; // native submit
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (schema) {
|
if (schema) {
|
||||||
const data = collectData(form);
|
const data = collectData(form);
|
||||||
|
console.log(data);
|
||||||
const result = schema.safeParse(data);
|
const result = schema.safeParse(data);
|
||||||
|
console.log(result);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
showErrors(form, result.error);
|
showErrors(form, result.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFormError("");
|
setFormError("");
|
||||||
form.querySelectorAll("[data-field]").forEach(clearState);
|
form.querySelectorAll("[data-field]").forEach(clearState);
|
||||||
|
|
||||||
if (!onSubmitName) {
|
if (!onSubmitName) {
|
||||||
form.submit();
|
form.submit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const btn = form.querySelector('button[type="submit"]');
|
const btn = form.querySelector('button[type="submit"]');
|
||||||
btn && (btn.disabled = true);
|
btn && (btn.disabled = true);
|
||||||
try {
|
try {
|
||||||
@@ -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) => {
|
form.querySelectorAll("textarea[data-initial]").forEach((t) => {
|
||||||
t.value = t.getAttribute("data-initial") || "";
|
t.value = t.getAttribute("data-initial") || "";
|
||||||
t.removeAttribute("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) => {
|
form.addEventListener("wr:set-errors", (e) => {
|
||||||
const map = e.detail || {};
|
const map = e.detail || {};
|
||||||
if ("_form" in map) setFormError(map._form);
|
if ("_form" in map) setFormError(map._form);
|
||||||
|
|
||||||
for (const [key, val] of Object.entries(map)) {
|
for (const [key, val] of Object.entries(map)) {
|
||||||
if (key === "_form") continue;
|
if (key === "_form") continue;
|
||||||
const root = form.querySelector(
|
const root = form.querySelector(
|
||||||
`[data-field="${CSS.escape(key)}"]`,
|
`[data-field="${CSS.escape(key)}"]`,
|
||||||
);
|
);
|
||||||
if (root) {
|
if (root) setError(root, val);
|
||||||
setError(root, val);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
form.addEventListener("wr:set-form-error", (e) =>
|
||||||
const formErrEl = form.querySelector("[data-form-error]");
|
setFormError(e.detail),
|
||||||
|
);
|
||||||
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:clear-form-error", () => setFormError(""));
|
form.addEventListener("wr:clear-form-error", () => setFormError(""));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ import Base from "./Base.astro";
|
|||||||
<li class="list-item">
|
<li class="list-item">
|
||||||
<a href="/forms2">Forms 2</a>
|
<a href="/forms2">Forms 2</a>
|
||||||
</li>
|
</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">
|
<li class="list-item">
|
||||||
<a href="/theme">Theme</a>
|
<a href="/theme">Theme</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import ComponentLayout from "../layouts/ComponentLayout.astro";
|
|||||||
<Field
|
<Field
|
||||||
name="name"
|
name="name"
|
||||||
label="Project name"
|
label="Project name"
|
||||||
value="Test"
|
value="Te"
|
||||||
required
|
required
|
||||||
placeholder="Acme CRM"
|
placeholder="Acme CRM"
|
||||||
prefixText="Prefix"
|
prefixText="Prefix"
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ import ComponentLayout from "../layouts/ComponentLayout.astro";
|
|||||||
.number()
|
.number()
|
||||||
.positive("Enter a positive number.")
|
.positive("Enter a positive number.")
|
||||||
.optional(),
|
.optional(),
|
||||||
resume: resumeSchema,
|
// resume: resumeSchema,
|
||||||
coverLetter: z
|
coverLetter: z
|
||||||
.string()
|
.string()
|
||||||
.max(1000, "Keep it under 1000 characters.")
|
.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