mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 10:40:07 +08:00
feat(select): introduce new Select component and related features
- Added a new Select component based on Radix UI, including SelectTrigger, SelectContent, SelectItem, and SelectValue. - Implemented support for groups and separators within the Select component. - Updated package.json to include @radix-ui/react-select as a dependency. - Removed deprecated Selector and SearchableSelector components to streamline the codebase. - Added stories for the Select component to showcase various use cases and configurations.
This commit is contained in:
parent
8246f46e7d
commit
b382b06c57
@ -51,6 +51,7 @@
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@ -41,19 +41,19 @@ export {
|
||||
export { default as SvgSpinners180Ring } from './icons/SvgSpinners180Ring'
|
||||
export { default as ToolsCallingIcon } from './icons/ToolsCallingIcon'
|
||||
|
||||
/* Selector Components */
|
||||
export { default as Selector } from './primitives/Selector'
|
||||
export { default as SearchableSelector } from './primitives/Selector/SearchableSelector'
|
||||
export type {
|
||||
MultipleSearchableSelectorProps,
|
||||
MultipleSelectorProps,
|
||||
SearchableSelectorItem,
|
||||
SearchableSelectorProps,
|
||||
SelectorItem,
|
||||
SelectorProps,
|
||||
SingleSearchableSelectorProps,
|
||||
SingleSelectorProps
|
||||
} from './primitives/Selector/types'
|
||||
// /* Selector Components */
|
||||
// export { default as Selector } from './primitives/select'
|
||||
// export { default as SearchableSelector } from './primitives/Selector/SearchableSelector'
|
||||
// export type {
|
||||
// MultipleSearchableSelectorProps,
|
||||
// MultipleSelectorProps,
|
||||
// SearchableSelectorItem,
|
||||
// SearchableSelectorProps,
|
||||
// SelectorItem,
|
||||
// SelectorProps,
|
||||
// SingleSearchableSelectorProps,
|
||||
// SingleSelectorProps
|
||||
// } from './primitives/Selector/types'
|
||||
|
||||
/* Additional Composite Components */
|
||||
// CodeEditor
|
||||
@ -85,4 +85,5 @@ export * from './primitives/command'
|
||||
export * from './primitives/dialog'
|
||||
export * from './primitives/popover'
|
||||
export * from './primitives/radioGroup'
|
||||
export * from './primitives/select'
|
||||
export * from './primitives/shadcn-io/dropzone'
|
||||
|
||||
@ -1,68 +0,0 @@
|
||||
/**
|
||||
* @deprecated 此组件使用频率为 0 次,不符合 UI 库提取标准(需 ≥3 次)
|
||||
* 计划在未来版本中移除。如需可搜索选择器,请直接使用 HeroUI 的 Autocomplete 组件。
|
||||
*
|
||||
* This component has 0 usages and does not meet the UI library extraction criteria (requires ≥3 usages).
|
||||
* Planned for removal in future versions. Consider using HeroUI's Autocomplete component directly.
|
||||
*/
|
||||
|
||||
import { Autocomplete, AutocompleteItem } from '@heroui/react'
|
||||
import type { Key } from '@react-types/shared'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { SearchableSelectorItem, SearchableSelectorProps } from './types'
|
||||
|
||||
const SearchableSelector = <T extends SearchableSelectorItem>(props: SearchableSelectorProps<T>) => {
|
||||
const { items, onSelectionChange, selectedKeys, selectionMode = 'single', children, ...rest } = props
|
||||
|
||||
// 转换 selectedKeys: V | V[] → Key | undefined (Autocomplete 只支持单选)
|
||||
const autocompleteSelectedKey = useMemo(() => {
|
||||
if (selectedKeys === undefined) return undefined
|
||||
|
||||
if (selectionMode === 'multiple') {
|
||||
// Autocomplete 不支持多选,取第一个
|
||||
const keys = selectedKeys as T['value'][]
|
||||
return keys.length > 0 ? String(keys[0]) : undefined
|
||||
} else {
|
||||
return String(selectedKeys)
|
||||
}
|
||||
}, [selectedKeys, selectionMode])
|
||||
|
||||
// 处理选择变化
|
||||
const handleSelectionChange = (key: Key | null) => {
|
||||
if (!onSelectionChange || key === null) return
|
||||
|
||||
const strKey = String(key)
|
||||
// 尝试转换回数字类型
|
||||
const num = Number(strKey)
|
||||
const value = !isNaN(num) && items.some((item) => item.value === num) ? (num as T['value']) : (strKey as T['value'])
|
||||
|
||||
if (selectionMode === 'multiple') {
|
||||
// 多选模式: 返回数组 (Autocomplete 只支持单选,这里简化处理)
|
||||
;(onSelectionChange as (keys: T['value'][]) => void)([value])
|
||||
} else {
|
||||
// 单选模式: 返回单个值
|
||||
;(onSelectionChange as (key: T['value']) => void)(value)
|
||||
}
|
||||
}
|
||||
|
||||
// 默认渲染函数
|
||||
const defaultRenderItem = (item: T) => (
|
||||
<AutocompleteItem key={String(item.value)} textValue={item.label ? String(item.label) : String(item.value)}>
|
||||
{item.label ?? item.value}
|
||||
</AutocompleteItem>
|
||||
)
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
{...rest}
|
||||
items={items}
|
||||
selectedKey={autocompleteSelectedKey}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
allowsCustomValue={false}>
|
||||
{children ?? defaultRenderItem}
|
||||
</Autocomplete>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchableSelector
|
||||
@ -1,75 +0,0 @@
|
||||
import type { Selection } from '@heroui/react'
|
||||
import { Select, SelectItem } from '@heroui/react'
|
||||
import type { Key } from '@react-types/shared'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { SelectorItem, SelectorProps } from './types'
|
||||
|
||||
const Selector = <T extends SelectorItem>(props: SelectorProps<T>) => {
|
||||
const { items, onSelectionChange, selectedKeys, selectionMode = 'single', children, ...rest } = props
|
||||
|
||||
// 转换 selectedKeys: V | V[] | undefined → Set<Key> | undefined
|
||||
const heroUISelectedKeys = useMemo(() => {
|
||||
if (selectedKeys === undefined) return undefined
|
||||
|
||||
if (selectionMode === 'multiple') {
|
||||
// 多选模式: V[] → Set<Key>
|
||||
return new Set((selectedKeys as T['value'][]).map((key) => String(key) as Key))
|
||||
} else {
|
||||
// 单选模式: V → Set<Key>
|
||||
return new Set([String(selectedKeys) as Key])
|
||||
}
|
||||
}, [selectedKeys, selectionMode])
|
||||
|
||||
// 处理选择变化,转换 Selection → V | V[]
|
||||
const handleSelectionChange = (keys: Selection) => {
|
||||
if (!onSelectionChange) return
|
||||
|
||||
if (keys === 'all') {
|
||||
// 如果是全选,返回所有非禁用项的值
|
||||
const allValues = items.filter((item) => !item.disabled).map((item) => item.value)
|
||||
if (selectionMode === 'multiple') {
|
||||
;(onSelectionChange as (keys: T['value'][]) => void)(allValues)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 转换 Set<Key> 为原始类型
|
||||
const keysArray = Array.from(keys).map((key) => {
|
||||
const strKey = String(key)
|
||||
// 尝试转换回数字类型(如果原始值是数字)
|
||||
const num = Number(strKey)
|
||||
return !isNaN(num) && items.some((item) => item.value === num) ? (num as T['value']) : (strKey as T['value'])
|
||||
})
|
||||
|
||||
if (selectionMode === 'multiple') {
|
||||
// 多选模式: 返回数组
|
||||
;(onSelectionChange as (keys: T['value'][]) => void)(keysArray)
|
||||
} else {
|
||||
// 单选模式: 返回单个值
|
||||
if (keysArray.length > 0) {
|
||||
;(onSelectionChange as (key: T['value']) => void)(keysArray[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 默认渲染函数
|
||||
const defaultRenderItem = (item: T) => (
|
||||
<SelectItem key={String(item.value)} textValue={item.label ? String(item.label) : String(item.value)}>
|
||||
{item.label ?? item.value}
|
||||
</SelectItem>
|
||||
)
|
||||
|
||||
return (
|
||||
<Select
|
||||
{...rest}
|
||||
items={items}
|
||||
selectionMode={selectionMode}
|
||||
selectedKeys={heroUISelectedKeys as 'all' | Iterable<Key> | undefined}
|
||||
onSelectionChange={handleSelectionChange}>
|
||||
{children ?? defaultRenderItem}
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export default Selector
|
||||
@ -1,13 +0,0 @@
|
||||
// 统一导出 Selector 相关组件和类型
|
||||
export { default as SearchableSelector } from './SearchableSelector'
|
||||
export { default } from './Selector'
|
||||
export type {
|
||||
MultipleSearchableSelectorProps,
|
||||
MultipleSelectorProps,
|
||||
SearchableSelectorItem,
|
||||
SearchableSelectorProps,
|
||||
SelectorItem,
|
||||
SelectorProps,
|
||||
SingleSearchableSelectorProps,
|
||||
SingleSelectorProps
|
||||
} from './types'
|
||||
@ -1,79 +0,0 @@
|
||||
import type { AutocompleteProps, SelectProps } from '@heroui/react'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
|
||||
interface SelectorItem<V = string | number> {
|
||||
label?: string | ReactNode
|
||||
value: V
|
||||
disabled?: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// 自定义渲染函数类型
|
||||
type SelectorRenderItem<T> = (item: T) => ReactElement
|
||||
|
||||
// 单选模式的 Props
|
||||
interface SingleSelectorProps<T extends SelectorItem = SelectorItem>
|
||||
extends Omit<SelectProps<T>, 'children' | 'onSelectionChange' | 'selectedKeys' | 'selectionMode'> {
|
||||
items: T[]
|
||||
selectionMode?: 'single'
|
||||
selectedKeys?: T['value']
|
||||
onSelectionChange?: (key: T['value']) => void
|
||||
children?: SelectorRenderItem<T>
|
||||
}
|
||||
|
||||
// 多选模式的 Props
|
||||
interface MultipleSelectorProps<T extends SelectorItem = SelectorItem>
|
||||
extends Omit<SelectProps<T>, 'children' | 'onSelectionChange' | 'selectedKeys' | 'selectionMode'> {
|
||||
items: T[]
|
||||
selectionMode: 'multiple'
|
||||
selectedKeys?: T['value'][]
|
||||
onSelectionChange?: (keys: T['value'][]) => void
|
||||
children?: SelectorRenderItem<T>
|
||||
}
|
||||
|
||||
type SelectorProps<T extends SelectorItem = SelectorItem> = SingleSelectorProps<T> | MultipleSelectorProps<T>
|
||||
|
||||
interface SearchableSelectorItem<V = string | number> {
|
||||
label?: string | ReactNode
|
||||
value: V
|
||||
disabled?: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// 自定义渲染函数类型
|
||||
type SearchableRenderItem<T> = (item: T) => ReactElement
|
||||
|
||||
// 单选模式的 Props
|
||||
interface SingleSearchableSelectorProps<T extends SearchableSelectorItem = SearchableSelectorItem>
|
||||
extends Omit<AutocompleteProps<T>, 'children' | 'onSelectionChange' | 'selectedKey' | 'selectionMode'> {
|
||||
items: T[]
|
||||
selectionMode?: 'single'
|
||||
selectedKeys?: T['value']
|
||||
onSelectionChange?: (key: T['value']) => void
|
||||
children?: SearchableRenderItem<T>
|
||||
}
|
||||
|
||||
// 多选模式的 Props
|
||||
interface MultipleSearchableSelectorProps<T extends SearchableSelectorItem = SearchableSelectorItem>
|
||||
extends Omit<AutocompleteProps<T>, 'children' | 'onSelectionChange' | 'selectedKey' | 'selectionMode'> {
|
||||
items: T[]
|
||||
selectionMode: 'multiple'
|
||||
selectedKeys?: T['value'][]
|
||||
onSelectionChange?: (keys: T['value'][]) => void
|
||||
children?: SearchableRenderItem<T>
|
||||
}
|
||||
|
||||
type SearchableSelectorProps<T extends SearchableSelectorItem = SearchableSelectorItem> =
|
||||
| SingleSearchableSelectorProps<T>
|
||||
| MultipleSearchableSelectorProps<T>
|
||||
|
||||
export type {
|
||||
MultipleSearchableSelectorProps,
|
||||
MultipleSelectorProps,
|
||||
SearchableSelectorItem,
|
||||
SearchableSelectorProps,
|
||||
SelectorItem,
|
||||
SelectorProps,
|
||||
SingleSearchableSelectorProps,
|
||||
SingleSelectorProps
|
||||
}
|
||||
@ -18,7 +18,7 @@ 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',
|
||||
'inline-flex items-center justify-between rounded-2xs border-1 text-sm transition-colors outline-none font-normal bg-zinc-50 dark:bg-zinc-900',
|
||||
{
|
||||
variants: {
|
||||
state: {
|
||||
|
||||
179
packages/ui/src/components/primitives/select.tsx
Normal file
179
packages/ui/src/components/primitives/select.tsx
Normal file
@ -0,0 +1,179 @@
|
||||
import { cn } from '@cherrystudio/ui/utils/index'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
const selectTriggerVariants = cva(
|
||||
'inline-flex items-center justify-between rounded-2xs border-1 text-sm transition-colors outline-none font-normal',
|
||||
{
|
||||
variants: {
|
||||
state: {
|
||||
default: 'bg-zinc-50 dark:bg-zinc-900 border-border aria-expanded:border-primary aria-expanded:ring-3 aria-expanded:ring-primary/20',
|
||||
error: 'bg-zinc-50 dark:bg-zinc-900 border border-destructive! aria-expanded:ring-3 aria-expanded:ring-red-600/20',
|
||||
disabled: 'opacity-50 cursor-not-allowed pointer-events-none bg-zinc-50 dark:bg-zinc-900'
|
||||
},
|
||||
size: {
|
||||
sm: 'px-3 gap-2 h-8',
|
||||
default: 'px-3 gap-2 h-9'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
state: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = 'default',
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> &
|
||||
Omit<VariantProps<typeof selectTriggerVariants>, 'state'> & {
|
||||
size?: 'sm' | 'default'
|
||||
}) {
|
||||
const state = props.disabled ? 'disabled' : props['aria-invalid'] ? 'error' : 'default'
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
selectTriggerVariants({ state, size }),
|
||||
"data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground w-fit whitespace-nowrap *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = 'popper',
|
||||
align = 'center',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
|
||||
)}>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
}
|
||||
439
packages/ui/stories/components/primitives/Select.stories.tsx
Normal file
439
packages/ui/stories/components/primitives/Select.stories.tsx
Normal file
@ -0,0 +1,439 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { Globe, Palette, User } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '../../../src/components/primitives/select'
|
||||
|
||||
const meta: Meta<typeof Select> = {
|
||||
title: 'Components/Primitives/Select',
|
||||
component: Select,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'A dropdown select component based on Radix UI, with support for groups, separators, and custom content.'
|
||||
}
|
||||
}
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
disabled: {
|
||||
control: { type: 'boolean' },
|
||||
description: 'Whether the select is disabled'
|
||||
},
|
||||
defaultValue: {
|
||||
control: { type: 'text' },
|
||||
description: 'Default selected value'
|
||||
},
|
||||
value: {
|
||||
control: { type: 'text' },
|
||||
description: 'Value in controlled mode'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Default
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an option" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
<SelectItem value="option3">Option 3</SelectItem>
|
||||
<SelectItem value="option4">Option 4</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
// With Default Value
|
||||
export const WithDefaultValue: Story = {
|
||||
render: () => (
|
||||
<Select defaultValue="option2">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an option" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
<SelectItem value="option3">Option 3</SelectItem>
|
||||
<SelectItem value="option4">Option 4</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
// With Icons
|
||||
export const WithIcons: Story = {
|
||||
render: () => (
|
||||
<Select defaultValue="user">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a feature" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">
|
||||
<User className="size-4" />
|
||||
User Management
|
||||
</SelectItem>
|
||||
<SelectItem value="theme">
|
||||
<Palette className="size-4" />
|
||||
Theme Settings
|
||||
</SelectItem>
|
||||
<SelectItem value="language">
|
||||
<Globe className="size-4" />
|
||||
Language
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
// With Groups
|
||||
export const WithGroups: Story = {
|
||||
render: () => (
|
||||
<Select>
|
||||
<SelectTrigger className="w-[280px]">
|
||||
<SelectValue placeholder="Select a fruit or vegetable" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Fruits</SelectLabel>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
<SelectItem value="orange">Orange</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Vegetables</SelectLabel>
|
||||
<SelectItem value="carrot">Carrot</SelectItem>
|
||||
<SelectItem value="potato">Potato</SelectItem>
|
||||
<SelectItem value="tomato">Tomato</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
// Sizes
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-muted-foreground">Small</p>
|
||||
<Select defaultValue="option1">
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue placeholder="Small size" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
<SelectItem value="option3">Option 3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-muted-foreground">Default</p>
|
||||
<Select defaultValue="option1">
|
||||
<SelectTrigger size="default">
|
||||
<SelectValue placeholder="Default size" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
<SelectItem value="option3">Option 3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Disabled
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<Select disabled defaultValue="option1">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Disabled select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
<SelectItem value="option3">Option 3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
// Disabled Items
|
||||
export const DisabledItems: Story = {
|
||||
render: () => (
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Some options disabled" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2" disabled>
|
||||
Option 2 (Disabled)
|
||||
</SelectItem>
|
||||
<SelectItem value="option3">Option 3</SelectItem>
|
||||
<SelectItem value="option4" disabled>
|
||||
Option 4 (Disabled)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
// Controlled
|
||||
export const Controlled: Story = {
|
||||
render: function ControlledExample() {
|
||||
const [value, setValue] = useState('option1')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Select value={value} onValueChange={setValue}>
|
||||
<SelectTrigger className="w-[280px]">
|
||||
<SelectValue placeholder="Select an option" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
<SelectItem value="option3">Option 3</SelectItem>
|
||||
<SelectItem value="option4">Option 4</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-sm text-muted-foreground">Current value: {value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// All States
|
||||
export const AllStates: Story = {
|
||||
render: function AllStatesExample() {
|
||||
const [normalValue, setNormalValue] = useState('')
|
||||
const [selectedValue, setSelectedValue] = useState('option2')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Normal State */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-muted-foreground">Normal State</p>
|
||||
<Select value={normalValue} onValueChange={setNormalValue}>
|
||||
<SelectTrigger className="w-[280px]">
|
||||
<SelectValue placeholder="Please select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
<SelectItem value="option3">Option 3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Selected State */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-muted-foreground">Selected State</p>
|
||||
<Select value={selectedValue} onValueChange={setSelectedValue}>
|
||||
<SelectTrigger className="w-[280px]">
|
||||
<SelectValue placeholder="Please select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
<SelectItem value="option3">Option 3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Disabled State */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-muted-foreground">Disabled State</p>
|
||||
<Select disabled value={selectedValue}>
|
||||
<SelectTrigger className="w-[280px]">
|
||||
<SelectValue placeholder="Please select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
<SelectItem value="option3">Option 3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-muted-foreground">Error State</p>
|
||||
<Select value="">
|
||||
<SelectTrigger className="w-[280px]" aria-invalid>
|
||||
<SelectValue placeholder="This field is required" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
<SelectItem value="option3">Option 3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-destructive">Please select an option</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Real World Examples
|
||||
export const RealWorldExamples: Story = {
|
||||
render: function RealWorldExample() {
|
||||
const [language, setLanguage] = useState('zh-CN')
|
||||
const [theme, setTheme] = useState('system')
|
||||
const [timezone, setTimezone] = useState('')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Language Selection */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">Language Settings</h3>
|
||||
<Select value={language} onValueChange={setLanguage}>
|
||||
<SelectTrigger className="w-[280px]">
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="zh-CN">
|
||||
<Globe className="size-4" />
|
||||
Simplified Chinese
|
||||
</SelectItem>
|
||||
<SelectItem value="zh-TW">
|
||||
<Globe className="size-4" />
|
||||
Traditional Chinese
|
||||
</SelectItem>
|
||||
<SelectItem value="en-US">
|
||||
<Globe className="size-4" />
|
||||
English
|
||||
</SelectItem>
|
||||
<SelectItem value="ja-JP">
|
||||
<Globe className="size-4" />
|
||||
Japanese
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Theme Selection */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">Theme Settings</h3>
|
||||
<Select value={theme} onValueChange={setTheme}>
|
||||
<SelectTrigger className="w-[280px]">
|
||||
<SelectValue placeholder="Select theme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">
|
||||
<Palette className="size-4" />
|
||||
Light
|
||||
</SelectItem>
|
||||
<SelectItem value="dark">
|
||||
<Palette className="size-4" />
|
||||
Dark
|
||||
</SelectItem>
|
||||
<SelectItem value="system">
|
||||
<Palette className="size-4" />
|
||||
System
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Timezone Selection (with groups) */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">Timezone Settings</h3>
|
||||
<Select value={timezone} onValueChange={setTimezone}>
|
||||
<SelectTrigger className="w-[280px]">
|
||||
<SelectValue placeholder="Select timezone" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Asia</SelectLabel>
|
||||
<SelectItem value="Asia/Shanghai">Shanghai (UTC+8)</SelectItem>
|
||||
<SelectItem value="Asia/Tokyo">Tokyo (UTC+9)</SelectItem>
|
||||
<SelectItem value="Asia/Seoul">Seoul (UTC+9)</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>America</SelectLabel>
|
||||
<SelectItem value="America/New_York">New York (UTC-5)</SelectItem>
|
||||
<SelectItem value="America/Los_Angeles">Los Angeles (UTC-8)</SelectItem>
|
||||
<SelectItem value="America/Chicago">Chicago (UTC-6)</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Europe</SelectLabel>
|
||||
<SelectItem value="Europe/London">London (UTC+0)</SelectItem>
|
||||
<SelectItem value="Europe/Paris">Paris (UTC+1)</SelectItem>
|
||||
<SelectItem value="Europe/Berlin">Berlin (UTC+1)</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Required Field Example */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">User Role (Required)</h3>
|
||||
<Select value="">
|
||||
<SelectTrigger className="w-[280px]" aria-invalid>
|
||||
<SelectValue placeholder="Select user role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">
|
||||
<User className="size-4" />
|
||||
Administrator
|
||||
</SelectItem>
|
||||
<SelectItem value="editor">
|
||||
<User className="size-4" />
|
||||
Editor
|
||||
</SelectItem>
|
||||
<SelectItem value="viewer">
|
||||
<User className="size-4" />
|
||||
Viewer
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-destructive">Please select a user role</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Long List
|
||||
export const LongList: Story = {
|
||||
render: () => (
|
||||
<Select>
|
||||
<SelectTrigger className="w-[280px]">
|
||||
<SelectValue placeholder="Select a number" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 50 }, (_, i) => (
|
||||
<SelectItem key={i + 1} value={`item-${i + 1}`}>
|
||||
Option {i + 1}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
66
yarn.lock
66
yarn.lock
@ -2018,6 +2018,7 @@ __metadata:
|
||||
"@radix-ui/react-dialog": "npm:^1.1.15"
|
||||
"@radix-ui/react-popover": "npm:^1.1.15"
|
||||
"@radix-ui/react-radio-group": "npm:^1.3.8"
|
||||
"@radix-ui/react-select": "npm:^2.2.6"
|
||||
"@radix-ui/react-slot": "npm:^1.2.3"
|
||||
"@radix-ui/react-use-controllable-state": "npm:^1.2.2"
|
||||
"@storybook/addon-docs": "npm:^10.0.5"
|
||||
@ -6908,6 +6909,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@radix-ui/number@npm:1.1.1":
|
||||
version: 1.1.1
|
||||
resolution: "@radix-ui/number@npm:1.1.1"
|
||||
checksum: 10c0/0570ad92287398e8a7910786d7cee0a998174cdd6637ba61571992897c13204adf70b9ed02d0da2af554119411128e701d9c6b893420612897b438dc91db712b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@radix-ui/primitive@npm:1.1.3":
|
||||
version: 1.1.3
|
||||
resolution: "@radix-ui/primitive@npm:1.1.3"
|
||||
@ -7334,6 +7342,45 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@radix-ui/react-select@npm:^2.2.6":
|
||||
version: 2.2.6
|
||||
resolution: "@radix-ui/react-select@npm:2.2.6"
|
||||
dependencies:
|
||||
"@radix-ui/number": "npm:1.1.1"
|
||||
"@radix-ui/primitive": "npm:1.1.3"
|
||||
"@radix-ui/react-collection": "npm:1.1.7"
|
||||
"@radix-ui/react-compose-refs": "npm:1.1.2"
|
||||
"@radix-ui/react-context": "npm:1.1.2"
|
||||
"@radix-ui/react-direction": "npm:1.1.1"
|
||||
"@radix-ui/react-dismissable-layer": "npm:1.1.11"
|
||||
"@radix-ui/react-focus-guards": "npm:1.1.3"
|
||||
"@radix-ui/react-focus-scope": "npm:1.1.7"
|
||||
"@radix-ui/react-id": "npm:1.1.1"
|
||||
"@radix-ui/react-popper": "npm:1.2.8"
|
||||
"@radix-ui/react-portal": "npm:1.1.9"
|
||||
"@radix-ui/react-primitive": "npm:2.1.3"
|
||||
"@radix-ui/react-slot": "npm:1.2.3"
|
||||
"@radix-ui/react-use-callback-ref": "npm:1.1.1"
|
||||
"@radix-ui/react-use-controllable-state": "npm:1.2.2"
|
||||
"@radix-ui/react-use-layout-effect": "npm:1.1.1"
|
||||
"@radix-ui/react-use-previous": "npm:1.1.1"
|
||||
"@radix-ui/react-visually-hidden": "npm:1.2.3"
|
||||
aria-hidden: "npm:^1.2.4"
|
||||
react-remove-scroll: "npm:^2.6.3"
|
||||
peerDependencies:
|
||||
"@types/react": "*"
|
||||
"@types/react-dom": "*"
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
"@types/react":
|
||||
optional: true
|
||||
"@types/react-dom":
|
||||
optional: true
|
||||
checksum: 10c0/34b2492589c3a4b118a03900d622640033630f30ac93c4a69b3701513117607f4ac3a0d9dd3cad39caa8b6495660f71f3aa9d0074d4eb4dac6804dc0b8408deb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@radix-ui/react-slot@npm:1.2.3, @radix-ui/react-slot@npm:^1.2.3":
|
||||
version: 1.2.3
|
||||
resolution: "@radix-ui/react-slot@npm:1.2.3"
|
||||
@ -7464,6 +7511,25 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@radix-ui/react-visually-hidden@npm:1.2.3":
|
||||
version: 1.2.3
|
||||
resolution: "@radix-ui/react-visually-hidden@npm:1.2.3"
|
||||
dependencies:
|
||||
"@radix-ui/react-primitive": "npm:2.1.3"
|
||||
peerDependencies:
|
||||
"@types/react": "*"
|
||||
"@types/react-dom": "*"
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
"@types/react":
|
||||
optional: true
|
||||
"@types/react-dom":
|
||||
optional: true
|
||||
checksum: 10c0/cf86a37f1cbee50a964056f3dc4f6bb1ee79c76daa321f913aa20ff3e1ccdfafbf2b114d7bb616aeefc7c4b895e6ca898523fdb67710d89bd5d8edb739a0d9b6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@radix-ui/rect@npm:1.1.1":
|
||||
version: 1.1.1
|
||||
resolution: "@radix-ui/rect@npm:1.1.1"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user