diff --git a/src/components/FieldRow.astro b/src/components/FieldRow.astro index 225203f..e7a9ec9 100644 --- a/src/components/FieldRow.astro +++ b/src/components/FieldRow.astro @@ -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 = { + 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)); --- -
- { - label && ( - - {label} - - ) - } +
{description}

- ) - } + // 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", -
- -
+ // 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} +> + +
- { - error && ( -

- {error} -

- ) - } -
+ diff --git a/src/components/Form.astro b/src/components/Form.astro index 87762c0..61909a4 100644 --- a/src/components/Form.astro +++ b/src/components/Form.astro @@ -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("")); }; diff --git a/src/layouts/ComponentLayout.astro b/src/layouts/ComponentLayout.astro index 6623aee..494ed88 100644 --- a/src/layouts/ComponentLayout.astro +++ b/src/layouts/ComponentLayout.astro @@ -32,6 +32,12 @@ import Base from "./Base.astro";
  • Forms 2
  • +
  • + Forms 3 +
  • +
  • + Forms 4 +
  • Theme
  • diff --git a/src/pages/forms.astro b/src/pages/forms.astro index 323467e..b69048b 100644 --- a/src/pages/forms.astro +++ b/src/pages/forms.astro @@ -18,7 +18,7 @@ import ComponentLayout from "../layouts/ComponentLayout.astro"; +
    + +
    + + +
    + + + + + + +
    + + + +
    + + +
    +
    + Additional addresses (array of objects) +
    +
    + + + +
    +
    + + + +
    +
    + + +
    + + +
    + + +
    +
    + Interests (checkbox array) +
    +
    + + + +
    + +
    + + + + + +
    +
    Plan
    +
    + + + +
    + +
    + + + + + +
    + +
    + + + + diff --git a/src/pages/forms4.astro b/src/pages/forms4.astro new file mode 100644 index 0000000..f780173 --- /dev/null +++ b/src/pages/forms4.astro @@ -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" }, +]; +--- + + +
    +

    Create Account

    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    + { + INTERESTS.map((opt) => ( + + )) + } +
    + +
    + + + +
    + + + + + + + + +
    +
    + +