From 12e3a227267edc62041a679b264ec1e1250ddd89 Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Thu, 6 Nov 2025 17:24:45 +0800 Subject: [PATCH] feat: add Combobox component with search and multi-select functionality - 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. --- .../ui/src/components/primitives/combobox.tsx | 313 +++++++++++++ .../primitives/Combobox.stories.tsx | 421 ++++++++++++++++++ 2 files changed, 734 insertions(+) create mode 100644 packages/ui/src/components/primitives/combobox.tsx create mode 100644 packages/ui/stories/components/primitives/Combobox.stories.tsx diff --git a/packages/ui/src/components/primitives/combobox.tsx b/packages/ui/src/components/primitives/combobox.tsx new file mode 100644 index 0000000000..daef43c11d --- /dev/null +++ b/packages/ui/src/components/primitives/combobox.tsx @@ -0,0 +1,313 @@ +'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} /> +} diff --git a/packages/ui/stories/components/primitives/Combobox.stories.tsx b/packages/ui/stories/components/primitives/Combobox.stories.tsx new file mode 100644 index 0000000000..8d273898aa --- /dev/null +++ b/packages/ui/stories/components/primitives/Combobox.stories.tsx @@ -0,0 +1,421 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { ChevronDown, User } from 'lucide-react' +import { useState } from 'react' + +import { Combobox } from '../../../src/components/primitives/combobox' + +const meta: Meta = { + title: 'Components/Primitives/Combobox', + component: Combobox, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'A combobox component with search, single/multiple selection support. Based on shadcn/ui.' + } + } + }, + tags: ['autodocs'], + argTypes: { + size: { + control: { type: 'select' }, + options: ['sm', 'default', 'lg'], + description: 'The size of the combobox' + }, + error: { + control: { type: 'boolean' }, + description: 'Whether the combobox is in error state' + }, + disabled: { + control: { type: 'boolean' }, + description: 'Whether the combobox is disabled' + }, + multiple: { + control: { type: 'boolean' }, + description: 'Enable multiple selection' + }, + searchable: { + control: { type: 'boolean' }, + description: 'Enable search functionality' + } + } +} + +export default meta +type Story = StoryObj + +// Mock data - 根据设计稿中的用户选择场景 +const userOptions = [ + { + value: 'rachel-meyers', + label: 'Rachel Meyers', + description: '@rachel', + icon: ( +
+ RM +
+ ) + }, + { + value: 'john-doe', + label: 'John Doe', + description: '@john', + icon: ( +
+ JD +
+ ) + }, + { + value: 'jane-smith', + label: 'Jane Smith', + description: '@jane', + icon: ( +
+ JS +
+ ) + }, + { + value: 'alex-chen', + label: 'Alex Chen', + description: '@alex', + icon: ( +
+ AC +
+ ) + } +] + +// 简单选项数据 +const simpleOptions = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + { value: 'option3', label: 'Option 3' }, + { value: 'option4', label: 'Option 4' } +] + +// 带图标的简单选项 +const iconOptions = [ + { + value: 'user1', + label: '@rachel', + icon: + }, + { + value: 'user2', + label: '@john', + icon: + }, + { + value: 'user3', + label: '@jane', + icon: + } +] + +// ==================== Stories ==================== + +// Default - 占位符状态 +export const Default: Story = { + args: { + options: simpleOptions, + placeholder: 'Please Select', + width: 280 + } +} + +// 带头像和描述 - 对应设计稿顶部的用户选择器 +export const WithAvatarAndDescription: Story = { + args: { + options: userOptions, + placeholder: 'Please Select', + width: 280 + } +} + +// 已选中状态 - 对应设计稿中有值的状态 +export const WithSelectedValue: Story = { + args: { + options: userOptions, + defaultValue: 'rachel-meyers', + placeholder: 'Please Select', + width: 280 + } +} + +// 带简单图标 - 对应设计稿中间部分 +export const WithSimpleIcon: Story = { + args: { + options: iconOptions, + placeholder: 'Please Select', + width: 280 + } +} + +// 多选模式 - 对应设计稿底部的标签形式 +export const MultipleSelection: Story = { + args: { + multiple: true, + options: userOptions, + placeholder: 'Please Select', + width: 280 + } +} + +// 多选已选中状态 +export const MultipleWithSelectedValues: Story = { + args: { + multiple: true, + options: userOptions, + defaultValue: ['rachel-meyers', 'john-doe'], + placeholder: 'Please Select', + width: 280 + } +} + +// 所有状态展示 - 对应设计稿的三列(Normal, Focus, Error) +export const AllStates: Story = { + render: function AllStatesExample() { + const [normalValue, setNormalValue] = useState('') + const [selectedValue, setSelectedValue] = useState('rachel-meyers') + const [errorValue, setErrorValue] = useState('') + + return ( +
+ {/* Normal State - 默认灰色边框 */} +
+

Normal State

+ setNormalValue(val as string)} + placeholder="Please Select" + width={280} + /> +
+ + {/* Selected State - 绿色边框 (focus 时) */} +
+

Selected State

+ setSelectedValue(val as string)} + placeholder="Please Select" + width={280} + /> +
+ + {/* Error State - 红色边框 */} +
+

Error State

+ setErrorValue(val as string)} + placeholder="Please Select" + width={280} + /> +
+ + {/* Disabled State */} +
+

Disabled State

+ setSelectedValue(val as string)} + placeholder="Please Select" + width={280} + /> +
+
+ ) + } +} + +// 所有尺寸 +export const AllSizes: Story = { + render: function AllSizesExample() { + const [value, setValue] = useState('') + return ( +
+
+

Small

+ setValue(val as string)} + width={280} + /> +
+
+

Default

+ setValue(val as string)} + width={280} + /> +
+
+

Large

+ setValue(val as string)} + width={280} + /> +
+
+ ) + } +} + +// 多选不同状态组合 - 对应设计稿底部区域 +export const MultipleStates: Story = { + render: function MultipleStatesExample() { + const [normalValue, setNormalValue] = useState([]) + const [selectedValue, setSelectedValue] = useState(['rachel-meyers', 'john-doe']) + const [errorValue, setErrorValue] = useState(['rachel-meyers']) + + return ( +
+ {/* Multiple - Normal */} +
+

Multiple - Normal (Empty)

+ setNormalValue(val as string[])} + placeholder="Please Select" + width={280} + /> +
+ + {/* Multiple - With Values */} +
+

Multiple - With Selected Values

+ setSelectedValue(val as string[])} + placeholder="Please Select" + width={280} + /> +
+ + {/* Multiple - Error */} +
+

Multiple - Error State

+ setErrorValue(val as string[])} + placeholder="Please Select" + width={280} + /> +
+
+ ) + } +} + +// 禁用选项 +export const WithDisabledOptions: Story = { + args: { + options: [...userOptions.slice(0, 2), { ...userOptions[2], disabled: true }, ...userOptions.slice(3)], + placeholder: 'Please Select', + width: 280 + } +} + +// 无搜索模式 +export const WithoutSearch: Story = { + args: { + searchable: false, + options: simpleOptions, + width: 280 + } +} + +// 实际使用场景 - 综合展示 +export const RealWorldExamples: Story = { + render: function RealWorldExample() { + const [assignee, setAssignee] = useState('') + const [members, setMembers] = useState([]) + const [status, setStatus] = useState('') + + const statusOptions = [ + { value: 'pending', label: 'Pending', description: 'Waiting for review' }, + { value: 'in-progress', label: 'In Progress', description: 'Currently working' }, + { value: 'completed', label: 'Completed', description: 'Task finished' } + ] + + return ( +
+ {/* 分配任务给单个用户 */} +
+

Assign Task

+ setAssignee(val as string)} + placeholder="Select assignee..." + width={280} + /> +
+ + {/* 添加多个成员 */} +
+

Add Team Members

+ setMembers(val as string[])} + placeholder="Select members..." + width={280} + /> +
+ + {/* 选择状态 */} +
+

Task Status

+ setStatus(val as string)} + placeholder="Select status..." + width={280} + /> +
+ + {/* 错误提示场景 */} +
+

Required Field (Error)

+ {}} + placeholder="This field is required" + width={280} + /> +

Please select at least one option

+
+
+ ) + } +}