feat(ui): implement new Selector component with enhanced functionality

- Replaced the existing Selector component with a new implementation using HeroUI's Select and SelectItem.
- Updated the props structure to support items and selection change handling.
- Added a new story file for the Selector component, showcasing various use cases including single and multiple selection modes, size variations, and disabled states.
- Improved type definitions for better clarity and usability.
This commit is contained in:
MyPrototypeWhat 2025-09-17 18:31:02 +08:00
parent 4f746842a5
commit 15569387c7
2 changed files with 255 additions and 180 deletions

View File

@ -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<V = string | number> {
interface SelectorItem<V = string | number> {
label: string | ReactNode
value: V
type?: 'group'
options?: SelectorOption<V>[]
disabled?: boolean
[key: string]: any
}
interface BaseSelectorProps<V = string | number> {
options: SelectorOption<V>[]
placeholder?: string
placement?: 'topLeft' | 'topCenter' | 'topRight' | 'bottomLeft' | 'bottomCenter' | 'bottomRight' | 'top' | 'bottom'
/** 字体大小 */
size?: number
/** 是否禁用 */
disabled?: boolean
interface SelectorProps<V = string | number> extends Omit<SelectProps, 'children' | 'onSelectionChange'> {
items: SelectorItem<V>[]
onSelectionChange?: (keys: V[]) => void
}
interface SingleSelectorProps<V> extends BaseSelectorProps<V> {
multiple?: false
value?: V
onChange: (value: V) => void
}
interface MultipleSelectorProps<V> extends BaseSelectorProps<V> {
multiple: true
value?: V[]
onChange: (value: V[]) => void
}
type SelectorProps<V> = SingleSelectorProps<V> | MultipleSelectorProps<V>
const Selector = <V extends string | number>({
options,
value,
onChange = () => {},
placement = 'bottomRight',
size = 13,
placeholder,
disabled = false,
multiple = false
}: SelectorProps<V>) => {
const [open, setOpen] = useState(false)
const { t } = useTranslation()
const inputRef = useRef<any>(null)
useEffect(() => {
let timer: NodeJS.Timeout
if (open) {
timer = setTimeout(() => {
inputRef.current?.focus()
}, 1)
const Selector = <V extends string | number>({ items, onSelectionChange, ...rest }: SelectorProps<V>) => {
// 处理选择变化,转换 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<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 : strKey
}) as V[]
const label = useMemo(() => {
if (selectedValues.length > 0) {
const findLabels = (opts: SelectorOption<V>[]): (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<V>) => ({
key: option.value,
label: option.label,
extra: <CheckIcon>{selectedValues.some((v) => v == option.value) && <Check size={14} />}</CheckIcon>,
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<V>['onChange'])(newValues)
} else {
;(onChange as SingleSelectorProps<V>['onChange'])(newValue)
setOpen(false)
}
}
const handleOpenChange: DropdownProps['onOpenChange'] = (nextOpen, info) => {
if (disabled) return
if (info.source === 'trigger' || nextOpen) {
setOpen(nextOpen)
}
onSelectionChange(keysArray)
}
return (
<Dropdown
overlayClassName="selector-dropdown"
menu={{ items, onClick }}
trigger={['click']}
placement={placement}
open={open && !disabled}
onOpenChange={handleOpenChange}>
<Label $size={size} $open={open} $disabled={disabled} $isPlaceholder={label === placeholder}>
{label}
<LabelIcon size={size + 3} />
</Label>
</Dropdown>
<Select
{...rest}
label={<label className="hidden">Select</label>}
items={items}
onSelectionChange={handleSelectionChange}>
{({ value, label, ...restItem }: SelectorItem<V>) => (
<SelectItem {...restItem} key={value} title={String(label)}>
{label}
</SelectItem>
)}
</Select>
)
}
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 }

View File

@ -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<typeof Selector> = {
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<typeof meta>
// 基础用法
export const Default: Story = {
render: function Render() {
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set(['react']))
return (
<div className="space-y-4">
<Selector
selectedKeys={selectedKeys}
onSelectionChange={(keys) => setSelectedKeys(new Set(keys.map(String)))}
placeholder="选择框架"
items={[
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' },
{ value: 'angular', label: 'Angular' },
{ value: 'svelte', label: 'Svelte' }
]}
/>
<div className="text-sm text-gray-600">
: <code>{Array.from(selectedKeys).join(', ')}</code>
</div>
</div>
)
}
}
// 多选模式
export const Multiple: Story = {
render: function Render() {
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set(['react', 'vue']))
return (
<div className="space-y-4">
<Selector
selectionMode="multiple"
selectedKeys={selectedKeys}
onSelectionChange={(keys) => 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' }
]}
/>
<div className="text-sm text-gray-600">
({selectedKeys.size}): {Array.from(selectedKeys).join(', ')}
</div>
</div>
)
}
}
// 数字值类型
export const NumberValues: Story = {
render: function Render() {
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set(['2']))
const [selectedValue, setSelectedValue] = useState<number>(2)
return (
<div className="space-y-4">
<Selector
selectedKeys={selectedKeys}
onSelectionChange={(keys) => {
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: '🟢 低' }
]}
/>
<div className="text-sm text-gray-600">
: <code>{selectedValue}</code> (: {typeof selectedValue})
</div>
</div>
)
}
}
// 不同大小
export const Sizes: Story = {
render: function Render() {
const items = [
{ value: 'option1', label: '选项 1' },
{ value: 'option2', label: '选项 2' },
{ value: 'option3', label: '选项 3' }
]
return (
<div className="space-y-4">
<div>
<label className="block text-sm mb-2"> (sm)</label>
<Selector
size="sm"
placeholder="选择一个选项"
items={items}
/>
</div>
<div>
<label className="block text-sm mb-2"> (md)</label>
<Selector
size="md"
placeholder="选择一个选项"
items={items}
/>
</div>
<div>
<label className="block text-sm mb-2"> (lg)</label>
<Selector
size="lg"
placeholder="选择一个选项"
items={items}
/>
</div>
</div>
)
}
}
// 禁用状态
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<Set<string>>(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 (
<div className="space-y-4">
<Selector
selectedKeys={selectedKeys}
onSelectionChange={(keys) => setSelectedKeys(new Set(keys.map(String)))}
placeholder="选择语言"
items={languages}
/>
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded">
: <strong>{languages.find(l => l.value === Array.from(selectedKeys)[0])?.label}</strong>
</div>
</div>
)
}
}