From b382b06c5752ff19e55facb193bd5ba619a07e98 Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Mon, 10 Nov 2025 19:42:33 +0800 Subject: [PATCH] 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. --- packages/ui/package.json | 1 + packages/ui/src/components/index.ts | 27 +- .../Selector/SearchableSelector.tsx | 68 --- .../primitives/Selector/Selector.tsx | 75 --- .../components/primitives/Selector/index.tsx | 13 - .../components/primitives/Selector/types.ts | 79 ---- .../ui/src/components/primitives/combobox.tsx | 2 +- .../ui/src/components/primitives/select.tsx | 179 +++++++ .../components/primitives/Select.stories.tsx | 439 ++++++++++++++++++ yarn.lock | 66 +++ 10 files changed, 700 insertions(+), 249 deletions(-) delete mode 100644 packages/ui/src/components/primitives/Selector/SearchableSelector.tsx delete mode 100644 packages/ui/src/components/primitives/Selector/Selector.tsx delete mode 100644 packages/ui/src/components/primitives/Selector/index.tsx delete mode 100644 packages/ui/src/components/primitives/Selector/types.ts create mode 100644 packages/ui/src/components/primitives/select.tsx create mode 100644 packages/ui/stories/components/primitives/Select.stories.tsx diff --git a/packages/ui/package.json b/packages/ui/package.json index 6620835a24..cec8f55c5a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 56cb212c81..3705c14872 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -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' diff --git a/packages/ui/src/components/primitives/Selector/SearchableSelector.tsx b/packages/ui/src/components/primitives/Selector/SearchableSelector.tsx deleted file mode 100644 index 2d178f93c9..0000000000 --- a/packages/ui/src/components/primitives/Selector/SearchableSelector.tsx +++ /dev/null @@ -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 = (props: SearchableSelectorProps) => { - 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) => ( - - {item.label ?? item.value} - - ) - - return ( - - {children ?? defaultRenderItem} - - ) -} - -export default SearchableSelector diff --git a/packages/ui/src/components/primitives/Selector/Selector.tsx b/packages/ui/src/components/primitives/Selector/Selector.tsx deleted file mode 100644 index 410c953dd3..0000000000 --- a/packages/ui/src/components/primitives/Selector/Selector.tsx +++ /dev/null @@ -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 = (props: SelectorProps) => { - const { items, onSelectionChange, selectedKeys, selectionMode = 'single', children, ...rest } = props - - // 转换 selectedKeys: V | V[] | undefined → Set | undefined - const heroUISelectedKeys = useMemo(() => { - if (selectedKeys === undefined) return undefined - - if (selectionMode === 'multiple') { - // 多选模式: V[] → Set - return new Set((selectedKeys as T['value'][]).map((key) => String(key) as Key)) - } else { - // 单选模式: V → Set - 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 为原始类型 - 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) => ( - - {item.label ?? item.value} - - ) - - return ( - - ) -} - -export default Selector diff --git a/packages/ui/src/components/primitives/Selector/index.tsx b/packages/ui/src/components/primitives/Selector/index.tsx deleted file mode 100644 index 2a1b3359bc..0000000000 --- a/packages/ui/src/components/primitives/Selector/index.tsx +++ /dev/null @@ -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' diff --git a/packages/ui/src/components/primitives/Selector/types.ts b/packages/ui/src/components/primitives/Selector/types.ts deleted file mode 100644 index c8295d5731..0000000000 --- a/packages/ui/src/components/primitives/Selector/types.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { AutocompleteProps, SelectProps } from '@heroui/react' -import type { ReactElement, ReactNode } from 'react' - -interface SelectorItem { - label?: string | ReactNode - value: V - disabled?: boolean - [key: string]: any -} - -// 自定义渲染函数类型 -type SelectorRenderItem = (item: T) => ReactElement - -// 单选模式的 Props -interface SingleSelectorProps - extends Omit, 'children' | 'onSelectionChange' | 'selectedKeys' | 'selectionMode'> { - items: T[] - selectionMode?: 'single' - selectedKeys?: T['value'] - onSelectionChange?: (key: T['value']) => void - children?: SelectorRenderItem -} - -// 多选模式的 Props -interface MultipleSelectorProps - extends Omit, 'children' | 'onSelectionChange' | 'selectedKeys' | 'selectionMode'> { - items: T[] - selectionMode: 'multiple' - selectedKeys?: T['value'][] - onSelectionChange?: (keys: T['value'][]) => void - children?: SelectorRenderItem -} - -type SelectorProps = SingleSelectorProps | MultipleSelectorProps - -interface SearchableSelectorItem { - label?: string | ReactNode - value: V - disabled?: boolean - [key: string]: any -} - -// 自定义渲染函数类型 -type SearchableRenderItem = (item: T) => ReactElement - -// 单选模式的 Props -interface SingleSearchableSelectorProps - extends Omit, 'children' | 'onSelectionChange' | 'selectedKey' | 'selectionMode'> { - items: T[] - selectionMode?: 'single' - selectedKeys?: T['value'] - onSelectionChange?: (key: T['value']) => void - children?: SearchableRenderItem -} - -// 多选模式的 Props -interface MultipleSearchableSelectorProps - extends Omit, 'children' | 'onSelectionChange' | 'selectedKey' | 'selectionMode'> { - items: T[] - selectionMode: 'multiple' - selectedKeys?: T['value'][] - onSelectionChange?: (keys: T['value'][]) => void - children?: SearchableRenderItem -} - -type SearchableSelectorProps = - | SingleSearchableSelectorProps - | MultipleSearchableSelectorProps - -export type { - MultipleSearchableSelectorProps, - MultipleSelectorProps, - SearchableSelectorItem, - SearchableSelectorProps, - SelectorItem, - SelectorProps, - SingleSearchableSelectorProps, - SingleSelectorProps -} diff --git a/packages/ui/src/components/primitives/combobox.tsx b/packages/ui/src/components/primitives/combobox.tsx index 75af6f903c..330a3d5e43 100644 --- a/packages/ui/src/components/primitives/combobox.tsx +++ b/packages/ui/src/components/primitives/combobox.tsx @@ -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: { diff --git a/packages/ui/src/components/primitives/select.tsx b/packages/ui/src/components/primitives/select.tsx new file mode 100644 index 0000000000..f543162e52 --- /dev/null +++ b/packages/ui/src/components/primitives/select.tsx @@ -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) { + return +} + +function SelectGroup({ ...props }: React.ComponentProps) { + return +} + +function SelectValue({ ...props }: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = 'default', + children, + ...props +}: React.ComponentProps & + Omit, 'state'> & { + size?: 'sm' | 'default' + }) { + const state = props.disabled ? 'disabled' : props['aria-invalid'] ? 'error' : 'default' + + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = 'popper', + align = 'center', + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ className, children, ...props }: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ className, ...props }: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue +} diff --git a/packages/ui/stories/components/primitives/Select.stories.tsx b/packages/ui/stories/components/primitives/Select.stories.tsx new file mode 100644 index 0000000000..2bd6c4955b --- /dev/null +++ b/packages/ui/stories/components/primitives/Select.stories.tsx @@ -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 = { + 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 + +// Default +export const Default: Story = { + render: () => ( + + ) +} + +// With Default Value +export const WithDefaultValue: Story = { + render: () => ( + + ) +} + +// With Icons +export const WithIcons: Story = { + render: () => ( + + ) +} + +// With Groups +export const WithGroups: Story = { + render: () => ( + + ) +} + +// Sizes +export const Sizes: Story = { + render: () => ( +
+
+

Small

+ +
+ +
+

Default

+ +
+
+ ) +} + +// Disabled +export const Disabled: Story = { + render: () => ( + + ) +} + +// Disabled Items +export const DisabledItems: Story = { + render: () => ( + + ) +} + +// Controlled +export const Controlled: Story = { + render: function ControlledExample() { + const [value, setValue] = useState('option1') + + return ( +
+ +
Current value: {value}
+
+ ) + } +} + +// All States +export const AllStates: Story = { + render: function AllStatesExample() { + const [normalValue, setNormalValue] = useState('') + const [selectedValue, setSelectedValue] = useState('option2') + + return ( +
+ {/* Normal State */} +
+

Normal State

+ +
+ + {/* Selected State */} +
+

Selected State

+ +
+ + {/* Disabled State */} +
+

Disabled State

+ +
+ + {/* Error State */} +
+

Error State

+ +

Please select an option

+
+
+ ) + } +} + +// 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 ( +
+ {/* Language Selection */} +
+

Language Settings

+ +
+ + {/* Theme Selection */} +
+

Theme Settings

+ +
+ + {/* Timezone Selection (with groups) */} +
+

Timezone Settings

+ +
+ + {/* Required Field Example */} +
+

User Role (Required)

+ +

Please select a user role

+
+
+ ) + } +} + +// Long List +export const LongList: Story = { + render: () => ( + + ) +} diff --git a/yarn.lock b/yarn.lock index da45eee7fb..6215f2df22 100644 --- a/yarn.lock +++ b/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"