395 lines
14 KiB
Plaintext
395 lines
14 KiB
Plaintext
---
|
|
import type { HTMLAttributes } from "astro/types";
|
|
import { cn } from "../utils/cn";
|
|
import { Icon } from "astro-icon/components";
|
|
|
|
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[];
|
|
placeholder?: string;
|
|
searchable?: boolean;
|
|
clearable?: boolean;
|
|
maxSelections?: number;
|
|
size?: "sm" | "md" | "lg";
|
|
display?: "text" | "chips";
|
|
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 summarySSR = selected.size
|
|
? Array.from(selected)
|
|
.map((v) => p.options.find((o) => o.value === v)?.label ?? v)
|
|
.join(", ")
|
|
: "";
|
|
|
|
const sizeCls =
|
|
p.size === "sm"
|
|
? "wr:h-9 wr:px-3 wr:text-sm"
|
|
: p.size === "lg"
|
|
? "wr:h-11 wr:px-4 wr:text-base"
|
|
: "wr:h-10 wr:px-3 wr:text-sm";
|
|
|
|
const controlCls =
|
|
"wr:py-2.5 wr:sm:py-3 wr:px-4 wr:block wr:w-full wr:border-gray-200 wr:rounded-lg wr:sm:text-sm " +
|
|
"wr:focus:border-blue-500 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";
|
|
|
|
const chipBase =
|
|
"wr:inline-flex wr:items-center wr:gap-1 wr:rounded-full wr:px-2 wr:py-0.5 wr:text-xs wr:bg-muted wr:text-foreground";
|
|
|
|
const containerId = `ms-${Math.random().toString(36).slice(2)}`;
|
|
const displayMode = p.display ?? "text";
|
|
---
|
|
|
|
<div class={cn("wr:space-y-1.5", p.class)} data-field={p.name} id={containerId}>
|
|
{
|
|
p.label && (
|
|
<label
|
|
for={fieldId}
|
|
class="wr:mb-1.5 wr:block wr:text-sm wr:font-medium wr:text-foreground"
|
|
>
|
|
{p.label} {p.required && <span class="wr:text-danger">*</span>}
|
|
</label>
|
|
)
|
|
}
|
|
|
|
<div class="wr:relative">
|
|
<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,
|
|
"wr:pr-10 wr:cursor-pointer",
|
|
displayMode === "chips" && "wr:pl-3",
|
|
)}
|
|
placeholder={p.placeholder ?? "Select..."}
|
|
value={displayMode === "text" ? summarySSR : ""}
|
|
readonly
|
|
data-control
|
|
{...p.disabled ? { disabled: true } : {}}
|
|
/>
|
|
|
|
<div
|
|
class="wr:absolute wr:inset-y-0 wr:right-0 wr:flex wr:items-center wr:gap-1 wr:pr-2"
|
|
>
|
|
{
|
|
p.clearable !== false && !p.disabled && (
|
|
<button
|
|
type="button"
|
|
class={cn(
|
|
"wr:p-1 wr:rounded wr:hover:bg-muted",
|
|
selected.size === 0 && "wr:hidden",
|
|
)}
|
|
data-ms-clear
|
|
aria-label="Clear selection"
|
|
>
|
|
<Icon name="mdi:close" class="wr:size-4" />
|
|
</button>
|
|
)
|
|
}
|
|
<button
|
|
type="button"
|
|
class="wr:p-1 wr:rounded wr:hover:bg-muted"
|
|
data-ms-toggle
|
|
aria-label="Toggle menu"
|
|
>
|
|
<Icon name="mdi:chevron-down" class="wr:size-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
data-ms-chips
|
|
class={cn(
|
|
"wr:pointer-events-none wr:absolute wr:left-3 wr:top-1/2 wr:-translate-y-1/2 wr:flex wr:flex-wrap wr:gap-1 wr:pr-14",
|
|
displayMode === "chips" ? "" : "wr:hidden",
|
|
)}
|
|
>
|
|
</div>
|
|
|
|
<div
|
|
class="wr:absolute wr:text-popover-foreground wr:shadow-lg wr:hidden wr:mt-2 wr:z-50 wr:w-full wr:max-h-96 wr:p-1 wr:space-y-0.5 wr:bg-white wr:border wr:border-gray-200 wr:rounded-lg wr:overflow-hidden wr:overflow-y-auto wr:[&::-webkit-scrollbar]:w-2 wr:[&::-webkit-scrollbar-thumb]:rounded-full wr:[&::-webkit-scrollbar-track]:bg-gray-100 wr:[&::-webkit-scrollbar-thumb]:bg-gray-300 wr:dark:[&::-webkit-scrollbar-track]:bg-input wr:dark:[&::-webkit-scrollbar-thumb]:bg-input wr:dark:bg-input wr:dark:border-border"
|
|
id={`${containerId}-panel`}
|
|
>
|
|
{
|
|
p.searchable !== false && (
|
|
<div class="wr:p-2 wr:border-b wr:border-border">
|
|
<div class="wr:relative">
|
|
<Icon
|
|
name="mdi:magnify"
|
|
class="wr:absolute wr:left-2 wr:top-1/2 wr:-translate-y-1/2 wr:size-4 wr:text-muted-foreground"
|
|
/>
|
|
<input
|
|
type="text"
|
|
class={cn(
|
|
"wr:py-2.5 wr:sm:py-3 wr:px-4 wr:block wr:w-full wr:border-gray-200 wr:rounded-lg wr:sm:text-sm wr:focus:border-blue-500 wr:focus:ring-blue-500 wr:disabled:opacity-50 wr:disabled:pointer-events-none wr:dark:bg-input wr:dark:border-neutral-400 wr:dark:text-neutral-400 wr:dark:placeholder-neutral-400 wr:dark:focus:ring-neutral-400",
|
|
"wr:h-9 wr:pl-8 wr:pr-2 wr:text-sm",
|
|
)}
|
|
placeholder="Search…"
|
|
data-ms-search
|
|
{...(p.disabled ? { disabled: true } : {})}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
<ul
|
|
role="listbox"
|
|
aria-multiselectable="true"
|
|
id={`${containerId}-listbox`}
|
|
class="wr:max-h-56 wr:overflow-auto wr:p-1"
|
|
data-ms-list
|
|
>
|
|
{
|
|
p.options.map((opt) => (
|
|
<li
|
|
class={cn(
|
|
"wr:flex wr:items-center wr:gap-2 wr:px-2 wr:py-2 wr:rounded wr:hover:bg-muted",
|
|
opt.disabled &&
|
|
"wr:opacity-60 wr: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="wr:size-4 wr:rounded wr:border-border wr:text-primary wr:accent-primary wr:focus:ring-primary/40"
|
|
data-control
|
|
/>
|
|
<span class="wr:text-sm">{opt.label}</span>
|
|
</li>
|
|
))
|
|
}
|
|
</ul>
|
|
|
|
<div
|
|
class="wr:flex wr:items-center wr:justify-between wr:gap-2 wr:p-2 wr:border-t wr:border-border"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="wr:text-sm wr:text-primary wr:hover:underline wr:disabled:opacity-50"
|
|
data-ms-select-all
|
|
>
|
|
Select all
|
|
</button>
|
|
<div class="wr:flex wr:items-center wr:gap-2">
|
|
<button
|
|
type="button"
|
|
class="wr:text-sm wr:text-muted-foreground wr:hover:underline"
|
|
data-ms-cancel
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="wr:text-sm wr:text-primary wr:hover:underline"
|
|
data-ms-apply
|
|
>
|
|
Apply
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{
|
|
p.description && (
|
|
<p
|
|
id={descId}
|
|
class="wr:mt-2 wr:text-sm wr:text-gray-500 wr:dark:text-input"
|
|
>
|
|
{p.description}
|
|
</p>
|
|
)
|
|
}
|
|
<p id={errId} class="wr:mt-1 wr:text-xs wr:text-danger" hidden>
|
|
{p.error ?? ""}
|
|
</p>
|
|
</div>
|
|
|
|
<script
|
|
type="module"
|
|
define:vars={{
|
|
containerId,
|
|
fieldId,
|
|
name: p.name,
|
|
maxSelections: p.maxSelections ?? null,
|
|
displayMode,
|
|
}}
|
|
>
|
|
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 chips = root.querySelector("[data-ms-chips]");
|
|
|
|
const checks = Array.from(
|
|
root.querySelectorAll(
|
|
`input[type="checkbox"][name="${CSS.escape(name)}"]`,
|
|
),
|
|
);
|
|
let open = false;
|
|
const origPlaceholder = input.getAttribute("placeholder") || "Select...";
|
|
|
|
function setExpanded(v, { focusSearch = false } = {}) {
|
|
open = v;
|
|
input.setAttribute("aria-expanded", String(open));
|
|
panel.classList.toggle("wr:hidden", !open);
|
|
if (open && focusSearch) sch?.focus();
|
|
}
|
|
|
|
function makeChip(label) {
|
|
const span = document.createElement("span");
|
|
span.className =
|
|
"wr:inline-flex wr:items-center wr:gap-1 wr:rounded-full wr:px-2 wr:py-0.5 wr:text-xs wr:bg-muted wr:text-foreground";
|
|
span.textContent = label;
|
|
return span;
|
|
}
|
|
|
|
function toggleClear(show) {
|
|
if (!btnClr) return;
|
|
btnClr.classList.toggle("wr:hidden", !show);
|
|
}
|
|
|
|
function updateSummary() {
|
|
const selected = checks.filter((c) => c.checked);
|
|
const labels = selected.map(
|
|
(c) =>
|
|
c
|
|
.closest("[data-ms-item]")
|
|
?.querySelector("span")
|
|
?.textContent?.trim() || c.value,
|
|
);
|
|
|
|
if (displayMode === "text") {
|
|
const preview = labels.slice(0, 3).join(", ");
|
|
const extra = Math.max(0, labels.length - 3);
|
|
input.value = labels.length
|
|
? preview + (extra ? ` +${extra}` : "")
|
|
: "";
|
|
input.placeholder = labels.length ? "" : origPlaceholder;
|
|
if (chips) {
|
|
chips.innerHTML = "";
|
|
chips.classList.add("wr:hidden");
|
|
}
|
|
} else {
|
|
input.value = "";
|
|
input.placeholder = labels.length ? "" : origPlaceholder;
|
|
if (!chips) return;
|
|
chips.innerHTML = "";
|
|
const max = 3;
|
|
labels
|
|
.slice(0, max)
|
|
.forEach((lab) => chips.appendChild(makeChip(lab)));
|
|
const extra = labels.length - max;
|
|
if (extra > 0) chips.appendChild(makeChip(`+${extra}`));
|
|
chips.classList.toggle("wr:hidden", labels.length === 0);
|
|
}
|
|
|
|
toggleClear(labels.length > 0);
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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 });
|
|
}
|
|
});
|
|
|
|
btnClr?.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
clearAll();
|
|
});
|
|
btnAll?.addEventListener("click", selectAll);
|
|
btnCan?.addEventListener("click", () => setExpanded(false));
|
|
btnApp?.addEventListener("click", () => setExpanded(false));
|
|
|
|
list.addEventListener("change", () => {
|
|
enforceMax();
|
|
updateSummary();
|
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
});
|
|
|
|
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("wr:hidden", !m);
|
|
});
|
|
});
|
|
|
|
document.addEventListener("click", (e) => {
|
|
if (!root.contains(e.target)) setExpanded(false);
|
|
});
|
|
|
|
enforceMax();
|
|
updateSummary();
|
|
</script>
|