"use client"; import * as React from "react"; import { useMemo, useState } from "react"; import { ChevronsUpDown, Search } from "lucide-react"; import { cn } from "./utils"; import { Button } from "./button"; import { Checkbox } from "./checkbox"; import { Input } from "./input"; import { Popover, PopoverContent, PopoverTrigger } from "./popover"; import type { SearchableSelectOption } from "./searchable-select"; export type { SearchableSelectOption }; function dedupeIds(ids: string[]): string[] { const seen = new Set(); const out: string[] = []; for (const id of ids) { const t = id.trim(); if (!t || seen.has(t)) continue; seen.add(t); out.push(t); } return out; } export function SearchableMultiSelect({ values, onValuesChange, options, placeholder = "Select…", searchPlaceholder = "Search…", emptyText = "No matching results.", disabled, className, /** 顶栏/模板编辑器等紧凑行:与 SearchableSelect h-7、text-xs 对齐 */ compact = false, /** 在列表顶部增加一行「全选」:针对当前搜索结果对应的选项值全选 / 清除 */ selectAllRowLabel, }: { values: string[]; onValuesChange: (next: string[]) => void; options: SearchableSelectOption[]; placeholder?: string; searchPlaceholder?: string; emptyText?: string; disabled?: boolean; className?: string; compact?: boolean; selectAllRowLabel?: string; }) { const [open, setOpen] = useState(false); const [q, setQ] = useState(""); const normalized = useMemo(() => dedupeIds(values), [values]); const filtered = useMemo(() => { const s = q.trim().toLowerCase(); if (!s) return options; return options.filter( (o) => o.label.toLowerCase().includes(s) || o.value.toLowerCase().includes(s), ); }, [options, q]); const filteredValues = useMemo(() => filtered.map((o) => o.value).filter(Boolean), [filtered]); const allFilteredSelected = filteredValues.length > 0 && filteredValues.every((v) => normalized.includes(v)); const someFilteredSelected = filteredValues.some((v) => normalized.includes(v)); const labelByValue = useMemo(() => { const m = new Map(); for (const o of options) m.set(o.value, o.label); return m; }, [options]); const summary = useMemo(() => { if (normalized.length === 0) return null; if (normalized.length === 1) { return labelByValue.get(normalized[0]) ?? normalized[0]; } const first = labelByValue.get(normalized[0]) ?? normalized[0]; const second = labelByValue.get(normalized[1]) ?? normalized[1]; if (normalized.length === 2) return `${first}, ${second}`; return `${first}, ${second} +${normalized.length - 2}`; }, [normalized, labelByValue]); const toggle = (id: string) => { const t = id.trim(); if (!t) return; if (normalized.includes(t)) { onValuesChange(normalized.filter((x) => x !== t)); } else { onValuesChange([...normalized, t]); } }; const toggleSelectAllFiltered = () => { if (filteredValues.length === 0) return; if (allFilteredSelected) { onValuesChange(normalized.filter((x) => !filteredValues.includes(x))); } else { onValuesChange(dedupeIds([...normalized, ...filteredValues])); } }; return ( { setOpen(o); if (!o) setQ(""); }} >
setQ(e.target.value)} placeholder={searchPlaceholder} className="h-8 border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0" />
{filtered.length === 0 ? (
{emptyText}
) : ( <> {selectAllRowLabel ? ( ) : null} {filtered.map((opt) => ( ))} )}
{normalized.length > 0 ? (
) : null}
); }