mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-02 18:39:06 +08:00
- Introduced a new Combobox component that supports single and multiple selections, search functionality, and customizable rendering of options. - Implemented variants for different states (default, error, disabled) and sizes (small, default, large). - Added a demo and Storybook stories to showcase various use cases and states of the Combobox.
314 lines
8.9 KiB
TypeScript
314 lines
8.9 KiB
TypeScript
'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<VariantProps<typeof comboboxTriggerVariants>, '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<string | string[]>(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 <span className="text-muted-foreground">{placeholder}</span>
|
|
}
|
|
|
|
const selectedOptions = options.filter((opt) => selectedValues.includes(opt.value))
|
|
|
|
return (
|
|
<div className="flex flex-wrap gap-1 flex-1 min-w-0">
|
|
{selectedOptions.map((option) => (
|
|
<span
|
|
key={option.value}
|
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-success/10 text-success-foreground text-xs">
|
|
{option.label}
|
|
<X
|
|
className="size-3 cursor-pointer hover:text-success"
|
|
onClick={(e) => handleRemoveTag(option.value, e)}
|
|
/>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const selectedOption = options.find((opt) => opt.value === value)
|
|
if (selectedOption) {
|
|
return (
|
|
<div className="flex items-center gap-2 flex-1 min-w-0 truncate">
|
|
{selectedOption.icon}
|
|
<span className="truncate">{selectedOption.label}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return <span className="text-muted-foreground">{placeholder}</span>
|
|
}
|
|
|
|
const renderOptionContent = (option: ComboboxOption) => {
|
|
if (renderOption) {
|
|
return renderOption(option)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{option.icon && <span className="shrink-0">{option.icon}</span>}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="truncate">{option.label}</div>
|
|
{option.description && <div className="text-xs text-muted-foreground truncate">{option.description}</div>}
|
|
</div>
|
|
{isSelected(option.value) && <Check className="size-4 shrink-0 text-success" />}
|
|
</>
|
|
)
|
|
}
|
|
|
|
// ==================== Render ====================
|
|
|
|
const state = disabled ? 'disabled' : error ? 'error' : 'default'
|
|
const triggerWidth = width ? (typeof width === 'number' ? `${width}px` : width) : undefined
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size={size}
|
|
disabled={disabled}
|
|
style={{ width: triggerWidth }}
|
|
className={cn(comboboxTriggerVariants({ state, size }), className)}
|
|
aria-expanded={open}
|
|
aria-invalid={error}>
|
|
{renderTriggerContent()}
|
|
<ChevronDown className="size-4 opacity-50 shrink-0" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className={cn('p-0 rounded-2xs', popoverClassName)} style={{ width: triggerWidth }}>
|
|
<Command>
|
|
{searchable && <CommandInput placeholder={searchPlaceholder} className="h-9" onValueChange={onSearch} />}
|
|
<CommandList>
|
|
<CommandEmpty>{emptyText}</CommandEmpty>
|
|
<CommandGroup>
|
|
{options.map((option) => (
|
|
<CommandItem
|
|
key={option.value}
|
|
value={option.value}
|
|
disabled={option.disabled}
|
|
onSelect={() => handleSelect(option.value)}
|
|
className={cn(comboboxItemVariants({ state: option.disabled ? 'disabled' : 'default' }))}>
|
|
{renderOptionContent(option)}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
{name && <input type="hidden" name={name} value={multiple ? JSON.stringify(value) : (value as string)} />}
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
// ==================== 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 <Combobox options={frameworks} value={value} onChange={(val) => setValue(val as string)} width={200} />
|
|
}
|