Files
Astro-Component/src/components/MultiSelect.astro
T
2025-08-17 15:05:44 +05:30

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>