searchable-select.tsx 5.07 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 { Input } from "./input";
import { Popover, PopoverContent, PopoverTrigger } from "./popover";

export type SearchableSelectOption = {
  value: string;
  label: string;
};

/** 下拉默认可见行数,超出部分滚动 */
const DEFAULT_VISIBLE_LIST_ROWS = 5;
/** 单行高度:py-2 + text-sm 行高 */
const LIST_ROW_PX = 36;

export function SearchableSelect({
  value,
  onValueChange,
  options,
  placeholder = "Select…",
  searchPlaceholder = "Search…",
  emptyText = "No matching results.",
  disabled,
  className,
  /** 顶栏/模板编辑器:与 SelectTrigger !h-7、text-xs 对齐 */
  compact = false,
  /** 下拉列表可见行数(默认 5 行,其余滚动) */
  visibleListRows = DEFAULT_VISIBLE_LIST_ROWS,
}: {
  /** 空字符串表示未选择 */
  value: string;
  onValueChange: (next: string) => void;
  options: SearchableSelectOption[];
  placeholder?: string;
  searchPlaceholder?: string;
  emptyText?: string;
  disabled?: boolean;
  className?: string;
  compact?: boolean;
  visibleListRows?: number;
}) {
  const [open, setOpen] = useState(false);
  const [q, setQ] = useState("");
  const hit = value ? options.find((o) => o.value === value) : undefined;
  const selectedLabel = value ? hit?.label ?? value : null;
  const listMaxHeightPx = Math.max(1, visibleListRows) * LIST_ROW_PX;

  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 closePanel = () => {
    setOpen(false);
    setQ("");
  };

  return (
    <Popover
      modal
      open={open}
      onOpenChange={(next) => {
        setOpen(next);
        if (!next) 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-10 w-full justify-between overflow-hidden px-3 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",
              !selectedLabel && "text-gray-500",
            )}
            title={selectedLabel ?? placeholder}
          >
            {selectedLabel ?? placeholder}
          </span>
          <ChevronsUpDown
            className={cn(
              "ml-2 shrink-0 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 overflow-hidden"
        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="overflow-y-auto overscroll-contain p-1 [scrollbar-gutter:stable]"
          style={{ maxHeight: listMaxHeightPx }}
        >
          {filtered.length === 0 ? (
            <div className="px-2 py-6 text-center text-sm text-gray-500">{emptyText}</div>
          ) : (
            filtered.map((opt) => (
              <button
                key={opt.value}
                type="button"
                className={cn(
                  "flex w-full min-h-9 cursor-pointer items-center rounded-md px-2 py-2 text-left text-sm transition-colors",
                  "hover:bg-gray-100 hover:text-gray-900",
                  value === opt.value && "bg-blue-50 font-medium text-gray-900 hover:bg-blue-100",
                )}
                onClick={() => {
                  onValueChange(opt.value);
                  closePanel();
                }}
              >
                <span className="min-w-0 flex-1 truncate">{opt.label}</span>
              </button>
            ))
          )}
        </div>
        {value ? (
          <div className="border-t border-gray-100 px-2 py-1.5">
            <button
              type="button"
              className="text-xs text-gray-500 hover:text-gray-900 underline-offset-2 hover:underline"
              onClick={() => {
                onValueChange("");
                closePanel();
              }}
            >
              Clear selection
            </button>
          </div>
        ) : null}
      </PopoverContent>
    </Popover>
  );
}