| 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 |