first commit

This commit is contained in:
2025-08-16 09:52:47 +05:30
commit 4319823c76
249 changed files with 1192986 additions and 0 deletions
+134
View File
@@ -0,0 +1,134 @@
---
import type { HTMLAttributes } from "astro/types";
import { cn } from "../utils/cn";
import Icon from "./Icon.astro";
type Variant = "solid" | "outline" | "ghost" | "soft" | "link";
type Color =
| "primary"
| "secondary"
| "tertiary"
| "accent"
| "info"
| "success"
| "warning"
| "danger"
| "neutral";
type Size = "sm" | "md" | "lg" | "icon";
interface Props extends HTMLAttributes<"button"> {
variant?: Variant;
color?: Color;
size?: Size;
pill?: boolean;
icon?: string;
}
const {
variant = "solid",
color = "primary",
size = "md",
pill = false,
icon = "",
class: className,
...rest
}: Props = Astro.props;
const base =
"inline-flex items-center justify-center gap-2 font-medium transition-colors " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 " +
"disabled:opacity-50 disabled:pointer-events-none";
const sizeClass: Record<Size, string> = {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-11 px-5 text-base",
icon: "h-10 w-10 p-0",
};
const radius = pill ? "rounded-full" : "rounded-md";
const styles: Record<Color, Record<Variant, string>> = {
primary: {
solid: "bg-primary text-primary-foreground hover:bg-primary/90 border border-transparent focus-visible:ring-primary/40",
outline:
"border border-primary text-primary hover:bg-primary/10 focus-visible:ring-primary/40",
ghost: "text-primary hover:bg-primary/10 border border-transparent focus-visible:ring-primary/30",
soft: "bg-primary/15 text-primary hover:bg-primary/20 border border-transparent focus-visible:ring-primary/30",
link: "text-primary underline underline-offset-4 p-0 h-auto border-0 focus-visible:ring-primary/40",
},
secondary: {
solid: "bg-secondary text-secondary-foreground hover:bg-secondary/90 border border-transparent focus-visible:ring-secondary/40",
outline:
"border border-secondary text-secondary hover:bg-secondary/10 focus-visible:ring-secondary/40",
ghost: "text-secondary hover:bg-secondary/10 border border-transparent focus-visible:ring-secondary/30",
soft: "bg-secondary/15 text-secondary hover:bg-secondary/20 border border-transparent focus-visible:ring-secondary/30",
link: "text-secondary underline underline-offset-4 p-0 h-auto border-0 focus-visible:ring-secondary/40",
},
tertiary: {
solid: "bg-tertiary text-tertiary-foreground hover:bg-tertiary/90 border border-transparent focus-visible:ring-tertiary/40",
outline:
"border border-tertiary text-tertiary hover:bg-tertiary/10 focus-visible:ring-tertiary/40",
ghost: "text-tertiary hover:bg-tertiary/10 border border-transparent focus-visible:ring-tertiary/30",
soft: "bg-tertiary/15 text-tertiary hover:bg-tertiary/20 border border-transparent focus-visible:ring-tertiary/30",
link: "text-tertiary underline underline-offset-4 p-0 h-auto border-0 focus-visible:ring-tertiary/40",
},
accent: {
solid: "bg-accent text-accent-foreground hover:bg-accent/90 border border-transparent focus-visible:ring-accent/40",
outline:
"border border-accent text-accent hover:bg-accent/10 focus-visible:ring-accent/40",
ghost: "text-accent hover:bg-accent/10 border border-transparent focus-visible:ring-accent/30",
soft: "bg-accent/15 text-accent hover:bg-accent/20 border border-transparent focus-visible:ring-accent/30",
link: "text-accent underline underline-offset-4 p-0 h-auto border-0 focus-visible:ring-accent/40",
},
info: {
solid: "bg-info text-info-foreground hover:bg-info/90 border border-transparent focus-visible:ring-info/40",
outline:
"border border-info text-info hover:bg-info/10 focus-visible:ring-info/40",
ghost: "text-info hover:bg-info/10 border border-transparent focus-visible:ring-info/30",
soft: "bg-info/15 text-info hover:bg-info/20 border border-transparent focus-visible:ring-info/30",
link: "text-info underline underline-offset-4 p-0 h-auto border-0 focus-visible:ring-info/40",
},
success: {
solid: "bg-success text-success-foreground hover:bg-success/90 border border-transparent focus-visible:ring-success/40",
outline:
"border border-success text-success hover:bg-success/10 focus-visible:ring-success/40",
ghost: "text-success hover:bg-success/10 border border-transparent focus-visible:ring-success/30",
soft: "bg-success/15 text-success hover:bg-success/20 border border-transparent focus-visible:ring-success/30",
link: "text-success underline underline-offset-4 p-0 h-auto border-0 focus-visible:ring-success/40",
},
warning: {
solid: "bg-warning text-warning-foreground hover:bg-warning/90 border border-transparent focus-visible:ring-warning/40",
outline:
"border border-warning text-warning hover:bg-warning/10 focus-visible:ring-warning/40",
ghost: "text-warning hover:bg-warning/10 border border-transparent focus-visible:ring-warning/30",
soft: "bg-warning/14 text-warning hover:bg-warning/20 border border-transparent focus-visible:ring-warning/30",
link: "text-warning underline underline-offset-4 p-0 h-auto border-0 focus-visible:ring-warning/40",
},
danger: {
solid: "bg-danger text-danger-foreground hover:bg-danger/90 border border-transparent focus-visible:ring-danger/40",
outline:
"border border-danger text-danger hover:bg-danger/10 focus-visible:ring-danger/40",
ghost: "text-danger hover:bg-danger/10 border border-transparent focus-visible:ring-danger/30",
soft: "bg-danger/15 text-danger hover:bg-danger/20 border border-transparent focus-visible:ring-danger/30",
link: "text-danger underline underline-offset-4 p-0 h-auto border-0 focus-visible:ring-danger/40",
},
neutral: {
solid: "bg-neutral text-neutral-foreground hover:bg-neutral/90 border border-transparent focus-visible:ring-neutral/40",
outline:
"border border-neutral text-neutral hover:bg-neutral/10 focus-visible:ring-neutral/40",
ghost: "text-neutral hover:bg-neutral/10 border border-transparent focus-visible:ring-neutral/30",
soft: "bg-neutral/15 text-neutral hover:bg-neutral/20 border border-transparent focus-visible:ring-neutral/30",
link: "text-neutral underline underline-offset-4 p-0 h-auto border-0 focus-visible:ring-neutral/40",
},
};
const variantColor = styles[color][variant];
const sizeKls = variant === "link" ? "" : sizeClass[size];
const iconSize = size === "sm" ? "size-4" : size === "lg" ? "size-5" : "size-5";
---
<button class={cn(base, radius, sizeKls, variantColor, className)} {...rest}>
{icon && <Icon name={icon} class={cn(iconSize, "shrink-0")} />}
<slot />
</button>
+430
View File
@@ -0,0 +1,430 @@
---
import type { HTMLAttributes } from "astro/types";
import { cva } from "class-variance-authority";
import { cn } from "../utils/cn";
import Icon from "./Icon.astro";
type Kind =
| "text"
| "email"
| "password"
| "number"
| "textarea"
| "select"
| "date"
| "time"
| "file"
| "checkbox"
| "radio"
| "switch";
interface Option {
value: string;
label: string;
disabled?: boolean;
}
interface Props extends HTMLAttributes<"div"> {
id?: string;
name: string;
label?: string;
kind?: Kind;
placeholder?: string;
description?: string;
error?: string;
required?: boolean;
disabled?: boolean;
value?: string | number | boolean;
options?: Option[];
multiple?: boolean;
rows?: number;
prefixText?: string;
suffixText?: string;
iconLeft?: string;
iconRight?: string;
size?: "sm" | "md" | "lg";
class?: string;
}
const p = Astro.props as Props;
const fieldId = p.id ?? p.name;
const descId = p.description ? `${fieldId}-desc` : undefined;
const errId = `${fieldId}-err`;
const inputBase = cva(
"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",
{
variants: {
size: {
sm: "h-10 px-3 text-sm",
md: "h-11 px-3 text-sm",
lg: "h-12 px-4 text-base",
},
withIconLeft: { true: "pl-10" },
withIconRight: { true: "pr-10" },
},
},
);
const selectBase = cva(
"py-3 px-4 pe-9 block w-full border-gray-200 rounded-lg 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",
{
variants: {
size: {
sm: "h-10 px-3 text-sm",
md: "h-11 px-3 text-sm",
lg: "h-12 px-4 text-base",
},
withIconLeft: { true: "pl-10" },
withIconRight: { true: "pr-10" },
},
},
);
const iconSize =
p.size === "sm" ? "size-4" : p.size === "lg" ? "size-5" : "size-5";
const labelCls = "block text-sm font-medium mb-2 dark:text-white";
const helpCls = "mt-2 text-sm text-gray-500 dark:text-neutral-500";
const errCls = "text-sm text-red-600 mt-2";
const groupCls = "relative flex items-stretch";
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";
const controlWrap = "relative";
---
<div class={cn("space-y-1.5", p.class)} data-field={p.name}>
{
p.label &&
[
"text",
"textarea",
"email",
"password",
"number",
"date",
"time",
"file",
"select",
].includes(p.kind ?? "text") && (
<label for={fieldId} class={labelCls}>
{p.label} {p.required && <span class="text-danger">*</span>}
</label>
)
}
{
["text", "email", "password", "number", "date", "time"].includes(
p.kind ?? "text",
) && (
<div class={groupCls}>
{p.prefixText && (
<span class={cn(addonBase, "border-r-0 rounded-r-none")}>
{p.prefixText}
</span>
)}
<div class={cn(controlWrap, "flex-1")}>
{p.iconLeft && (
<span class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-muted-foreground">
<Icon name={p.iconLeft} class={iconSize} />
</span>
)}
<input
id={fieldId}
name={p.name}
type={(p.kind as any) ?? "text"}
class={cn(
inputBase({
size: p.size ?? "md",
withIconLeft: !!p.iconLeft,
withIconRight:
!!p.iconRight || p.kind === "password",
}),
p.prefixText && "rounded-l-none",
p.suffixText && "rounded-r-none",
)}
data-control
placeholder={p.placeholder}
required={p.required}
disabled={p.disabled}
aria-invalid={!!p.error}
aria-describedby={
[descId, p.error ? errId : undefined]
.filter(Boolean)
.join(" ") || undefined
}
value={
typeof p.value === "string" ||
typeof p.value === "number"
? String(p.value)
: undefined
}
/>
{p.kind === "password" && (
<button
type="button"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-muted-foreground hover:text-foreground"
onclick={`const i = this.previousElementSibling; i.type = i.type === 'password' ? 'text' : 'password';`}
>
<Icon name="mdi:eye" class={iconSize} />
</button>
)}
{p.iconRight && (
<span class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none text-muted-foreground">
<Icon name={p.iconRight} class={iconSize} />
</span>
)}
</div>
{p.suffixText && (
<span class={cn(addonBase, "border-l-0 rounded-l-none")}>
{p.suffixText}
</span>
)}
</div>
)
}
{
p.kind === "file" && (
<div class={groupCls}>
{p.prefixText && (
<span class={cn(addonBase, "border-r-0 rounded-r-none")}>
{p.prefixText}
</span>
)}
<div class={cn(controlWrap, "flex-1")}>
{p.iconLeft && (
<span class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-muted-foreground">
<Icon name={p.iconLeft} class={iconSize} />
</span>
)}
<input
id={fieldId}
name={p.name}
type={(p.kind as any) ?? "text"}
class={cn(
"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.suffixText && "rounded-r-none",
)}
data-control
multiple
placeholder={p.placeholder}
required={p.required}
disabled={p.disabled}
aria-invalid={!!p.error}
aria-describedby={
[descId, p.error ? errId : undefined]
.filter(Boolean)
.join(" ") || undefined
}
value={
typeof p.value === "string" ||
typeof p.value === "number"
? String(p.value)
: undefined
}
/>
{p.iconRight && (
<span class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none text-muted-foreground">
<Icon name={p.iconRight} class={iconSize} />
</span>
)}
</div>
{p.suffixText && (
<span class={cn(addonBase, "border-l-0 rounded-l-none")}>
{p.suffixText}
</span>
)}
</div>
)
}
{
p.kind === "textarea" && (
<div class={controlWrap}>
<textarea
id={fieldId}
name={p.name}
rows={p.rows ?? 4}
placeholder={p.placeholder}
required={p.required}
disabled={p.disabled}
data-control
aria-invalid={!!p.error}
aria-describedby={
[descId, p.error ? errId : undefined]
.filter(Boolean)
.join(" ") || undefined
}
class={cn(
"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",
p.size === "sm"
? "text-sm p-3"
: p.size === "lg"
? "text-base p-4"
: "text-sm p-3",
)}
data-initial={typeof p.value === "string" ? p.value : ""}
/>
</div>
)
}
{
p.kind === "select" && (
<div class={controlWrap}>
<select
id={fieldId}
name={p.name}
multiple={p.multiple}
required={p.required}
disabled={p.disabled}
data-control
aria-invalid={!!p.error}
aria-describedby={
[descId, p.error ? errId : undefined]
.filter(Boolean)
.join(" ") || undefined
}
class={cn(
selectBase({ size: p.size ?? "md" }),
"appearance-none bg-[length:16px_16px] pr-9",
)}
>
{!p.multiple && p.placeholder && (
<option
value=""
disabled
selected={p.value ? undefined : true}
>
{p.placeholder}
</option>
)}
{(p.options ?? []).map((opt) => (
<option
value={opt.value}
selected={String(p.value) === opt.value}
disabled={opt.disabled}
>
{opt.label}
</option>
))}
</select>
<span class="pointer-events-none absolute inset-y-0 right-0 pr-3 flex items-center text-muted-foreground">
<Icon name="mdi:chevron-down" class={iconSize} />
</span>
</div>
)
}
{
p.kind === "checkbox" && (
<label class="flex items-center gap-3">
<input
id={fieldId}
name={p.name}
type="checkbox"
class="shrink-0 mt-0.5 border-gray-200 rounded-sm text-blue-600 focus:ring-blue-500 checked:border-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-700 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-800"
checked={!!p.value}
disabled={p.disabled}
data-control
aria-invalid={!!p.error}
aria-describedby={
[descId, p.error ? errId : undefined]
.filter(Boolean)
.join(" ") || undefined
}
/>
<div>
{p.label && (
<span class="text-sm text-foreground">{p.label}</span>
)}
{p.description && (
<p id={descId} class={helpCls}>
{p.description}
</p>
)}
</div>
</label>
)
}
{
p.kind === "radio" && (
<fieldset class="space-y-2">
{p.label && (
<legend class="text-sm font-medium text-foreground">
{p.label}
</legend>
)}
<div class="space-x-3">
{(p.options ?? []).map((opt) => (
<label class="inline-flex items-center gap-2">
<input
type="radio"
name={p.name}
value={opt.value}
checked={String(p.value) === opt.value}
class="shrink-0 mt-0.5 border-gray-200 rounded-full text-blue-600 focus:ring-blue-500 checked:border-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-700 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-800"
disabled={opt.disabled || p.disabled}
data-control
/>
<span class="text-sm text-foreground">
{opt.label}
</span>
</label>
))}
</div>
</fieldset>
)
}
{
p.kind === "switch" && (
<div class="flex items-center gap-3">
<label class="relative inline-block w-11 h-6 cursor-pointer">
<input
id={fieldId}
name={p.name}
type="checkbox"
class="sr-only peer"
checked={!!p.value}
disabled={p.disabled}
data-control
/>
<span class="absolute inset-0 bg-gray-200 rounded-full transition-colors duration-200 ease-in-out peer-checked:bg-blue-600 dark:bg-neutral-700 dark:peer-checked:bg-blue-500 peer-disabled:opacity-50 peer-disabled:pointer-events-none" />
<span class="absolute top-1/2 start-0.5 -translate-y-1/2 size-5 bg-white rounded-full shadow-xs transition-transform duration-200 ease-in-out peer-checked:translate-x-full dark:bg-neutral-400 dark:peer-checked:bg-white" />
</label>
{p.label && (
<label
for={fieldId}
class="text-sm text-gray-500 dark:text-neutral-400"
>
{p.label}
</label>
)}
</div>
)
}
{
p.description && !["checkbox", "radio"].includes(p.kind ?? "text") && (
<p id={descId} class={helpCls}>
{p.description}
</p>
)
}
<!-- Always-present error container (client fills it) -->
<p id={errId} class={errCls} hidden>{p.error ?? ""}</p>
</div>
+390
View File
@@ -0,0 +1,390 @@
---
import type { HTMLAttributes } from "astro/types";
import { cn } from "../utils/cn";
import Button from "./Button.astro";
interface Props extends HTMLAttributes<"form"> {
title?: string;
description?: string;
columns?: 1 | 2;
schemaName?: string;
onSubmit?: string;
submitLabel?: string;
submitIcon?: string;
actionsAlign?: "start" | "center" | "end";
resetOnSubmit?: boolean;
validateOn?: string;
showSuccesInput?: boolean;
}
const {
title,
description,
columns = 1,
class: className,
schemaName,
onSubmit,
submitLabel = "Submit",
submitIcon = "",
actionsAlign = "end",
resetOnSubmit = false,
validateOn = "input,blur,change",
showSuccesInput = false,
...rest
}: Props = Astro.props;
const gridCls =
columns === 2 ? "grid grid-cols-1 sm:grid-cols-2 gap-4" : "space-y-4";
const actionsJustify =
actionsAlign === "start"
? "justify-start"
: actionsAlign === "center"
? "justify-center"
: "justify-end";
const formId = `f-${Math.random().toString(36).slice(2)}`;
---
<form
id={formId}
class={cn("space-y-4", className)}
{...rest}
data-schema={schemaName ?? ""}
data-onsubmit={onSubmit ?? ""}
data-reset={resetOnSubmit ? "1" : ""}
data-validateon={validateOn}
novalidate
>
<div>
{
title && (
<h2 class="text-lg font-semibold text-foreground">{title}</h2>
)
}
{
description && (
<p class="text-sm text-muted-foreground mt-1 text-gray-600 dark:text-neutral-400">
{description}
</p>
)
}
</div>
<div class={gridCls}>
<slot />
</div>
<div class={cn("flex gap-3", actionsJustify)}>
<Button type="submit" variant="solid" color="primary" icon={submitIcon}>
{submitLabel}
</Button>
<slot name="actions" />
</div>
</form>
<!-- Client validation & submit hook (no zod import here) -->
<script type="module" define:vars={{ formId, showSuccesInput }}>
const INVALID_KLS = ["!border-danger", "focus-visible:ring-danger/40"];
const VALID_KLS = ["!border-success", "focus-visible:ring-success/40"];
const form = document.getElementById(formId);
if (!form) throw new Error("Form element not found");
function getSchema() {
const name = form.dataset.schema;
if (!name) return null;
const val = window[name];
if (!val) {
console.warn("Schema not found:", name);
return null;
}
return typeof val === "function" ? val() : val;
}
// Prefer explicit [data-control]; fall back to any input/select/textarea
function fieldControls(root) {
// Prefer explicit data-control, fallback to any control
let ctrls = root.querySelectorAll(
"input[data-control], select[data-control], textarea[data-control]",
);
if (ctrls.length === 0)
ctrls = root.querySelectorAll("input,select,textarea");
const all = Array.from(ctrls);
const radios = all.filter(
(el) => el instanceof HTMLInputElement && el.type === "radio",
);
const checks = all.filter(
(el) => el instanceof HTMLInputElement && el.type === "checkbox",
);
const err = root.querySelector('[id$="-err"]');
const isRadioGroup = radios.length > 1;
const isMultiCheckbox = checks.length > 1;
return {
ctrls: all, // includes the readonly summary input if present
err,
isRadioGroup,
isMultiCheckbox,
checkboxes: checks, // handy for multi-select
};
}
function setError(root, msg) {
const { ctrls, err } = fieldControls(root);
if (!ctrls.length) return;
const first = ctrls[0];
if (msg) {
if (showSuccesInput) first.classList.remove(...VALID_KLS);
first.classList.add(...INVALID_KLS);
first.setAttribute("aria-invalid", "true");
if (err) {
err.textContent = msg;
err.hidden = false;
}
} else {
first.classList.remove(...INVALID_KLS);
if (showSuccesInput) first.classList.add(...VALID_KLS);
first.setAttribute("aria-invalid", "false");
if (err) {
err.textContent = "";
err.hidden = true;
}
}
}
function clearState(root) {
const { ctrls, err } = fieldControls(root);
ctrls.forEach((c) => c.classList.remove(...INVALID_KLS, ...VALID_KLS));
ctrls[0]?.removeAttribute("aria-invalid");
if (err) {
err.textContent = "";
err.hidden = true;
}
}
// Robust data collector (radios, checkboxes, multi-select, files)
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) {
if (checks.length > 1) {
data[name] = checks
.filter((el) => el.checked)
.map((el) => el.value || "on");
} else {
data[name] = 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 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])
: null;
if (!key) continue;
const root = formEl.querySelector(
`[data-field="${CSS.escape(key)}"]`,
);
if (!root) continue;
setError(root, issue.message);
if (!firstRoot) firstRoot = root;
}
if (firstRoot) {
const { ctrls } = fieldControls(firstRoot);
ctrls[0]?.focus();
}
}
function validateField(name) {
const schema = getSchema();
if (!schema || !schema.shape || !schema.shape[name]) return;
const root = form.querySelector(`[data-field="${CSS.escape(name)}"]`);
if (!root) return;
const { ctrls, isRadioGroup, isMultiCheckbox, checkboxes } =
fieldControls(root);
if (!ctrls.length) return;
let value;
if (isRadioGroup) {
// one-of
const chosen = ctrls.find(
(el) =>
el instanceof HTMLInputElement &&
el.type === "radio" &&
el.checked,
);
value = chosen ? chosen.value : "";
} else if (isMultiCheckbox) {
// array<string> for MultiSelect / checkbox groups
value = checkboxes
.filter((c) => c.checked)
.map((c) => c.value || "on");
} else {
// single control
const ctrl = ctrls[0];
if (ctrl instanceof HTMLInputElement && ctrl.type === "checkbox") {
value = ctrl.checked;
} else if (
ctrl instanceof HTMLInputElement &&
ctrl.type === "file"
) {
value = ctrl.multiple
? Array.from(ctrl.files || [])
: (ctrl.files?.[0] ?? null);
} else if (ctrl instanceof HTMLSelectElement && ctrl.multiple) {
value = Array.from(ctrl.selectedOptions).map((o) => o.value);
} else {
value = ctrl.value;
}
}
const res = schema.shape[name].safeParse(value);
setError(
root,
res.success ? "" : res.error.issues[0]?.message || "Invalid value",
);
}
const validateOn = (form.dataset.validateon || "input,blur,change")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (validateOn.includes("input")) {
form.addEventListener("input", (e) => {
const root = e.target?.closest?.("[data-field]");
if (!root) return;
const name = root.getAttribute("data-field");
if (name) validateField(name);
});
}
if (validateOn.includes("blur")) {
form.addEventListener(
"blur",
(e) => {
const root = e.target?.closest?.("[data-field]");
if (!root) return;
const name = root.getAttribute("data-field");
if (name) validateField(name);
},
true,
);
}
if (validateOn.includes("change")) {
form.addEventListener("change", (e) => {
const root = e.target?.closest?.("[data-field]");
if (!root) return;
const name = root.getAttribute("data-field");
if (name) validateField(name);
});
}
form.addEventListener("submit", async (e) => {
const schema = getSchema();
const onSubmitName = form.dataset.onsubmit;
if (!schema && !onSubmitName) return; // native submit
e.preventDefault();
if (schema) {
const data = collectData(form);
const result = schema.safeParse(data);
if (!result.success) {
showErrors(form, result.error);
return;
}
form.querySelectorAll("[data-field]").forEach(clearState);
if (!onSubmitName) {
form.submit(); // native submit after validation
return;
}
const btn = form.querySelector('button[type="submit"]');
btn && (btn.disabled = true);
try {
const fn = window[onSubmitName];
const res =
typeof fn === "function"
? await fn(result.data, form)
: null;
if (res !== false && form.dataset.reset) form.reset();
} catch (err) {
console.error("onSubmit error:", err);
} finally {
btn && (btn.disabled = false);
}
} else if (onSubmitName) {
const btn = form.querySelector('button[type="submit"]');
btn && (btn.disabled = true);
try {
const data = collectData(form);
const fn = window[onSubmitName];
const res =
typeof fn === "function" ? await fn(data, form) : null;
if (res !== false && form.dataset.reset) form.reset();
} finally {
btn && (btn.disabled = false);
}
}
});
form.querySelectorAll("textarea[data-initial]").forEach((t) => {
t.value = t.getAttribute("data-initial") || "";
t.removeAttribute("data-initial");
});
</script>
+39
View File
@@ -0,0 +1,39 @@
---
import type { HTMLAttributes } from "astro/types";
import { cn } from "../utils/cn";
import { cva, type VariantProps } from "class-variance-authority";
import { getDynamicIcon } from "../utils/icon";
const iconClass = cva("text-current", {
variants: {
size: {
sm: "size-4",
md: "size-6",
lg: "size-8",
xl: "size-14",
},
},
defaultVariants: {
size: "md",
},
});
interface Props extends HTMLAttributes<"svg">, VariantProps<typeof iconClass> {
size?: "sm" | "md" | "lg" | "xl";
name: string;
}
const { size = "md", name, class: className, ...rest }: Props = Astro.props;
const svg = await getDynamicIcon(name);
---
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class={cn(iconClass({ size, className }))}
{...rest}
>
<Fragment set:html={svg} />
</svg>
+356
View File
@@ -0,0 +1,356 @@
---
import type { HTMLAttributes } from "astro/types";
import { cn } from "../utils/cn";
import Icon from "./Icon.astro";
interface Option {
value: string;
label: string;
disabled?: boolean;
}
interface Props extends HTMLAttributes<"div"> {
id?: string;
name: string;
label?: string;
description?: string;
error?: string;
required?: boolean;
disabled?: boolean;
options: Option[];
value?: string[]; // initial selected values
placeholder?: string; // shown when empty
searchable?: boolean; // show search box in dropdown
clearable?: boolean; // show clear (x) button
maxSelections?: number; // optional selection cap
size?: "sm" | "md" | "lg";
class?: string;
}
const p = Astro.props as Props;
const fieldId = p.id ?? p.name;
const descId = p.description ? `${fieldId}-desc` : undefined;
const errId = `${fieldId}-err`;
const selected = new Set(p.value ?? []);
const summary = selected.size
? Array.from(selected)
.map((v) => p.options.find((o) => o.value === v)?.label ?? v)
.join(", ")
: "";
const sizeCls =
p.size === "sm"
? "h-9 px-3 text-sm"
: p.size === "lg"
? "h-11 px-4 text-base"
: "h-10 px-3 text-sm";
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";
const chipBase =
"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)}`;
---
<div class={cn("space-y-1.5", p.class)} data-field={p.name} id={containerId}>
{
p.label && (
<label
for={fieldId}
class="mb-1.5 block text-sm font-medium text-foreground"
>
{p.label} {p.required && <span class="text-danger">*</span>}
</label>
)
}
<div class="relative">
{
/** Visible control is a readonly input so validation styles apply here */
}
<input
id={fieldId}
type="text"
role="combobox"
aria-expanded="false"
aria-controls={`${containerId}-listbox`}
aria-describedby={[descId, p.error ? errId : undefined]
.filter(Boolean)
.join(" ") || undefined}
class={cn(controlCls, sizeCls, "pr-10 cursor-pointer")}
placeholder={p.placeholder ?? "Select..."}
value={summary}
readonly
data-control
{...p.disabled ? { disabled: true } : {}}
/>
{/** Chevron / Clear */}
<div class="absolute inset-y-0 right-0 flex items-center gap-1 pr-2">
{
p.clearable !== false && selected.size > 0 && !p.disabled && (
<button
type="button"
class="p-1 rounded hover:bg-muted"
data-ms-clear
aria-label="Clear selection"
>
<Icon name="mdi:close" class="size-4" />
</button>
)
}
<button
type="button"
class="p-1 rounded hover:bg-muted"
data-ms-toggle
aria-label="Toggle menu"
>
<Icon name="mdi:chevron-down" class="size-5" />
</button>
</div>
{
/** Selected chips (optional; comment out if you prefer only summary text) */
}
{
selected.size > 0 && (
<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>
)
}
{/** Dropdown */}
<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"
id={`${containerId}-panel`}
>
{
p.searchable !== false && (
<div class="p-2 border-b border-border">
<div class="relative">
<Icon
name="mdi:magnify"
class="absolute left-2 top-1/2 -translate-y-1/2 size-4 text-muted-foreground"
/>
<input
type="text"
class={cn(
"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",
"h-9 pl-8 pr-2 text-sm",
)}
placeholder="Search…"
data-ms-search
{...(p.disabled ? { disabled: true } : {})}
/>
</div>
</div>
)
}
<ul
role="listbox"
aria-multiselectable="true"
id={`${containerId}-listbox`}
class="max-h-56 overflow-auto p-1"
data-ms-list
>
{
p.options.map((opt) => (
<li
class={cn(
"flex items-center gap-2 px-2 py-2 rounded hover:bg-muted",
opt.disabled && "opacity-60 cursor-not-allowed",
)}
data-ms-item
data-label={opt.label.toLowerCase()}
>
<input
type="checkbox"
name={p.name}
value={opt.value}
checked={selected.has(opt.value)}
disabled={p.disabled || opt.disabled}
class="size-4 rounded border-border text-primary accent-primary focus:ring-primary/40"
data-control
/>
<span class="text-sm">{opt.label}</span>
</li>
))
}
</ul>
<div
class="flex items-center justify-between gap-2 p-2 border-t border-border"
>
<button
type="button"
class="text-sm text-primary hover:underline disabled:opacity-50"
data-ms-select-all>Select all</button
>
<div class="flex items-center gap-2">
<button
type="button"
class="text-sm text-muted-foreground hover:underline"
data-ms-cancel>Cancel</button
>
<button
type="button"
class="text-sm text-primary hover:underline"
data-ms-apply>Apply</button
>
</div>
</div>
</div>
</div>
{
p.description && (
<p
id={descId}
class="mt-2 text-sm text-gray-500 dark:text-neutral-500"
>
{p.description}
</p>
)
}
<p id={errId} class="mt-1 text-xs text-danger" hidden>{p.error ?? ""}</p>
</div>
<script
type="module"
define:vars={{
containerId,
fieldId,
name: p.name,
maxSelections: p.maxSelections ?? null,
}}
>
const root = document.getElementById(containerId);
const input = root.querySelector(`#${CSS.escape(fieldId)}`);
const panel = root.querySelector(`#${CSS.escape(containerId)}-panel`);
const list = root.querySelector("[data-ms-list]");
const btnTgl = root.querySelector("[data-ms-toggle]");
const btnClr = root.querySelector("[data-ms-clear]");
const btnAll = root.querySelector("[data-ms-select-all]");
const btnCan = root.querySelector("[data-ms-cancel]");
const btnApp = root.querySelector("[data-ms-apply]");
const sch = root.querySelector("[data-ms-search]");
const checks = Array.from(
root.querySelectorAll(
`input[type="checkbox"][name="${CSS.escape(name)}"]`,
),
);
let open = false;
function setExpanded(
v,
{ focusSearch = false, refocusInput = false } = {},
) {
open = v;
input.setAttribute("aria-expanded", String(open));
panel.classList.toggle("hidden", !open);
if (open && focusSearch) sch?.focus();
if (!open && refocusInput) input.focus();
}
function updateSummary() {
const selected = checks.filter((c) => c.checked);
const labels = selected
.slice(0, 3)
.map(
(c) =>
c
.closest("[data-ms-item]")
?.querySelector("span")
?.textContent?.trim() || c.value,
);
const extra = Math.max(0, selected.length - 3);
input.value = selected.length
? labels.join(", ") + (extra ? ` +${extra}` : "")
: "";
}
function enforceMax() {
if (!maxSelections) return;
const count = checks.filter((c) => c.checked).length;
const lock = count >= maxSelections;
checks.forEach((c) => {
if (!c.checked)
c.disabled = lock || c.hasAttribute("data-disabled");
});
}
function selectAll() {
checks.forEach((c) => {
if (!c.disabled) c.checked = true;
});
enforceMax();
updateSummary();
}
function clearAll() {
checks.forEach((c) => {
c.checked = false;
});
enforceMax();
updateSummary();
}
// Open/close
btnTgl?.addEventListener("click", () =>
setExpanded(!open, { focusSearch: !open }),
);
input.addEventListener("click", () =>
setExpanded(!open, { focusSearch: !open }),
);
input.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
e.preventDefault();
setExpanded(true, { focusSearch: true });
}
});
// Actions
btnClr?.addEventListener("click", (e) => {
e.stopPropagation();
clearAll();
});
btnAll?.addEventListener("click", selectAll);
btnCan?.addEventListener("click", () => setExpanded(false)); // no refocus
btnApp?.addEventListener("click", () => setExpanded(false)); // no refocus
list.addEventListener("change", () => {
enforceMax();
updateSummary();
input.dispatchEvent(new Event("change", { bubbles: true }));
});
// Search filter
sch?.addEventListener("input", () => {
const q = sch.value.trim().toLowerCase();
root.querySelectorAll("[data-ms-item]").forEach((li) => {
const m = (li.getAttribute("data-label") || "").includes(q);
li.classList.toggle("hidden", !m);
});
});
// Close on outside click without refocusing input
document.addEventListener("click", (e) => {
if (!root.contains(e.target)) setExpanded(false); // note: no refocusInput
});
// Init
enforceMax();
updateSummary();
</script>