mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-25 19:30:17 +08:00
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:
parent
4f746842a5
commit
15569387c7
@ -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 }
|
||||
|
||||
219
packages/ui/stories/components/interactive/Selector.stories.tsx
Normal file
219
packages/ui/stories/components/interactive/Selector.stories.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user