511 lines
21 KiB
Plaintext
511 lines
21 KiB
Plaintext
---
|
|
import type { HTMLAttributes } from "astro/types";
|
|
import { cva } from "class-variance-authority";
|
|
import { cn } from "../utils/cn";
|
|
import { Icon } from "astro-icon/components";
|
|
|
|
type Kind =
|
|
| "text"
|
|
| "email"
|
|
| "password"
|
|
| "number"
|
|
| "textarea"
|
|
| "select"
|
|
| "datetime-local"
|
|
| "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;
|
|
min?: number;
|
|
max?: number;
|
|
step?: number;
|
|
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(
|
|
"wr:py-2.5 wr:sm:py-3 wr:px-4 wr:block wr:w-full wr:border-border wr:rounded-lg wr:sm:text-sm wr:focus:border-border wr:focus:ring-blue-500 wr:disabled:opacity-50 wr:disabled:pointer-events-none wr:dark:bg-input wr:dark:border-border wr:dark:text-neutral-400 wr:dark:placeholder-neutral-500 wr:dark:focus:ring-neutral-600",
|
|
{
|
|
variants: {
|
|
size: {
|
|
sm: "wr:h-10 wr:px-3 wr:text-sm",
|
|
md: "wr:h-11 wr:px-3 wr:text-sm",
|
|
lg: "wr:h-12 wr:px-4 wr:text-base",
|
|
},
|
|
withIconLeft: { true: "wr:pl-10" },
|
|
withIconRight: { true: "wr:pr-10" },
|
|
},
|
|
},
|
|
);
|
|
|
|
const textareaBase = cva(
|
|
"wr:py-2.5 wr:sm:py-3 wr:px-4 wr:block wr:w-full wr:h-[350px] wr:border-border wr:rounded-lg wr:sm:text-sm wr:focus:border-border wr:focus:ring-blue-500 wr:disabled:opacity-50 wr:disabled:pointer-events-none wr:dark:bg-input wr:dark:border-border wr:dark:text-neutral-400 wr:dark:placeholder-neutral-500 wr:dark:focus:ring-neutral-600",
|
|
{
|
|
variants: {
|
|
size: {
|
|
sm: "wr:px-3 wr:text-sm",
|
|
md: "wr:px-3 wr:text-sm",
|
|
lg: "wr:px-4 wr:text-base",
|
|
},
|
|
withIconLeft: { true: "wr:pl-10" },
|
|
withIconRight: { true: "wr:pr-10" },
|
|
},
|
|
},
|
|
);
|
|
|
|
const selectBase = cva(
|
|
"wr:py-3 wr:px-4 wr:pe-9 wr:block wr:w-full wr:border-border wr:rounded-lg wr:text-sm wr:focus:border-border wr:focus:ring-blue-500 wr:disabled:opacity-50 wr:disabled:pointer-events-none wr:dark:bg-input wr:dark:border-border wr:dark:text-neutral-400 wr:dark:placeholder-neutral-500 wr:dark:focus:ring-neutral-600",
|
|
{
|
|
variants: {
|
|
size: {
|
|
sm: "wr:h-10 wr:px-3 wr:text-sm",
|
|
md: "wr:h-11 wr:px-3 wr:text-sm",
|
|
lg: "wr:h-12 wr:px-4 wr:text-base",
|
|
},
|
|
withIconLeft: { true: "wr:pl-10" },
|
|
withIconRight: { true: "wr:pr-10" },
|
|
},
|
|
},
|
|
);
|
|
|
|
const iconSize =
|
|
p.size === "sm" ? "wr:size-4" : p.size === "lg" ? "wr:size-5" : "wr:size-5";
|
|
const labelCls =
|
|
"wr:block wr:text-sm wr:font-medium wr:mb-2 wr:dark:text-white";
|
|
const helpCls = "wr:mt-2 wr:text-sm wr:text-gray-500 wr:dark:text-neutral-500";
|
|
const errCls = "wr:text-sm wr:text-red-600 wr:mt-2 wr:capitalize";
|
|
const groupCls = "wr:relative wr:flex wr:items-stretch";
|
|
const addonBase =
|
|
"wr:px-4 wr:inline-flex wr:items-center wr:min-w-fit wr:rounded-md wr:border wr:border-border wr:bg-gray-200 wr:text-sm wr:text-gray-500 wr:dark:bg-card wr:dark:border-border wr:dark:text-neutral-400";
|
|
const controlWrap = "wr:relative";
|
|
---
|
|
|
|
<div class={cn("wr:space-y-1.5", p.class)} data-field={p.name}>
|
|
{
|
|
p.label &&
|
|
[
|
|
"text",
|
|
"textarea",
|
|
"email",
|
|
"password",
|
|
"number",
|
|
"datetime-local",
|
|
"date",
|
|
"time",
|
|
"file",
|
|
"select",
|
|
].includes(p.kind ?? "text") && (
|
|
<label for={fieldId} class={labelCls}>
|
|
{p.label}{" "}
|
|
{p.required && <span class="wr:text-danger">*</span>}
|
|
</label>
|
|
)
|
|
}
|
|
|
|
{
|
|
[
|
|
"text",
|
|
"email",
|
|
"password",
|
|
"number",
|
|
"date",
|
|
"datetime-local",
|
|
"time",
|
|
].includes(p.kind ?? "text") && (
|
|
<div class={groupCls}>
|
|
{p.prefixText && (
|
|
<span
|
|
class={cn(addonBase, "wr:border-r-0 wr:rounded-r-none")}
|
|
>
|
|
{p.prefixText}
|
|
</span>
|
|
)}
|
|
|
|
<div class={cn(controlWrap, "wr:flex-1")}>
|
|
{p.iconLeft && (
|
|
<span class="wr:absolute wr:inset-y-0 wr:left-0 wr:flex wr:items-center wr:pl-3 wr:pointer-events-none wr: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 && "wr:rounded-l-none",
|
|
p.suffixText && "wr:rounded-r-none",
|
|
p.error &&
|
|
"wr:border-danger wr:focus-visible:ring-danger/40",
|
|
)}
|
|
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
|
|
}
|
|
min={p.kind === "number" ? p.min : undefined}
|
|
max={p.kind === "number" ? p.max : undefined}
|
|
step={p.kind === "number" ? p.step : undefined}
|
|
value={
|
|
typeof p.value === "string" ||
|
|
typeof p.value === "number"
|
|
? String(p.value)
|
|
: undefined
|
|
}
|
|
/>
|
|
|
|
{p.kind === "password" && (
|
|
<button
|
|
type="button"
|
|
class="wr:absolute wr:inset-y-0 wr:right-0 wr:pr-3 wr:flex wr:items-center wr:text-muted-foreground wr: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="wr:absolute wr:inset-y-0 wr:right-0 wr:pr-3 wr:flex wr:items-center wr:pointer-events-none wr:text-muted-foreground">
|
|
<Icon name={p.iconRight} class={iconSize} />
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{p.suffixText && (
|
|
<span
|
|
class={cn(addonBase, "wr:border-l-0 wr:rounded-l-none")}
|
|
>
|
|
{p.suffixText}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
{
|
|
p.kind === "file" && (
|
|
<div class={groupCls}>
|
|
{p.prefixText && (
|
|
<span
|
|
class={cn(addonBase, "wr:border-r-0 wr:rounded-r-none")}
|
|
>
|
|
{p.prefixText}
|
|
</span>
|
|
)}
|
|
|
|
<div class={cn(controlWrap, "wr:flex-1")}>
|
|
{p.iconLeft && (
|
|
<span class="wr:absolute wr:inset-y-0 wr:left-0 wr:flex wr:items-center wr:pl-3 wr:pointer-events-none wr:text-muted-foreground">
|
|
<Icon name={p.iconLeft} class={iconSize} />
|
|
</span>
|
|
)}
|
|
|
|
<input
|
|
id={fieldId}
|
|
name={p.name}
|
|
type={(p.kind as any) ?? "text"}
|
|
class={cn(
|
|
"wr:block wr:w-full wr:border wr:border-border wr:shadow-sm wr:rounded-lg wr:text-sm wr:focus:z-10 wr:focus:border-border wr:focus:ring-blue-500 wr:disabled:opacity-50 wr:disabled:pointer-events-none wr:dark:bg-input wr:dark:border-border wr:dark:text-neutral-400 wr:file:bg-gray-50 wr:file:border-0 wr:file:me-4 wr:file:py-3 wr:file:px-4 wr:dark:file:bg-input wr:dark:file:text-neutral-400",
|
|
p.prefixText && "wr:rounded-l-none",
|
|
p.suffixText && "wr:rounded-r-none",
|
|
p.error &&
|
|
"wr:border-danger wr:focus-visible:ring-danger/40",
|
|
)}
|
|
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="wr:absolute wr:inset-y-0 wr:right-0 wr:pr-3 wr:flex wr:items-center wr:pointer-events-none wr:text-muted-foreground">
|
|
<Icon name={p.iconRight} class={iconSize} />
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{p.suffixText && (
|
|
<span
|
|
class={cn(addonBase, "wr:border-l-0 wr:rounded-l-none")}
|
|
>
|
|
{p.suffixText}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
{
|
|
p.kind === "textarea" && (
|
|
<div class={cn(controlWrap, "wr:flex-1")}>
|
|
{p.iconLeft && (
|
|
<span class="wr:absolute wr:inset-y-0 wr:left-0 wr:flex wr:items-start wr:pl-3 wr:pointer-events-none wr:text-muted-foreground">
|
|
<Icon name={p.iconLeft} class={iconSize} />
|
|
</span>
|
|
)}
|
|
|
|
<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(
|
|
"wr:py-2.5 wr:sm:py-3 wr:px-4 wr:block wr:w-full wr:border-border wr:rounded-lg wr:sm:text-sm wr:focus:border-border wr:focus:ring-blue-500 wr:disabled:opacity-50 wr:disabled:pointer-events-none wr:dark:bg-input wr:dark:border-border wr:dark:text-neutral-400 wr:dark:placeholder-neutral-500 wr:dark:focus:ring-neutral-600",
|
|
p.size === "sm"
|
|
? "wr:text-sm wr:p-3"
|
|
: p.size === "lg"
|
|
? "wr:text-base wr:p-4"
|
|
: "wr:text-sm wr:p-3",
|
|
p.error &&
|
|
"wr:border-danger wr:focus-visible:ring-danger/40",
|
|
textareaBase({
|
|
size: p.size ?? "md",
|
|
withIconLeft: !!p.iconLeft,
|
|
withIconRight: !!p.iconRight,
|
|
}),
|
|
)}
|
|
data-initial={typeof p.value === "string" ? p.value : ""}
|
|
/>
|
|
|
|
{p.iconRight && (
|
|
<span class="wr:absolute wr:inset-y-0 wr:right-0 wr:pr-3 wr:flex wr:items-start wr:pointer-events-none wr:text-muted-foreground">
|
|
<Icon name={p.iconRight} class={iconSize} />
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
{
|
|
p.kind === "select" && (
|
|
<div class={cn(controlWrap, "wr:flex-1")}>
|
|
{p.iconLeft && (
|
|
<span class="wr:absolute wr:inset-y-0 wr:left-0 wr:flex wr:items-center wr:pl-3 wr:pointer-events-none wr:text-muted-foreground">
|
|
<Icon name={p.iconLeft} class={iconSize} />
|
|
</span>
|
|
)}
|
|
<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",
|
|
withIconLeft: !!p.iconLeft,
|
|
withIconRight: !!p.iconRight,
|
|
}),
|
|
"wr:appearance-none wr:bg-none wr:pr-9",
|
|
p.error &&
|
|
"wr:border-danger wr:focus-visible:ring-danger/40",
|
|
)}
|
|
>
|
|
{!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="wr:pointer-events-none wr:absolute wr:inset-y-0 wr:right-0 wr:pr-3 wr:flex wr:items-center wr:text-muted-foreground">
|
|
<Icon name="mdi:chevron-down" class={iconSize} />
|
|
</span>
|
|
|
|
{p.iconRight && (
|
|
<span class="wr:absolute wr:inset-y-0 wr:right-0 wr:pr-3 wr:flex wr:items-center wr:pointer-events-none wr:text-muted-foreground">
|
|
<Icon name={p.iconRight} class={iconSize} />
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
{
|
|
p.kind === "checkbox" && (
|
|
<label class="wr:flex wr:items-center wr:gap-3">
|
|
<input
|
|
id={fieldId}
|
|
name={p.name}
|
|
type="checkbox"
|
|
class="wr:shrink-0 wr:mt-0.5 wr:border-border wr:rounded-sm wr:text-blue-600 wr:focus:ring-blue-500 wr:checked:border-border wr:disabled:opacity-50 wr:disabled:pointer-events-none wr:dark:bg-input wr:dark:border-border wr:dark:checked:bg-blue-500 wr:dark:checked:border-border wr: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="wr:text-sm wr:text-foreground">
|
|
{p.label}
|
|
</span>
|
|
)}
|
|
{p.description && (
|
|
<p id={descId} class={helpCls}>
|
|
{p.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</label>
|
|
)
|
|
}
|
|
|
|
{
|
|
p.kind === "radio" && (
|
|
<fieldset class="wr:space-y-2">
|
|
{p.label && (
|
|
<legend class="wr:text-sm wr:font-medium wr:text-foreground">
|
|
{p.label}
|
|
</legend>
|
|
)}
|
|
<div class="wr:space-x-3">
|
|
{(p.options ?? []).map((opt) => (
|
|
<label class="wr:inline-flex wr:items-center wr:gap-2">
|
|
<input
|
|
type="radio"
|
|
name={p.name}
|
|
value={opt.value}
|
|
checked={String(p.value) === opt.value}
|
|
class="wr:shrink-0 wr:mt-0.5 wr:border-border wr:rounded-full wr:text-blue-600 wr:focus:ring-blue-500 wr:checked:border-border wr:disabled:opacity-50 wr:disabled:pointer-events-none wr:dark:bg-input wr:dark:border-border wr:dark:checked:bg-blue-500 wr:dark:checked:border-border wr:dark:focus:ring-offset-gray-800"
|
|
disabled={opt.disabled || p.disabled}
|
|
data-control
|
|
/>
|
|
<span class="wr:text-sm wr:text-foreground">
|
|
{opt.label}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
)
|
|
}
|
|
|
|
{
|
|
p.kind === "switch" && (
|
|
<div class="wr:flex wr:items-center wr:gap-3">
|
|
<label class="wr:relative wr:inline-block wr:w-11 wr:h-6 wr:cursor-pointer">
|
|
<input
|
|
id={fieldId}
|
|
name={p.name}
|
|
type="checkbox"
|
|
class="wr:sr-only wr:peer"
|
|
checked={!!p.value}
|
|
disabled={p.disabled}
|
|
data-control
|
|
/>
|
|
<span class="wr:absolute wr:inset-0 wr:bg-gray-200 wr:rounded-full wr:transition-colors wr:duration-200 wr:ease-in-out wr:peer-checked:bg-blue-600 wr:dark:bg-input wr:dark:peer-checked:bg-blue-500 wr:peer-disabled:opacity-50 wr:peer-disabled:pointer-events-none" />
|
|
<span class="wr:absolute wr:top-1/2 wr:start-0.5 wr:-translate-y-1/2 wr:size-5 wr:bg-white wr:rounded-full wr:shadow-xs wr:transition-transform wr:duration-200 wr:ease-in-out wr:peer-checked:translate-x-full wr:dark:bg-white wr:dark:peer-checked:bg-white" />
|
|
</label>
|
|
{p.label && (
|
|
<label
|
|
for={fieldId}
|
|
class="wr:text-sm wr:text-gray-500 wr:dark:text-neutral-400"
|
|
>
|
|
{p.label}
|
|
</label>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
{
|
|
p.description && !["checkbox", "radio"].includes(p.kind ?? "text") && (
|
|
<p id={descId} class={helpCls}>
|
|
{p.description}
|
|
</p>
|
|
)
|
|
}
|
|
|
|
<p id={errId} class={errCls} hidden>{p.error ?? ""}</p>
|
|
</div>
|