first commit
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user