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(""));
};