searchable-select.tsx 3.47 KB
"use client";

import * as React from "react";
import { useState } from "react";
import { ChevronsUpDown } from "lucide-react";
import { cn } from "./utils";
import { Button } from "./button";
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "./command";
import { Popover, PopoverContent, PopoverTrigger } from "./popover";

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

export function SearchableSelect({
  value,
  onValueChange,
  options,
  placeholder = "Select…",
  searchPlaceholder = "Search…",
  emptyText = "No matching results.",
  disabled,
  className,
}: {
  /** 空字符串表示未选择 */
  value: string;
  onValueChange: (next: string) => void;
  options: SearchableSelectOption[];
  placeholder?: string;
  searchPlaceholder?: string;
  emptyText?: string;
  disabled?: boolean;
  className?: string;
}) {
  const [open, setOpen] = useState(false);
  const hit = value ? options.find((o) => o.value === value) : undefined;
  const selectedLabel = value ? hit?.label ?? value : null;

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <Button
          type="button"
          variant="outline"
          role="combobox"
          aria-expanded={open}
          disabled={disabled}
          className={cn(
            "w-full justify-between h-10 px-3 font-normal border border-gray-300 bg-white",
            className,
          )}
        >
          <span
            className={cn(
              "truncate text-left text-sm",
              !selectedLabel && "text-gray-500",
            )}
          >
            {selectedLabel ?? placeholder}
          </span>
          <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
        </Button>
      </PopoverTrigger>
      <PopoverContent
        className="p-0 w-[var(--radix-popover-trigger-width)] max-w-[min(100vw-2rem,400px)]"
        align="start"
      >
        <Command>
          <CommandInput placeholder={searchPlaceholder} />
          <CommandList>
            <CommandEmpty>{emptyText}</CommandEmpty>
            <CommandGroup>
              {options.map((opt) => (
                <CommandItem
                  key={opt.value}
                  value={`${opt.label} ${opt.value}`}
                  onSelect={() => {
                    onValueChange(opt.value);
                    setOpen(false);
                  }}
                  className={cn(
                    "cursor-pointer rounded-md px-2 py-2 transition-colors",
                    "hover:bg-gray-100 hover:text-gray-900",
                    "data-[selected=true]:bg-gray-100",
                    value === opt.value &&
                      "bg-blue-50 text-gray-900 font-medium data-[selected=true]:bg-blue-100",
                  )}
                >
                  <span className="truncate">{opt.label}</span>
                </CommandItem>
              ))}
            </CommandGroup>
          </CommandList>
        </Command>
        {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("");
                setOpen(false);
              }}
            >
              Clear selection
            </button>
          </div>
        ) : null}
      </PopoverContent>
    </Popover>
  );
}