Fixxed bugs From form and Fields

This commit is contained in:
2025-08-16 12:37:38 +05:30
parent acc2d2c767
commit a7f4b59c36
5 changed files with 285 additions and 169 deletions
+14
View File
@@ -19,6 +19,7 @@
"@iconify/json": "^2.2.373", "@iconify/json": "^2.2.373",
"@iconify/utils": "^3.0.1", "@iconify/utils": "^3.0.1",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"tailwind-merge": "^3.3.1" "tailwind-merge": "^3.3.1"
}, },
@@ -2686,6 +2687,19 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"clsx": "^2.1.1"
},
"funding": {
"url": "https://polar.sh/cva"
}
},
"node_modules/cli-boxes": { "node_modules/cli-boxes": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
+1
View File
@@ -77,6 +77,7 @@
"@iconify/json": "^2.2.373", "@iconify/json": "^2.2.373",
"@iconify/utils": "^3.0.1", "@iconify/utils": "^3.0.1",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"tailwind-merge": "^3.3.1" "tailwind-merge": "^3.3.1"
} }
+2 -10
View File
@@ -89,7 +89,7 @@ const helpCls = "mt-2 text-sm text-gray-500 dark:text-neutral-500";
const errCls = "text-sm text-red-600 mt-2"; const errCls = "text-sm text-red-600 mt-2";
const groupCls = "relative flex items-stretch"; const groupCls = "relative flex items-stretch";
const addonBase = const addonBase =
"px-4 inline-flex items-center min-w-fit rounded-s-md border border-e-0 border-gray-200 bg-gray-50 text-sm text-gray-500 dark:bg-neutral-700 dark:border-neutral-700 dark:text-neutral-400"; "px-4 inline-flex items-center min-w-fit rounded-md border border-gray-200 bg-gray-50 text-sm text-gray-500 dark:bg-neutral-700 dark:border-neutral-700 dark:text-neutral-400";
const controlWrap = "relative"; const controlWrap = "relative";
--- ---
@@ -144,8 +144,6 @@ const controlWrap = "relative";
}), }),
p.prefixText && "rounded-l-none", p.prefixText && "rounded-l-none",
p.suffixText && "rounded-r-none", p.suffixText && "rounded-r-none",
p.iconLeft && "rounded-l-none",
p.iconRight && "rounded-r-none",
p.error && p.error &&
"border-danger focus-visible:ring-danger/40", "border-danger focus-visible:ring-danger/40",
)} )}
@@ -217,8 +215,6 @@ const controlWrap = "relative";
"block w-full border border-gray-200 shadow-sm rounded-lg text-sm focus:z-10 focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 file:bg-gray-50 file:border-0 file:me-4 file:py-3 file:px-4 dark:file:bg-neutral-700 dark:file:text-neutral-400", "block w-full border border-gray-200 shadow-sm rounded-lg text-sm focus:z-10 focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 file:bg-gray-50 file:border-0 file:me-4 file:py-3 file:px-4 dark:file:bg-neutral-700 dark:file:text-neutral-400",
p.prefixText && "rounded-l-none", p.prefixText && "rounded-l-none",
p.suffixText && "rounded-r-none", p.suffixText && "rounded-r-none",
p.iconLeft && "rounded-l-none",
p.iconRight && "rounded-r-none",
p.error && p.error &&
"border-danger focus-visible:ring-danger/40", "border-danger focus-visible:ring-danger/40",
)} )}
@@ -281,8 +277,6 @@ const controlWrap = "relative";
: p.size === "lg" : p.size === "lg"
? "text-base p-4" ? "text-base p-4"
: "text-sm p-3", : "text-sm p-3",
p.iconLeft && "rounded-l-none",
p.iconRight && "rounded-r-none",
p.error && "border-danger focus-visible:ring-danger/40", p.error && "border-danger focus-visible:ring-danger/40",
)} )}
data-initial={typeof p.value === "string" ? p.value : ""} data-initial={typeof p.value === "string" ? p.value : ""}
@@ -309,9 +303,7 @@ const controlWrap = "relative";
} }
class={cn( class={cn(
selectBase({ size: p.size ?? "md" }), selectBase({ size: p.size ?? "md" }),
"appearance-none bg-[length:16px_16px] pr-9", "appearance-none bg-none pr-9",
p.iconLeft && "rounded-l-none",
p.iconRight && "rounded-r-none",
p.error && "border-danger focus-visible:ring-danger/40", p.error && "border-danger focus-visible:ring-danger/40",
)} )}
> >
+89 -56
View File
@@ -18,12 +18,13 @@ interface Props extends HTMLAttributes<"div"> {
required?: boolean; required?: boolean;
disabled?: boolean; disabled?: boolean;
options: Option[]; options: Option[];
value?: string[]; // initial selected values value?: string[];
placeholder?: string; // shown when empty placeholder?: string;
searchable?: boolean; // show search box in dropdown searchable?: boolean;
clearable?: boolean; // show clear (x) button clearable?: boolean;
maxSelections?: number; // optional selection cap maxSelections?: number;
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
display?: "text" | "chips";
class?: string; class?: string;
} }
@@ -32,8 +33,9 @@ const p = Astro.props as Props;
const fieldId = p.id ?? p.name; const fieldId = p.id ?? p.name;
const descId = p.description ? `${fieldId}-desc` : undefined; const descId = p.description ? `${fieldId}-desc` : undefined;
const errId = `${fieldId}-err`; const errId = `${fieldId}-err`;
const selected = new Set(p.value ?? []); const selected = new Set(p.value ?? []);
const summary = selected.size const summarySSR = selected.size
? Array.from(selected) ? Array.from(selected)
.map((v) => p.options.find((o) => o.value === v)?.label ?? v) .map((v) => p.options.find((o) => o.value === v)?.label ?? v)
.join(", ") .join(", ")
@@ -47,11 +49,15 @@ const sizeCls =
: "h-10 px-3 text-sm"; : "h-10 px-3 text-sm";
const controlCls = const controlCls =
"py-2.5 sm:py-3 px-4 block w-full border-gray-200 rounded-lg sm:text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600"; "py-2.5 sm:py-3 px-4 block w-full border-gray-200 rounded-lg sm:text-sm " +
"focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none " +
"dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600";
const chipBase = const chipBase =
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs bg-muted text-foreground"; "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs bg-muted text-foreground";
const containerId = `ms-${Math.random().toString(36).slice(2)}`; const containerId = `ms-${Math.random().toString(36).slice(2)}`;
const displayMode = p.display ?? "text";
--- ---
<div class={cn("space-y-1.5", p.class)} data-field={p.name} id={containerId}> <div class={cn("space-y-1.5", p.class)} data-field={p.name} id={containerId}>
@@ -67,9 +73,6 @@ const containerId = `ms-${Math.random().toString(36).slice(2)}`;
} }
<div class="relative"> <div class="relative">
{
/** Visible control is a readonly input so validation styles apply here */
}
<input <input
id={fieldId} id={fieldId}
type="text" type="text"
@@ -79,21 +82,28 @@ const containerId = `ms-${Math.random().toString(36).slice(2)}`;
aria-describedby={[descId, p.error ? errId : undefined] aria-describedby={[descId, p.error ? errId : undefined]
.filter(Boolean) .filter(Boolean)
.join(" ") || undefined} .join(" ") || undefined}
class={cn(controlCls, sizeCls, "pr-10 cursor-pointer")} class={cn(
controlCls,
sizeCls,
"pr-10 cursor-pointer",
displayMode === "chips" && "pl-3",
)}
placeholder={p.placeholder ?? "Select..."} placeholder={p.placeholder ?? "Select..."}
value={summary} value={displayMode === "text" ? summarySSR : ""}
readonly readonly
data-control data-control
{...p.disabled ? { disabled: true } : {}} {...p.disabled ? { disabled: true } : {}}
/> />
{/** Chevron / Clear */}
<div class="absolute inset-y-0 right-0 flex items-center gap-1 pr-2"> <div class="absolute inset-y-0 right-0 flex items-center gap-1 pr-2">
{ {
p.clearable !== false && selected.size > 0 && !p.disabled && ( p.clearable !== false && !p.disabled && (
<button <button
type="button" type="button"
class="p-1 rounded hover:bg-muted" class={cn(
"p-1 rounded hover:bg-muted",
selected.size === 0 && "hidden",
)}
data-ms-clear data-ms-clear
aria-label="Clear selection" aria-label="Clear selection"
> >
@@ -111,28 +121,15 @@ const containerId = `ms-${Math.random().toString(36).slice(2)}`;
</button> </button>
</div> </div>
{ <div
/** Selected chips (optional; comment out if you prefer only summary text) */ data-ms-chips
} class={cn(
{ "pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 flex flex-wrap gap-1 pr-14",
selected.size > 0 && ( displayMode === "chips" ? "" : "hidden",
<div class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 flex flex-wrap gap-1 pr-14">
{Array.from(selected)
.slice(0, 3)
.map((v) => {
const lab =
p.options.find((o) => o.value === v)?.label ??
v;
return <span class={chipBase}>{lab}</span>;
})}
{selected.size > 3 && (
<span class={chipBase}>+{selected.size - 3}</span>
)} )}
>
</div> </div>
)
}
{/** Dropdown */}
<div <div
class="absolute border-border bg-popover text-popover-foreground shadow-lg hidden mt-2 z-50 w-full max-h-72 p-1 space-y-0.5 bg-white border border-gray-200 rounded-lg overflow-hidden overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-gray-100 [&::-webkit-scrollbar-thumb]:bg-gray-300 dark:[&::-webkit-scrollbar-track]:bg-neutral-700 dark:[&::-webkit-scrollbar-thumb]:bg-neutral-500 dark:bg-neutral-900 dark:border-neutral-700" class="absolute border-border bg-popover text-popover-foreground shadow-lg hidden mt-2 z-50 w-full max-h-72 p-1 space-y-0.5 bg-white border border-gray-200 rounded-lg overflow-hidden overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-gray-100 [&::-webkit-scrollbar-thumb]:bg-gray-300 dark:[&::-webkit-scrollbar-track]:bg-neutral-700 dark:[&::-webkit-scrollbar-thumb]:bg-neutral-500 dark:bg-neutral-900 dark:border-neutral-700"
id={`${containerId}-panel`} id={`${containerId}-panel`}
@@ -198,19 +195,25 @@ const containerId = `ms-${Math.random().toString(36).slice(2)}`;
<button <button
type="button" type="button"
class="text-sm text-primary hover:underline disabled:opacity-50" class="text-sm text-primary hover:underline disabled:opacity-50"
data-ms-select-all>Select all</button data-ms-select-all
> >
Select all
</button>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
type="button" type="button"
class="text-sm text-muted-foreground hover:underline" class="text-sm text-muted-foreground hover:underline"
data-ms-cancel>Cancel</button data-ms-cancel
> >
Cancel
</button>
<button <button
type="button" type="button"
class="text-sm text-primary hover:underline" class="text-sm text-primary hover:underline"
data-ms-apply>Apply</button data-ms-apply
> >
Apply
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -236,6 +239,7 @@ const containerId = `ms-${Math.random().toString(36).slice(2)}`;
fieldId, fieldId,
name: p.name, name: p.name,
maxSelections: p.maxSelections ?? null, maxSelections: p.maxSelections ?? null,
displayMode,
}} }}
> >
const root = document.getElementById(containerId); const root = document.getElementById(containerId);
@@ -248,39 +252,72 @@ const containerId = `ms-${Math.random().toString(36).slice(2)}`;
const btnCan = root.querySelector("[data-ms-cancel]"); const btnCan = root.querySelector("[data-ms-cancel]");
const btnApp = root.querySelector("[data-ms-apply]"); const btnApp = root.querySelector("[data-ms-apply]");
const sch = root.querySelector("[data-ms-search]"); const sch = root.querySelector("[data-ms-search]");
const chips = root.querySelector("[data-ms-chips]");
const checks = Array.from( const checks = Array.from(
root.querySelectorAll( root.querySelectorAll(
`input[type="checkbox"][name="${CSS.escape(name)}"]`, `input[type="checkbox"][name="${CSS.escape(name)}"]`,
), ),
); );
let open = false; let open = false;
const origPlaceholder = input.getAttribute("placeholder") || "Select...";
function setExpanded( function setExpanded(v, { focusSearch = false } = {}) {
v,
{ focusSearch = false, refocusInput = false } = {},
) {
open = v; open = v;
input.setAttribute("aria-expanded", String(open)); input.setAttribute("aria-expanded", String(open));
panel.classList.toggle("hidden", !open); panel.classList.toggle("hidden", !open);
if (open && focusSearch) sch?.focus(); if (open && focusSearch) sch?.focus();
if (!open && refocusInput) input.focus(); }
function makeChip(label) {
const span = document.createElement("span");
span.className =
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs bg-muted text-foreground";
span.textContent = label;
return span;
}
function toggleClear(show) {
if (!btnClr) return;
btnClr.classList.toggle("hidden", !show);
} }
function updateSummary() { function updateSummary() {
const selected = checks.filter((c) => c.checked); const selected = checks.filter((c) => c.checked);
const labels = selected const labels = selected.map(
.slice(0, 3)
.map(
(c) => (c) =>
c c
.closest("[data-ms-item]") .closest("[data-ms-item]")
?.querySelector("span") ?.querySelector("span")
?.textContent?.trim() || c.value, ?.textContent?.trim() || c.value,
); );
const extra = Math.max(0, selected.length - 3);
input.value = selected.length if (displayMode === "text") {
? labels.join(", ") + (extra ? ` +${extra}` : "") const preview = labels.slice(0, 3).join(", ");
const extra = Math.max(0, labels.length - 3);
input.value = labels.length
? preview + (extra ? ` +${extra}` : "")
: ""; : "";
input.placeholder = labels.length ? "" : origPlaceholder;
if (chips) {
chips.innerHTML = "";
chips.classList.add("hidden");
}
} else {
input.value = "";
input.placeholder = labels.length ? "" : origPlaceholder;
if (!chips) return;
chips.innerHTML = "";
const max = 3;
labels
.slice(0, max)
.forEach((lab) => chips.appendChild(makeChip(lab)));
const extra = labels.length - max;
if (extra > 0) chips.appendChild(makeChip(`+${extra}`));
chips.classList.toggle("hidden", labels.length === 0);
}
toggleClear(labels.length > 0);
} }
function enforceMax() { function enforceMax() {
@@ -308,7 +345,6 @@ const containerId = `ms-${Math.random().toString(36).slice(2)}`;
updateSummary(); updateSummary();
} }
// Open/close
btnTgl?.addEventListener("click", () => btnTgl?.addEventListener("click", () =>
setExpanded(!open, { focusSearch: !open }), setExpanded(!open, { focusSearch: !open }),
); );
@@ -322,21 +358,20 @@ const containerId = `ms-${Math.random().toString(36).slice(2)}`;
} }
}); });
// Actions
btnClr?.addEventListener("click", (e) => { btnClr?.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
clearAll(); clearAll();
}); });
btnAll?.addEventListener("click", selectAll); btnAll?.addEventListener("click", selectAll);
btnCan?.addEventListener("click", () => setExpanded(false)); // no refocus btnCan?.addEventListener("click", () => setExpanded(false));
btnApp?.addEventListener("click", () => setExpanded(false)); // no refocus btnApp?.addEventListener("click", () => setExpanded(false));
list.addEventListener("change", () => { list.addEventListener("change", () => {
enforceMax(); enforceMax();
updateSummary(); updateSummary();
input.dispatchEvent(new Event("change", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true }));
}); });
// Search filter
sch?.addEventListener("input", () => { sch?.addEventListener("input", () => {
const q = sch.value.trim().toLowerCase(); const q = sch.value.trim().toLowerCase();
root.querySelectorAll("[data-ms-item]").forEach((li) => { root.querySelectorAll("[data-ms-item]").forEach((li) => {
@@ -345,12 +380,10 @@ const containerId = `ms-${Math.random().toString(36).slice(2)}`;
}); });
}); });
// Close on outside click without refocusing input
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
if (!root.contains(e.target)) setExpanded(false); // note: no refocusInput if (!root.contains(e.target)) setExpanded(false);
}); });
// Init
enforceMax(); enforceMax();
updateSummary(); updateSummary();
</script> </script>
+166 -90
View File
@@ -1,105 +1,155 @@
--- ---
import Form from "../components/Form.astro"; import { Form, Field, MultiSelect } from "@wrnexus/astro";
import Field from "../components/Field.astro";
import Base from "../layouts/Base.astro"; import Base from "../layouts/Base.astro";
import MultiSelect from "../components/MultiSelect.astro";
--- ---
<Base class="p-4"> <Base class="p-4">
<Form <Form
title="Create Project" title="Job Application"
description="Project metadata, constraints, and attachments." description="Tell us about yourself. Fields marked * are required."
columns={2} columns={2}
schemaName="schema" schemaName="schema"
onSubmit="submit" onSubmit="submit"
submitLabel="Create Project" submitLabel="Submit Application"
submitIcon="mdi:check" submitIcon="mdi:send"
actionsAlign="center" actionsAlign="end"
resetOnSubmit resetOnSubmit
validateOn="blur,change,input" validateOn="input,blur,change"
> >
<!-- Applicant -->
<Field <Field
name="projectName" name="firstName"
label="Project name" label="First name"
required required
iconLeft="mdi:briefcase" iconLeft="mdi:account"
/> />
<Field <Field
name="slug" name="lastName"
label="Slug" label="Last name"
prefixText="https://app.example.com/p/"
placeholder="acme-crm"
required required
iconLeft="mdi:account"
/>
<Field
name="email"
label="Email"
kind="email"
required
iconLeft="mdi:email"
/>
<Field
name="phone"
label="Phone"
placeholder="+91 98765 43210"
iconLeft="mdi:phone"
/>
<Field
name="portfolio"
label="Portfolio URL"
kind="text"
placeholder="https://example.com"
iconLeft="mdi:link-variant" iconLeft="mdi:link-variant"
class="sm:col-span-2"
/> />
<!-- Role details -->
<Field <Field
name="teamSize" name="role"
label="Team size" label="Desired role"
kind="number" kind="select"
placeholder="e.g., 8" placeholder="Select a role"
options={[
{ value: "frontend", label: "Frontend Engineer" },
{ value: "backend", label: "Backend Engineer" },
{ value: "fullstack", label: "Full-stack Engineer" },
{ value: "designer", label: "Product Designer" },
]}
required required
iconLeft="mdi:account-group"
/>
<Field
name="budget"
label="Budget (USD)"
kind="number"
placeholder="Optional"
iconLeft="mdi:currency-usd"
/> />
<Field name="startDate" label="Start date" kind="date" required /> <Field
<Field name="kickoffTime" label="Kickoff time" kind="time" required /> name="experience"
label="Years of experience"
kind="number"
placeholder="e.g., 3"
suffixText="yrs"
required
/>
<MultiSelect <MultiSelect
name="tags" name="skills"
label="Tags" label="Key skills"
placeholder="Pick skills"
searchable
clearable
maxSelections={6}
value={["javascript"]}
options={[ options={[
{ value: "web", label: "Web" }, { value: "javascript", label: "JavaScript" },
{ value: "mobile", label: "Mobile" }, { value: "typescript", label: "TypeScript" },
{ value: "api", label: "API" }, { value: "react", label: "React" },
{ value: "ml", label: "ML" }, { value: "node", label: "Node.js" },
{ value: "ops", label: "Ops" }, { value: "astro", label: "Astro" },
{ value: "css", label: "CSS" },
{ value: "figma", label: "Figma" },
]} ]}
description="Use Ctrl/Cmd to pick multiple."
class="sm:col-span-2" class="sm:col-span-2"
required description="Choose up to 6."
/> />
<Field <Field
name="visibility" name="availableFrom"
label="Visibility" label="Available from"
kind="date"
required
/>
<Field name="preferredCall" label="Preferred call time" kind="time" />
<!-- Work auth -->
<Field
name="workAuth"
label="Work Authorization"
kind="radio" kind="radio"
value="private" value="authorized"
class="sm:col-span-2" class="sm:col-span-2"
options={[ options={[
{ value: "private", label: "Private" }, { value: "authorized", label: "Authorized to work" },
{ value: "internal", label: "Internal" }, { value: "requiresVisa", label: "Requires visa sponsorship" },
{ value: "public", label: "Public" },
]} ]}
/> />
<!-- Compensation -->
<Field <Field
name="attachments" name="salary"
label="Attachments" label="Expected CTC"
kind="number"
prefixText="₹"
suffixText="LPA"
placeholder="e.g., 12"
class="sm:col-span-2"
/>
<!-- Resume/Cover -->
<Field
name="resume"
label="Resume (PDF)"
kind="file" kind="file"
multiple
class="sm:col-span-2" class="sm:col-span-2"
/> />
<Field <Field
name="description" name="coverLetter"
label="Description" label="Cover letter"
kind="textarea" kind="textarea"
rows={5} rows={6}
placeholder="What are we building?" placeholder="A short note about why youd be a great fit…"
class="sm:col-span-2" class="sm:col-span-2"
required
/> />
<Field <Field
name="notify" name="agree"
label="Notify team on create" label="I confirm the information provided is accurate"
kind="switch" kind="checkbox"
class="sm:col-span-2" class="sm:col-span-2"
/> />
</Form> </Form>
@@ -108,46 +158,72 @@ import MultiSelect from "../components/MultiSelect.astro";
<script> <script>
import { z } from "zod"; import { z } from "zod";
const fileArray = z.array(z.instanceof(File)).optional(); const phoneRegex = /^\+?[0-9\s().-]{7,20}$/i;
const urlRegex = /^https?:\/\/.+/i;
window.schema = z.object({ const resumeSchema = z
projectName: z.string().min(3, "Enter at least 3 characters."), .union([z.instanceof(File), z.null()])
slug: z
.string()
.regex(
/^[a-z0-9-]{3,}$/i,
"Slug must be 3+ chars; letters, digits, hyphen.",
),
teamSize: z.coerce.number().int().min(1, "Team size must be ≥ 1."),
budget: z.coerce
.number()
.positive("Budget must be positive.")
.optional()
.or(z.nan().transform(() => undefined)),
startDate: z.string().min(1, "Start date is required."),
kickoffTime: z.string().min(1, "Kickoff time is required."),
tags: z.array(z.string()).min(1, "Pick at least one tag."),
visibility: z.enum(["private", "internal", "public"], {
required_error: "Choose a visibility.",
}),
// attachments -> array<File> (from our collector when multiple)
attachments: fileArray
.refine((arr) => !arr || arr.length <= 3, "Max 3 files.")
.refine( .refine(
(arr) => !arr || arr.every((f) => f.size <= 5 * 1024 * 1024), (f) => f === null || /pdf$/i.test(f.type),
"Files must be ≤ 5MB.", "Resume must be a PDF.",
) )
.refine( .refine(
(arr) => (f) => f === null || f.size <= 5 * 1024 * 1024,
!arr || arr.every((f) => /pdf|png|jpg|jpeg/i.test(f.type)), "Max file size is 5MB.",
"Only PDF/PNG/JPG.", )
), .optional();
description: z.string().min(20, "Describe the project (20+ chars)."),
notify: z.boolean().optional(), window.schema = z
.object({
firstName: z.string().min(2, "Enter at least 2 characters."),
lastName: z.string().min(2, "Enter at least 2 characters."),
email: z.string().email("Enter a valid email."),
phone: z
.string()
.regex(phoneRegex, "Enter a valid phone.")
.optional()
.or(z.literal("")),
portfolio: z
.string()
.regex(urlRegex, "Enter a valid URL (http/https).")
.optional()
.or(z.literal("")),
role: z.enum(["frontend", "backend", "fullstack", "designer"], {
required_error: "Select a role.",
}),
experience: z.coerce.number().int().min(0, "Must be 0 or more."),
skills: z
.array(z.string())
.min(1, "Pick at least one skill.")
.max(6, "Up to 6 skills."),
availableFrom: z.string().min(1, "Pick a date."),
preferredCall: z.string().optional().or(z.literal("")),
workAuth: z.enum(["authorized", "requiresVisa"]),
salary: z.coerce
.number()
.positive("Enter a positive number.")
.optional(),
resume: resumeSchema,
coverLetter: z
.string()
.max(1000, "Keep it under 1000 characters.")
.optional()
.or(z.literal("")),
agree: z.literal(true, {
errorMap: () => ({ message: "You must confirm to proceed." }),
}),
})
.superRefine((obj, ctx) => {
if (obj.workAuth === "requiresVisa" && !obj.coverLetter) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["coverLetter"],
message: "Please add a note about your visa status.",
});
}
}); });
window.submit = async (data: any, form: any) => { window.submit = async (data: any, form: any) => {
console.log("Project Create submit:", data); console.log("Application:", data);
await new Promise((r) => setTimeout(r, 800));
}; };
</script> </script>