searchable-select.tsx
3.47 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
"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>
);
}