searchable-multi-select.tsx 6.93 KB
"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<string>();
  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<string, string>();
    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 (
    <Popover
      open={open}
      onOpenChange={(o) => {
        setOpen(o);
        if (!o) setQ("");
      }}
    >
      <PopoverTrigger asChild>
        <Button
          type="button"
          variant="outline"
          role="combobox"
          aria-expanded={open}
          disabled={disabled}
          className={cn(
            compact
              ? "w-full justify-between overflow-hidden border border-gray-300 bg-white font-normal shadow-none hover:bg-white"
              : "h-auto min-h-10 w-full justify-between overflow-hidden px-3 py-2 font-normal border border-gray-300 bg-white",
            className,
          )}
        >
          <span
            className={cn(
              "min-w-0 flex-1 truncate text-left",
              compact ? "text-xs leading-tight" : "text-sm",
              !summary && "text-gray-500",
            )}
            title={summary ?? placeholder}
          >
            {summary ?? placeholder}
          </span>
          <ChevronsUpDown
            className={cn(
              "ml-2 shrink-0 self-center opacity-50",
              compact ? "h-3 w-3" : "h-4 w-4",
            )}
          />
        </Button>
      </PopoverTrigger>
      <PopoverContent
        className="w-[var(--radix-popover-trigger-width)] max-w-[min(100vw-2rem,400px)] p-0"
        align="start"
      >
        <div className="flex items-center gap-2 border-b border-gray-100 px-2 py-2">
          <Search className="h-4 w-4 shrink-0 text-gray-400" />
          <Input
            value={q}
            onChange={(e) => setQ(e.target.value)}
            placeholder={searchPlaceholder}
            className="h-8 border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0"
          />
        </div>
        <div className="max-h-[min(240px,50vh)] overflow-y-auto p-1">
          {filtered.length === 0 ? (
            <div className="px-2 py-6 text-center text-sm text-gray-500">{emptyText}</div>
          ) : (
            <>
              {selectAllRowLabel ? (
                <label className="flex cursor-pointer items-center gap-2 rounded-md border-b border-gray-100 px-2 py-2 text-sm font-medium hover:bg-gray-100">
                  <Checkbox
                    checked={
                      allFilteredSelected ? true : someFilteredSelected ? "indeterminate" : false
                    }
                    onCheckedChange={() => toggleSelectAllFiltered()}
                    onClick={(e) => e.stopPropagation()}
                  />
                  <span className="min-w-0 flex-1 truncate">{selectAllRowLabel}</span>
                </label>
              ) : null}
              {filtered.map((opt) => (
                <label
                  key={opt.value}
                  className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-2 text-sm hover:bg-gray-100"
                >
                  <Checkbox
                    checked={normalized.includes(opt.value)}
                    onCheckedChange={() => toggle(opt.value)}
                    onClick={(e) => e.stopPropagation()}
                  />
                  <span className="min-w-0 flex-1 truncate">{opt.label}</span>
                </label>
              ))}
            </>
          )}
        </div>
        {normalized.length > 0 ? (
          <div className="border-t border-gray-100 px-2 py-1.5">
            <button
              type="button"
              className="text-xs text-gray-500 underline-offset-2 hover:text-gray-900 hover:underline"
              onClick={() => {
                onValuesChange([]);
                setQ("");
              }}
            >
              Clear all ({normalized.length})
            </button>
          </div>
        ) : null}
      </PopoverContent>
    </Popover>
  );
}