diff --git a/packages/ui/src/components/interactive/Selector/index.tsx b/packages/ui/src/components/interactive/Selector/index.tsx index c9fa13aa84..7c441df1ba 100644 --- a/packages/ui/src/components/interactive/Selector/index.tsx +++ b/packages/ui/src/components/interactive/Selector/index.tsx @@ -1,199 +1,55 @@ -// Original path: src/renderer/src/components/Selector.tsx -import type { DropdownProps } from 'antd' -import { Dropdown } from 'antd' -import { Check, ChevronsUpDown } from 'lucide-react' +import type { Selection, SelectProps } from '@heroui/react' +import { Select, SelectItem } from '@heroui/react' import type { ReactNode } from 'react' -import { useEffect, useMemo, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import styled, { css } from 'styled-components' -interface SelectorOption { +interface SelectorItem { label: string | ReactNode value: V - type?: 'group' - options?: SelectorOption[] disabled?: boolean + [key: string]: any } -interface BaseSelectorProps { - options: SelectorOption[] - placeholder?: string - placement?: 'topLeft' | 'topCenter' | 'topRight' | 'bottomLeft' | 'bottomCenter' | 'bottomRight' | 'top' | 'bottom' - /** 字体大小 */ - size?: number - /** 是否禁用 */ - disabled?: boolean +interface SelectorProps extends Omit { + items: SelectorItem[] + onSelectionChange?: (keys: V[]) => void } -interface SingleSelectorProps extends BaseSelectorProps { - multiple?: false - value?: V - onChange: (value: V) => void -} - -interface MultipleSelectorProps extends BaseSelectorProps { - multiple: true - value?: V[] - onChange: (value: V[]) => void -} - -type SelectorProps = SingleSelectorProps | MultipleSelectorProps - -const Selector = ({ - options, - value, - onChange = () => {}, - placement = 'bottomRight', - size = 13, - placeholder, - disabled = false, - multiple = false -}: SelectorProps) => { - const [open, setOpen] = useState(false) - const { t } = useTranslation() - const inputRef = useRef(null) - - useEffect(() => { - let timer: NodeJS.Timeout - if (open) { - timer = setTimeout(() => { - inputRef.current?.focus() - }, 1) +const Selector = ({ items, onSelectionChange, ...rest }: SelectorProps) => { + // 处理选择变化,转换 Set 为数组 + const handleSelectionChange = (keys: Selection) => { + if (!onSelectionChange) return + if (keys === 'all') { + // 如果是全选,返回所有非禁用项的值 + const allValues = items.filter((item) => !item.disabled).map((item) => item.value) + onSelectionChange(allValues) + return } - return () => { - clearTimeout(timer) - } - }, [open]) - const selectedValues = useMemo(() => { - if (multiple) { - return (value as V[]) || [] - } - return value !== undefined ? [value as V] : [] - }, [value, multiple]) + // 转换 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 : strKey + }) as V[] - const label = useMemo(() => { - if (selectedValues.length > 0) { - const findLabels = (opts: SelectorOption[]): (string | ReactNode)[] => { - const labels: (string | ReactNode)[] = [] - for (const opt of opts) { - if (selectedValues.some((v) => v == opt.value)) { - labels.push(opt.label) - } - if (opt.options) { - labels.push(...findLabels(opt.options)) - } - } - return labels - } - const labels = findLabels(options) - if (labels.length === 0) return placeholder - if (labels.length === 1) return labels[0] - return t('common.selectedItems', { count: labels.length }) - } - return placeholder - }, [selectedValues, placeholder, options, t]) - - const items = useMemo(() => { - const mapOption = (option: SelectorOption) => ({ - key: option.value, - label: option.label, - extra: {selectedValues.some((v) => v == option.value) && }, - disabled: option.disabled, - type: option.type || (option.options ? 'group' : undefined), - children: option.options?.map(mapOption) - }) - - return options.map(mapOption) - }, [options, selectedValues]) - - function onClick(e: { key: string }) { - if (disabled) return - - const newValue = e.key as V - if (multiple) { - const newValues = selectedValues.includes(newValue) - ? selectedValues.filter((v) => v !== newValue) - : [...selectedValues, newValue] - ;(onChange as MultipleSelectorProps['onChange'])(newValues) - } else { - ;(onChange as SingleSelectorProps['onChange'])(newValue) - setOpen(false) - } - } - - const handleOpenChange: DropdownProps['onOpenChange'] = (nextOpen, info) => { - if (disabled) return - - if (info.source === 'trigger' || nextOpen) { - setOpen(nextOpen) - } + onSelectionChange(keysArray) } return ( - - - + ) } -const LabelIcon = styled(ChevronsUpDown)` - border-radius: 4px; - padding: 2px 0; - background-color: var(--color-background-soft); - transition: background-color 0.2s; -` - -const Label = styled.div<{ $size: number; $open: boolean; $disabled: boolean; $isPlaceholder: boolean }>` - display: flex; - align-items: center; - gap: 4px; - border-radius: 99px; - padding: 3px 2px 3px 10px; - font-size: ${({ $size }) => $size}px; - line-height: 1; - cursor: ${({ $disabled }) => ($disabled ? 'not-allowed' : 'pointer')}; - opacity: ${({ $disabled }) => ($disabled ? 0.6 : 1)}; - color: ${({ $isPlaceholder }) => ($isPlaceholder ? 'var(--color-text-2)' : 'inherit')}; - - transition: - background-color 0.2s, - opacity 0.2s; - &:hover { - ${({ $disabled }) => - !$disabled && - css` - background-color: var(--color-background-mute); - ${LabelIcon} { - background-color: var(--color-background-mute); - } - `} - } - ${({ $open, $disabled }) => - $open && - !$disabled && - css` - background-color: var(--color-background-mute); - ${LabelIcon} { - background-color: var(--color-background-mute); - } - `} -` - -const CheckIcon = styled.div` - width: 20px; - display: flex; - align-items: center; - justify-content: end; -` - export default Selector +export type { SelectorItem, SelectorProps } diff --git a/packages/ui/stories/components/interactive/Selector.stories.tsx b/packages/ui/stories/components/interactive/Selector.stories.tsx new file mode 100644 index 0000000000..3a6fe29bc3 --- /dev/null +++ b/packages/ui/stories/components/interactive/Selector.stories.tsx @@ -0,0 +1,219 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { useState } from 'react' + +import Selector from '../../../src/components/interactive/Selector' + +const meta: Meta = { + title: 'Interactive/Selector', + component: Selector, + parameters: { + layout: 'padded' + }, + tags: ['autodocs'], + argTypes: { + items: { + control: false, + description: '选项数组' + }, + selectedKeys: { + control: false, + description: '选中的键值集合' + }, + onSelectionChange: { + control: false, + description: '选择变化回调函数' + }, + selectionMode: { + control: 'select', + options: ['single', 'multiple'], + description: '选择模式' + }, + placeholder: { + control: 'text', + description: '占位符文本' + }, + size: { + control: 'select', + options: ['sm', 'md', 'lg'], + description: 'HeroUI 大小变体' + }, + isDisabled: { + control: 'boolean', + description: '是否禁用' + }, + className: { + control: 'text', + description: '自定义类名' + } + } +} + +export default meta +type Story = StoryObj + +// 基础用法 +export const Default: Story = { + render: function Render() { + const [selectedKeys, setSelectedKeys] = useState>(new Set(['react'])) + + return ( +
+ setSelectedKeys(new Set(keys.map(String)))} + placeholder="选择框架" + items={[ + { value: 'react', label: 'React' }, + { value: 'vue', label: 'Vue' }, + { value: 'angular', label: 'Angular' }, + { value: 'svelte', label: 'Svelte' } + ]} + /> +
+ 当前选择: {Array.from(selectedKeys).join(', ')} +
+
+ ) + } +} + +// 多选模式 +export const Multiple: Story = { + render: function Render() { + const [selectedKeys, setSelectedKeys] = useState>(new Set(['react', 'vue'])) + + return ( +
+ setSelectedKeys(new Set(keys.map(String)))} + placeholder="选择多个框架" + items={[ + { value: 'react', label: 'React' }, + { value: 'vue', label: 'Vue' }, + { value: 'angular', label: 'Angular' }, + { value: 'svelte', label: 'Svelte' }, + { value: 'solid', label: 'Solid' } + ]} + /> +
+ 已选择 ({selectedKeys.size}): {Array.from(selectedKeys).join(', ')} +
+
+ ) + } +} + +// 数字值类型 +export const NumberValues: Story = { + render: function Render() { + const [selectedKeys, setSelectedKeys] = useState>(new Set(['2'])) + const [selectedValue, setSelectedValue] = useState(2) + + return ( +
+ { + setSelectedKeys(new Set(keys.map(String))) + setSelectedValue(keys[0] as number) + }} + placeholder="选择优先级" + items={[ + { value: 1, label: '🔴 紧急' }, + { value: 2, label: '🟠 高' }, + { value: 3, label: '🟡 中' }, + { value: 4, label: '🟢 低' } + ]} + /> +
+ 优先级值: {selectedValue} (类型: {typeof selectedValue}) +
+
+ ) + } +} + +// 不同大小 +export const Sizes: Story = { + render: function Render() { + const items = [ + { value: 'option1', label: '选项 1' }, + { value: 'option2', label: '选项 2' }, + { value: 'option3', label: '选项 3' } + ] + + return ( +
+
+ + +
+
+ + +
+
+ + +
+
+ ) + } +} + +// 禁用状态 +export const Disabled: Story = { + args: { + isDisabled: true, + selectedKeys: new Set(['react']), + placeholder: '禁用的选择器', + items: [ + { value: 'react', label: 'React' }, + { value: 'vue', label: 'Vue' } + ] + } +} + +// 实际应用场景:语言选择 +export const LanguageSelector: Story = { + render: function Render() { + const [selectedKeys, setSelectedKeys] = useState>(new Set(['zh'])) + + const languages = [ + { value: 'zh', label: '🇨🇳 简体中文' }, + { value: 'en', label: '🇺🇸 English' }, + { value: 'ja', label: '🇯🇵 日本語' }, + { value: 'ko', label: '🇰🇷 한국어' }, + { value: 'fr', label: '🇫🇷 Français' }, + { value: 'de', label: '🇩🇪 Deutsch' } + ] + + return ( +
+ setSelectedKeys(new Set(keys.map(String)))} + placeholder="选择语言" + items={languages} + /> +
+ 当前语言: {languages.find(l => l.value === Array.from(selectedKeys)[0])?.label} +
+
+ ) + } +} \ No newline at end of file