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:
MyPrototypeWhat 2025-11-10 19:42:33 +08:00
parent 8246f46e7d
commit b382b06c57
10 changed files with 700 additions and 249 deletions

View File

@ -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",

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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
}

View File

@ -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: {

View 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
}

View 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>
)
}

View File

@ -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"