mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-30 07:39:06 +08:00
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.
This commit is contained in:
parent
2b1269af92
commit
12e3a22726
313
packages/ui/src/components/primitives/combobox.tsx
Normal file
313
packages/ui/src/components/primitives/combobox.tsx
Normal file
@ -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<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} />
|
||||
}
|
||||
421
packages/ui/stories/components/primitives/Combobox.stories.tsx
Normal file
421
packages/ui/stories/components/primitives/Combobox.stories.tsx
Normal file
@ -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<typeof Combobox> = {
|
||||
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<typeof meta>
|
||||
|
||||
// Mock data - 根据设计稿中的用户选择场景
|
||||
const userOptions = [
|
||||
{
|
||||
value: 'rachel-meyers',
|
||||
label: 'Rachel Meyers',
|
||||
description: '@rachel',
|
||||
icon: (
|
||||
<div className="flex size-6 items-center justify-center rounded-full bg-red-500 text-white text-xs font-medium">
|
||||
RM
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'john-doe',
|
||||
label: 'John Doe',
|
||||
description: '@john',
|
||||
icon: (
|
||||
<div className="flex size-6 items-center justify-center rounded-full bg-blue-500 text-white text-xs font-medium">
|
||||
JD
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'jane-smith',
|
||||
label: 'Jane Smith',
|
||||
description: '@jane',
|
||||
icon: (
|
||||
<div className="flex size-6 items-center justify-center rounded-full bg-green-500 text-white text-xs font-medium">
|
||||
JS
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'alex-chen',
|
||||
label: 'Alex Chen',
|
||||
description: '@alex',
|
||||
icon: (
|
||||
<div className="flex size-6 items-center justify-center rounded-full bg-purple-500 text-white text-xs font-medium">
|
||||
AC
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
// 简单选项数据
|
||||
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: <User className="size-4" />
|
||||
},
|
||||
{
|
||||
value: 'user2',
|
||||
label: '@john',
|
||||
icon: <ChevronDown className="size-4" />
|
||||
},
|
||||
{
|
||||
value: 'user3',
|
||||
label: '@jane',
|
||||
icon: <User className="size-4" />
|
||||
}
|
||||
]
|
||||
|
||||
// ==================== 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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Normal State - 默认灰色边框 */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-muted-foreground">Normal State</p>
|
||||
<Combobox
|
||||
options={userOptions}
|
||||
value={normalValue}
|
||||
onChange={(val) => setNormalValue(val as string)}
|
||||
placeholder="Please Select"
|
||||
width={280}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Selected State - 绿色边框 (focus 时) */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-muted-foreground">Selected State</p>
|
||||
<Combobox
|
||||
options={userOptions}
|
||||
value={selectedValue}
|
||||
onChange={(val) => setSelectedValue(val as string)}
|
||||
placeholder="Please Select"
|
||||
width={280}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error State - 红色边框 */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-muted-foreground">Error State</p>
|
||||
<Combobox
|
||||
error
|
||||
options={userOptions}
|
||||
value={errorValue}
|
||||
onChange={(val) => setErrorValue(val as string)}
|
||||
placeholder="Please Select"
|
||||
width={280}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Disabled State */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-muted-foreground">Disabled State</p>
|
||||
<Combobox
|
||||
disabled
|
||||
options={userOptions}
|
||||
value={selectedValue}
|
||||
onChange={(val) => setSelectedValue(val as string)}
|
||||
placeholder="Please Select"
|
||||
width={280}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 所有尺寸
|
||||
export const AllSizes: Story = {
|
||||
render: function AllSizesExample() {
|
||||
const [value, setValue] = useState('')
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-muted-foreground">Small</p>
|
||||
<Combobox
|
||||
size="sm"
|
||||
options={simpleOptions}
|
||||
value={value}
|
||||
onChange={(val) => setValue(val as string)}
|
||||
width={280}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-muted-foreground">Default</p>
|
||||
<Combobox
|
||||
size="default"
|
||||
options={simpleOptions}
|
||||
value={value}
|
||||
onChange={(val) => setValue(val as string)}
|
||||
width={280}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-muted-foreground">Large</p>
|
||||
<Combobox
|
||||
size="lg"
|
||||
options={simpleOptions}
|
||||
value={value}
|
||||
onChange={(val) => setValue(val as string)}
|
||||
width={280}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 多选不同状态组合 - 对应设计稿底部区域
|
||||
export const MultipleStates: Story = {
|
||||
render: function MultipleStatesExample() {
|
||||
const [normalValue, setNormalValue] = useState<string[]>([])
|
||||
const [selectedValue, setSelectedValue] = useState<string[]>(['rachel-meyers', 'john-doe'])
|
||||
const [errorValue, setErrorValue] = useState<string[]>(['rachel-meyers'])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Multiple - Normal */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-muted-foreground">Multiple - Normal (Empty)</p>
|
||||
<Combobox
|
||||
multiple
|
||||
options={userOptions}
|
||||
value={normalValue}
|
||||
onChange={(val) => setNormalValue(val as string[])}
|
||||
placeholder="Please Select"
|
||||
width={280}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Multiple - With Values */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-muted-foreground">Multiple - With Selected Values</p>
|
||||
<Combobox
|
||||
multiple
|
||||
options={userOptions}
|
||||
value={selectedValue}
|
||||
onChange={(val) => setSelectedValue(val as string[])}
|
||||
placeholder="Please Select"
|
||||
width={280}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Multiple - Error */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-muted-foreground">Multiple - Error State</p>
|
||||
<Combobox
|
||||
multiple
|
||||
error
|
||||
options={userOptions}
|
||||
value={errorValue}
|
||||
onChange={(val) => setErrorValue(val as string[])}
|
||||
placeholder="Please Select"
|
||||
width={280}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用选项
|
||||
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<string[]>([])
|
||||
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 (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* 分配任务给单个用户 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">Assign Task</h3>
|
||||
<Combobox
|
||||
options={userOptions}
|
||||
value={assignee}
|
||||
onChange={(val) => setAssignee(val as string)}
|
||||
placeholder="Select assignee..."
|
||||
width={280}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 添加多个成员 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">Add Team Members</h3>
|
||||
<Combobox
|
||||
multiple
|
||||
options={userOptions}
|
||||
value={members}
|
||||
onChange={(val) => setMembers(val as string[])}
|
||||
placeholder="Select members..."
|
||||
width={280}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 选择状态 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">Task Status</h3>
|
||||
<Combobox
|
||||
options={statusOptions}
|
||||
value={status}
|
||||
onChange={(val) => setStatus(val as string)}
|
||||
placeholder="Select status..."
|
||||
width={280}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 错误提示场景 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">Required Field (Error)</h3>
|
||||
<Combobox
|
||||
error
|
||||
options={userOptions}
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
placeholder="This field is required"
|
||||
width={280}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-destructive">Please select at least one option</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user