'use client' import { Button } from '@cherrystudio/ui/components/primitives/button' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@cherrystudio/ui/components/primitives/command' import { Popover, PopoverContent, PopoverTrigger } from '@cherrystudio/ui/components/primitives/popover' import { cn } from '@cherrystudio/ui/utils/index' import { cva, type VariantProps } from 'class-variance-authority' import { Check, ChevronDown, X } from 'lucide-react' import * as React from 'react' // ==================== Variants ==================== const comboboxTriggerVariants = cva( 'inline-flex items-center justify-between rounded-2xs border-1 text-sm transition-colors outline-none font-normal', { variants: { state: { default: 'border-input bg-background aria-expanded:border-success aria-expanded:ring-2 aria-expanded:ring-success/20', error: 'border-destructive ring-2 ring-destructive/20 aria-expanded:border-destructive aria-expanded:ring-destructive/20', disabled: 'opacity-50 cursor-not-allowed pointer-events-none' }, size: { sm: 'h-8 px-2 text-xs gap-1', default: 'h-9 px-3 gap-2', lg: 'h-10 px-4 gap-2' } }, defaultVariants: { state: 'default', size: 'default' } } ) const comboboxItemVariants = cva( 'relative flex items-center gap-2 px-2 py-1.5 text-sm rounded-2xs cursor-pointer transition-colors outline-none select-none', { variants: { state: { default: 'hover:bg-accent data-[selected=true]:bg-accent', selected: 'bg-success/10 text-success-foreground', disabled: 'opacity-50 cursor-not-allowed pointer-events-none' } }, defaultVariants: { state: 'default' } } ) // ==================== Types ==================== export interface ComboboxOption { value: string label: string disabled?: boolean icon?: React.ReactNode description?: string [key: string]: any } export interface ComboboxProps extends Omit, 'state'> { // 数据源 options: ComboboxOption[] value?: string | string[] defaultValue?: string | string[] onChange?: (value: string | string[]) => void // 模式 multiple?: boolean // 自定义渲染 renderOption?: (option: ComboboxOption) => React.ReactNode renderValue?: (value: string | string[], options: ComboboxOption[]) => React.ReactNode // 搜索 searchable?: boolean searchPlaceholder?: string emptyText?: string onSearch?: (search: string) => void // 状态 error?: boolean disabled?: boolean open?: boolean onOpenChange?: (open: boolean) => void // 样式 placeholder?: string className?: string popoverClassName?: string width?: string | number // 其他 name?: string } // ==================== Component ==================== export function Combobox({ options, value: controlledValue, defaultValue, onChange, multiple = false, renderOption, renderValue, searchable = true, searchPlaceholder = 'Search...', emptyText = 'No results found.', onSearch, error = false, disabled = false, open: controlledOpen, onOpenChange, placeholder = 'Please Select', className, popoverClassName, width, size, name }: ComboboxProps) { // ==================== State ==================== const [internalOpen, setInternalOpen] = React.useState(false) const [internalValue, setInternalValue] = React.useState(defaultValue ?? (multiple ? [] : '')) const open = controlledOpen ?? internalOpen const setOpen = onOpenChange ?? setInternalOpen const value = controlledValue ?? internalValue const setValue = (newValue: string | string[]) => { if (controlledValue === undefined) { setInternalValue(newValue) } onChange?.(newValue) } // ==================== Handlers ==================== const handleSelect = (selectedValue: string) => { if (multiple) { const currentValues = (value as string[]) || [] const newValues = currentValues.includes(selectedValue) ? currentValues.filter((v) => v !== selectedValue) : [...currentValues, selectedValue] setValue(newValues) } else { setValue(selectedValue === value ? '' : selectedValue) setOpen(false) } } const handleRemoveTag = (tagValue: string, e: React.MouseEvent) => { e.stopPropagation() if (multiple) { const currentValues = (value as string[]) || [] setValue(currentValues.filter((v) => v !== tagValue)) } } const isSelected = (optionValue: string): boolean => { if (multiple) { return ((value as string[]) || []).includes(optionValue) } return value === optionValue } // ==================== Render Helpers ==================== const renderTriggerContent = () => { if (renderValue) { return renderValue(value, options) } if (multiple) { const selectedValues = (value as string[]) || [] if (selectedValues.length === 0) { return {placeholder} } const selectedOptions = options.filter((opt) => selectedValues.includes(opt.value)) return (
{selectedOptions.map((option) => ( {option.label} handleRemoveTag(option.value, e)} /> ))}
) } const selectedOption = options.find((opt) => opt.value === value) if (selectedOption) { return (
{selectedOption.icon} {selectedOption.label}
) } return {placeholder} } const renderOptionContent = (option: ComboboxOption) => { if (renderOption) { return renderOption(option) } return ( <> {option.icon && {option.icon}}
{option.label}
{option.description &&
{option.description}
}
{isSelected(option.value) && } ) } // ==================== Render ==================== const state = disabled ? 'disabled' : error ? 'error' : 'default' const triggerWidth = width ? (typeof width === 'number' ? `${width}px` : width) : undefined return ( {searchable && } {emptyText} {options.map((option) => ( handleSelect(option.value)} className={cn(comboboxItemVariants({ state: option.disabled ? 'disabled' : 'default' }))}> {renderOptionContent(option)} ))} {name && } ) } // ==================== Demo (for testing) ==================== const frameworks = [ { value: 'next.js', label: 'Next.js' }, { value: 'sveltekit', label: 'SvelteKit' }, { value: 'nuxt.js', label: 'Nuxt.js' }, { value: 'remix', label: 'Remix' }, { value: 'astro', label: 'Astro' } ] export function ComboboxDemo() { const [value, setValue] = React.useState('') return setValue(val as string)} width={200} /> }