first commit
This commit is contained in:
@@ -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