返回 AiToEarn
searchable-select.tsx
根目录 / project / aitoearn-web / src / components / ui / searchable-select.tsx
1 /**
2 * SearchableSelect - 可搜索的下拉选择器组件
3 * 基于 Command 和 Popover 封装,支持搜索过滤选项
4 */
5
6 'use client'
7
8 import { Check, ChevronsUpDown, Loader2, X } from 'lucide-react'
9 import * as React from 'react'
10
11 import { useTransClient } from '@/app/i18n/client'
12 import { Button } from '@/components/ui/button'
13 import {
14 Command,
15 CommandEmpty,
16 CommandGroup,
17 CommandInput,
18 CommandItem,
19 CommandList,
20 } from '@/components/ui/command'
21 import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
22 import { cn } from '@/utils/className'
23
24 export interface SearchableSelectOption {
25 value: string
26 label: string
27 description?: string
28 icon?: string
29 color?: string
30 }
31
32 type SearchableSelectLoadingMode = 'trigger' | 'list'
33
34 export interface SearchableSelectProps {
35 /** 选项列表 */
36 options: SearchableSelectOption[]
37 /** 当前选中的值 */
38 value?: string
39 /** 值改变时的回调 */
40 onValueChange?: (value: string) => void
41 /** 占位符文本 */
42 placeholder?: string
43 /** 搜索框占位符 */
44 searchPlaceholder?: string
45 /** 无结果时显示的文本 */
46 emptyText?: string
47 /** 是否禁用 */
48 disabled?: boolean
49 /** 是否处于加载中 */
50 loading?: boolean
51 /** loading 展示位置:默认在触发器内,远程搜索时可展示在列表内 */
52 loadingMode?: SearchableSelectLoadingMode
53 /** 加载中文案 */
54 loadingText?: string
55 /** 受控搜索关键词 */
56 searchValue?: string
57 /** 搜索关键词变化回调,传入后可用于远程搜索 */
58 onSearchChange?: (value: string) => void
59 /** 是否启用 Command 内置过滤 */
60 shouldFilter?: boolean
61 /** 自定义类名 */
62 className?: string
63 /** 触发器高度,默认 h-8 */
64 triggerClassName?: string
65 /** 是否显示清除按钮 */
66 clearable?: boolean
67 /** 点击清除的回调 */
68 onClear?: () => void
69 }
70
71 function SearchableSelect({
72 options,
73 value,
74 onValueChange,
75 placeholder,
76 searchPlaceholder,
77 emptyText,
78 disabled = false,
79 loading = false,
80 loadingMode = 'trigger',
81 loadingText,
82 searchValue,
83 onSearchChange,
84 shouldFilter = true,
85 className,
86 triggerClassName,
87 clearable,
88 onClear,
89 }: SearchableSelectProps) {
90 const { t } = useTransClient('common')
91 const [open, setOpen] = React.useState(false)
92 const [internalSearchValue, setInternalSearchValue] = React.useState('')
93
94 // 使用传入的文本或国际化默认值
95 const placeholderText = placeholder || t('select.placeholder')
96 const searchPlaceholderText = searchPlaceholder || t('select.searchPlaceholder')
97 const emptyTextDisplay = emptyText || t('select.noResults')
98 const loadingTextDisplay = loadingText || t('actions.loading')
99 const isListLoading = loading && loadingMode === 'list'
100 const isTriggerLoading = loading && loadingMode === 'trigger'
101 const isDisabled = disabled || isTriggerLoading
102 const commandSearchValue = searchValue ?? internalSearchValue
103
104 const handleSearchChange = React.useCallback((nextValue: string) => {
105 if (searchValue === undefined) {
106 setInternalSearchValue(nextValue)
107 }
108 onSearchChange?.(nextValue)
109 }, [onSearchChange, searchValue])
110
111 // 获取当前选中项
112 const selectedOption = React.useMemo(() => {
113 return options.find(option => option.value === value)
114 }, [options, value])
115
116 const selectedLabel = selectedOption?.label
117
118 const listRef = React.useRef<HTMLDivElement>(null)
119
120 React.useEffect(() => {
121 if (!open || !selectedLabel)
122 return
123 // 等 DOM 渲染完成后再滚动
124 requestAnimationFrame(() => {
125 const listEl = listRef.current
126 if (!listEl)
127 return
128 const selectedEl = listEl.querySelector(
129 `[data-value="${CSS.escape(selectedLabel.toLowerCase())}"]`,
130 ) as HTMLElement | null
131 if (!selectedEl)
132 return
133 // 手动计算居中位置,避免 scrollIntoView 影响外层页面滚动
134 listEl.scrollTop = selectedEl.offsetTop - listEl.clientHeight / 2 + selectedEl.clientHeight / 2
135 })
136 }, [open, selectedLabel])
137
138 React.useEffect(() => {
139 if (isTriggerLoading) {
140 setOpen(false)
141 }
142 }, [isTriggerLoading])
143
144 return (
145 <Popover open={open} onOpenChange={setOpen} modal>
146 <PopoverTrigger asChild>
147 <Button
148 variant="outline"
149 role="combobox"
150 aria-expanded={open}
151 aria-busy={loading || undefined}
152 disabled={isDisabled}
153 className={cn(
154 'w-full justify-between font-normal h-8 cursor-pointer',
155 !value && 'text-muted-foreground',
156 triggerClassName,
157 className,
158 )}
159 >
160 <div className="flex items-center gap-2 min-w-0">
161 {selectedOption?.icon && (
162 <img src={selectedOption.icon} alt="" className="w-5 h-5 rounded-sm object-contain shrink-0" />
163 )}
164 <span className="truncate" style={{ color: selectedOption?.color }}>
165 {isTriggerLoading ? loadingTextDisplay : selectedLabel || placeholderText}
166 </span>
167 </div>
168 {isTriggerLoading ? (
169 <Loader2 className="ml-2 h-4 w-4 shrink-0 animate-spin opacity-70" />
170 ) : clearable && !isDisabled && onClear ? (
171 <span
172 role="button"
173 className="ml-2 shrink-0 opacity-50 hover:opacity-100 transition-opacity"
174 onClick={(e) => {
175 e.stopPropagation()
176 e.preventDefault()
177 onClear()
178 }}
179 onPointerDown={(e) => {
180 e.stopPropagation()
181 e.preventDefault()
182 }}
183 >
184 <X className="h-4 w-4" />
185 </span>
186 ) : (
187 <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
188 )}
189 </Button>
190 </PopoverTrigger>
191 <PopoverContent
192 className="w-[--radix-popover-trigger-width] min-w-[200px] p-0"
193 align="start"
194 side="bottom"
195 sideOffset={4}
196 allowInnerScroll
197 onOpenAutoFocus={(e) => {
198 // 移动端不自动聚焦输入框,避免键盘弹出
199 if (window.innerWidth < 768) {
200 e.preventDefault()
201 }
202 }}
203 >
204 <Command
205 value={selectedLabel ?? ''}
206 shouldFilter={shouldFilter}
207 filter={(value, search) => {
208 // 自定义过滤逻辑,支持中文搜索
209 if (value.toLowerCase().includes(search.toLowerCase()))
210 return 1
211 return 0
212 }}
213 >
214 <CommandInput
215 value={commandSearchValue}
216 placeholder={searchPlaceholderText}
217 className="h-9"
218 onValueChange={handleSearchChange}
219 />
220 <CommandList ref={listRef} className="max-h-[40vh] md:max-h-[300px]">
221 {isListLoading && (
222 <div className="flex items-center gap-2 px-3 py-3 text-sm text-muted-foreground">
223 <Loader2 className="h-4 w-4 animate-spin" />
224 <span>{loadingTextDisplay}</span>
225 </div>
226 )}
227 {!isListLoading && <CommandEmpty>{emptyTextDisplay}</CommandEmpty>}
228 <CommandGroup>
229 {options.map(option => (
230 <CommandItem
231 key={option.value}
232 value={option.label}
233 onSelect={() => {
234 onValueChange?.(option.value)
235 setOpen(false)
236 }}
237 className="cursor-pointer"
238 >
239 <Check
240 className={cn(
241 'mr-2 h-4 w-4 shrink-0',
242 value === option.value ? 'opacity-100' : 'opacity-0',
243 )}
244 />
245 {option.icon && (
246 <img src={option.icon} alt="" className="w-5 h-5 rounded-sm object-contain shrink-0" />
247 )}
248 <span className="min-w-0 flex-1">
249 <span className="block truncate" style={{ color: option.color }}>{option.label}</span>
250 {option.description && (
251 <span className="mt-0.5 block truncate text-xs text-muted-foreground">
252 {option.description}
253 </span>
254 )}
255 </span>
256 </CommandItem>
257 ))}
258 </CommandGroup>
259 </CommandList>
260 </Command>
261 </PopoverContent>
262 </Popover>
263 )
264 }
265
266 SearchableSelect.displayName = 'SearchableSelect'
267
268 export { SearchableSelect }
269
269 lines Plain Text