---
title: 设计文档
order: 2
category: pro
---

﻿# PisellMultipleSelect 多选下拉组件设计文档

> **组件概述**: PisellMultipleSelect（多选下拉组件）是Pisell-2.0组件库中的核心表单组件，基于Ant
> Design的Select组件进行二次封装，提供符合Pisell设计规范的多选功能，支持标签聚合与收敛展示。

## 设计稿链接

**Figma 设计稿
**: https://www.figma.com/design/pM8Ho6d7kCMv9vIBvFHUlj/Pisell-2.0-Component-Library?node-id=3235-7189&m=dev

## 第一部分：组件概述

### 1. 组件定位与价值

PisellMultipleSelect在Pisell-2.0组件库中定位为**Pro Components**下的**General**分类，是表单多选输入场景的基础组件。其核心价值在于：

* **技术统一**：基于Ant Design Select组件封装，确保与设计系统的一致性。
* **样式定制**：通过Pisell Token系统（`packages/materials/src/theme/tokens.ts`）定制样式，符合Pisell设计规范。
* **智能聚合**：支持多选标签的聚合展示，优化界面空间使用。
* **易用性**：提供简洁的API，降低使用成本。
* **复用性**：核心逻辑抽取到`@pisell/utils`，可在多个组件间复用。

### 2. 核心特性总结

1. **基础多选功能**：提供标准的下拉多选功能。
2. **标签聚合与收敛**：通过`maxTagCount`控制标签显示数量，超出的部分以"+N"形式聚合，优化界面空间。
3. **最大可选数量限制**：通过`maxCount`限制最大可选数量。
4. **完整的状态管理**：全面支持`disabled`（禁用态）、`readOnly`（只读态）、`error`（错误态）及`focused`（焦点态）。
5. **搜索与过滤**：内置搜索功能（`showSearch`），支持自定义过滤逻辑，并集成防抖优化。
6. **字段筛选与排序**：遵循统一的组件规范，可与筛选、排序控件无缝集成。
7. **样式定制**：支持通过Less自定义样式，优先使用Token配置。

### 3. 设计原则

* **基于Ant Design**：充分利用Ant Design Select组件的能力，避免重复造轮子。
* **单一职责**：组件只负责多选功能，保持简洁。
* **类型安全**：深度利用TypeScript，确保编译时就能发现类型错误。
* **性能优先**：对搜索等高频操作进行防抖优化。
* **可访问性**：遵循WAI-ARIA标准，提供完整的键盘导航支持和屏幕阅读器兼容性。

## 第二部分：详细API文档

### 1. 完整的Props接口定义

```
import { ReactNode } from 'react';
import { SelectProps as AntSelectProps } from 'antd';

interface PisellMultipleSelectProps<T = string | number> extends Omit<AntSelectProps, 'mode' | 'value' | 'onChange'> {
  // 核心数据
  value?: T[];
  defaultValue?: T[];
  onChange?: (value: T[], options: SelectOption<T>[]) => void;
  options: SelectOption<T>[];

  // 交互控制
  placeholder?: string;
  showSearch?: boolean;
  allowClear?: boolean;
  disabled?: boolean;
  readOnly?: boolean;

  // 样式与布局
  size?: 'small' | 'medium' | 'large';
  className?: string;
  style?: React.CSSProperties;

  // 表单标签与校验
  label?: string;
  helperText?: string;
  required?: boolean;
  error?: boolean;
  errorText?: string;

  // 多选专属配置
  maxTagCount?: number; // 默认：3，最多显示的标签数量
  showTooltipOnOverflow?: boolean; // 默认：true，超出标签hover时显示tooltip
  maxCount?: number; // 最大可选数量

  // 筛选与排序
  filterable?: boolean;
  filterOption?: (input: string, option: SelectOption<T>) => boolean;
  sortable?: boolean;
  sortDirection?: 'asc' | 'desc';

  // 事件回调
  onSearch?: (value: string) => void;
  onFocus?: (event: React.FocusEvent) => void;
  onBlur?: (event: React.FocusEvent) => void;
  onDropdownVisibleChange?: (visible: boolean) => void;
  onDeselect?: (value: T, option: SelectOption<T>) => void; // 取消选中时触发
}
```

### 2. 类型定义详解

```
// 选项基础类型
type SelectOption<T = string | number> = {
  value: T;
  label: string;
  disabled?: boolean;
  color?: string; // 用于Tag样式时的背景色
  icon?: ReactNode;
};

// 组件实例引用方法
interface MultipleSelectRef {
  focus: () => void;
  blur: () => void;
  getValue: () => T[];
  setValue: (value: T[]) => void;
  clearValue: () => void;
  addValue: (value: T) => void; // 新增一个选中项
  removeValue: (value: T) => void; // 移除一个选中项
}
```

### 3. 使用示例

#### 基础多选

```
import { PisellMultipleSelect } from '@pisell/materials';
import { useState } from 'react';

const fruitOptions = [
  { value: 'apple', label: '苹果' },
  { value: 'banana', label: '香蕉' },
  { value: 'orange', label: '橙子' },
  { value: 'grape', label: '葡萄' },
  { value: 'watermelon', label: '西瓜' },
];

function MultipleSelectDemo() {
  const [fruits, setFruits] = useState<string[]>([]);
  
  return (
    <PisellMultipleSelect
      value={fruits}
      onChange={setFruits}
      options={fruitOptions}
      placeholder="请选择喜欢的水果"
      label="喜欢的水果"
      helperText="可以选择多个"
      size="medium"
    />
  );
}
```

#### 标签聚合样式

```
function TagAggregationDemo() {
  const [skills, setSkills] = useState<string[]>([]);
  const skillOptions = [
    { value: 'react', label: 'React', color: '#61dafb' },
    { value: 'typescript', label: 'TypeScript', color: '#3178c6' },
    { value: 'node', label: 'Node.js', color: '#339933' },
    { value: 'vue', label: 'Vue.js', color: '#42b883' },
    { value: 'angular', label: 'Angular', color: '#dd0031' },
  ];

  return (
    <PisellMultipleSelect
      value={skills}
      onChange={setSkills}
      options={skillOptions}
      maxTagCount={2} // 最多显示2个标签，其余显示 +N
      showTooltipOnOverflow // hover时显示完整列表
      placeholder="请选择技能"
      label="技术栈"
      helperText="最多可选择5项"
      maxCount={5}
    />
  );
}
```

#### 带搜索功能

```
<PisellMultipleSelect
  options={largeCityOptions} // 假设有几百个城市选项
  showSearch
  onSearch={(keyword) => console.log('搜索关键词:', keyword)}
  filterOption={(input, option) =>
    option.label.toLowerCase().includes(input.toLowerCase()) ||
    option.value.toString().includes(input)
  }
  placeholder="搜索并选择多个城市..."
/>
```

#### 禁用与错误状态

```
// 禁用状态
<PisellMultipleSelect 
  options={options} 
  disabled 
  value={['value1', 'value2']} 
/>

// 错误状态
<PisellMultipleSelect
  options={options}
  error
  errorText="至少选择一项"
  required
/>
```

#### 限制最大可选数量

```
<PisellMultipleSelect
  options={options}
  maxCount={3}
  value={selected}
  onChange={setSelected}
  helperText="最多可选择3项"
/>
```

## 第三部分：实现架构

### 1. 技术栈

- **基础组件**：Ant Design Select (mode="multiple")
- **样式方案**：Less + Ant Design Token 系统
- **状态管理**：自定义 Hook（从 `@pisell/utils` 导入）
- **类型系统**：TypeScript

### 2. 目录结构说明

```
src/components/pisellMultipleSelect/
├── index.tsx                           # 主入口：export { PisellMultipleSelect }
├── PisellMultipleSelect.tsx           # 主组件实现
├── PisellMultipleSelect.less          # 组件样式文件
├── PisellMultipleSelect.test.tsx      # 组件单元测试
├── PisellMultipleSelect.stories.tsx   # Storybook 故事
├── types.ts                           # TypeScript 类型定义
├── components/                        # 子组件目录
│   ├── DisabledView.tsx              # 禁用态视图
│   ├── ReadOnlyView.tsx              # 只读态视图
│   ├── EditView.tsx                  # 编辑态视图
│   └── TagRenderer.tsx               # 自定义标签渲染器
└── docs/                             # 组件文档目录
    ├── pisellMultipleSelect.md           # 组件使用文档
    ├── pisellMultipleSelect.$tab-design.md  # 设计文档（本文档）
    ├── pisellMultipleSelect.$tab-dev.md     # 开发文档
    └── pisellMultipleSelect.$tab-test.md    # 测试用例文档
```

### 3. 复用逻辑（@pisell/utils）

```
packages/utils/src/select/
├── index.ts                        # 导出所有 select 相关工具
├── types.ts                        # 共享类型定义
├── hooks/                          # 自定义Hooks
│   ├── index.ts
│   ├── useSelectState.ts          # 核心状态管理
│   └── useDebouncedSearch.ts      # 防抖搜索
└── utils/                          # 工具函数
    ├── index.ts
    ├── filterOptions.ts            # 选项过滤
    ├── sortOptions.ts              # 选项排序
    ├── tagAggregation.ts           # 标签聚合逻辑
    └── helpers.ts                  # 辅助函数
```

### 4. 核心组件实现要点

#### PisellMultipleSelect 主组件

* **职责**：封装Ant Design Select组件（mode="multiple"），管理状态，处理样式定制和标签聚合。
* **关键实现**：
  - 使用`@pisell/utils`中的`useSelectState` Hook管理状态
  - 通过Less文件覆盖Ant Design默认样式
  - 实现`maxTagCount`标签聚合逻辑
  - 实现`maxCount`最大可选数量限制

```
const PisellMultipleSelect = forwardRef<MultipleSelectRef, PisellMultipleSelectProps>((props, ref) => {
  const {
    value,
    defaultValue,
    onChange,
    options,
    label,
    helperText,
    error,
    errorText,
    disabled,
    readOnly,
    maxCount,
    maxTagCount = 3,
    showTooltipOnOverflow = true,
    ...restProps
  } = props;

  // 使用 @pisell/utils 中的状态管理
  const state = useSelectState({
    value,
    defaultValue,
    onChange,
    mode: 'multiple',
  });

  // 处理最大可选数量限制
  const handleChange = useCallback((newValue: T[]) => {
    if (maxCount && newValue.length > maxCount) {
      // 显示提示或阻止选择
      return;
    }
    onChange?.(newValue, newValue.map(v => options.find(opt => opt.value === v)!));
  }, [maxCount, onChange, options]);

  // 根据不同状态渲染不同视图
  if (disabled) {
    return <DisabledView {...props} />;
  }
  
  if (readOnly) {
    return <ReadOnlyView {...props} />;
  }

  return <EditView {...props} state={state} onChangeInternal={handleChange} />;
});
```

## 第四部分：样式系统

### 1. Token 配置（优先方式）

组件样式优先通过修改 `packages/materials/src/theme/tokens.ts` 中的 Select Token 来实现。

当前 Select Token 配置：

```
Select: {
  // 基础样式（与 Input 保持一致）
  fontSizeIcon: 16,
  fontSize: 16,
  lineHeight: 1.5,

  // 边框和圆角（与 Input 一致）
  borderRadius: 8,
  colorBorder: '#D0D5DD',

  // 高度和内边距（与 Input 完全一致）
  controlHeight: 48,
  controlPaddingHorizontal: 16, // 水平内边距

  // 颜色
  colorText: '#101828',
  colorTextPlaceholder: '#667085',
  colorBgContainer: '#FFFFFF',

  // 禁用态
  colorBgContainerDisabled: '#F9FAFB',
  colorTextDisabled: '#98A2B3',
}
```

### 2. Less 样式文件（补充定制）

当Token无法满足需求时，使用Less文件进行补充定制。

**基础配置** (`PisellMultipleSelect.less`):

```
.pisell-multiple-select {
  width: 100%;
  
  // 包装器
  &-wrapper {
    display: flex;
    flex-direction: column;
    gap: 6px;
  }

  // 标签
  &-label {
    font-size: 14px;
    font-weight: 500;
    color: #344054;
    line-height: 20px;
    
    &-required::after {
      content: ' *';
      color: #f5222d;
    }
  }

  // 辅助文本
  &-helper-text {
    font-size: 14px;
    color: #667085;
    line-height: 20px;
  }

  // 错误文本
  &-error-text {
    font-size: 14px;
    color: #f5222d;
    line-height: 20px;
  }

  // 错误状态
  &-error {
    :global(.ant-select-selector) {
      border-color: #f5222d !important;
      
      &:hover,
      &:focus {
        border-color: #f5222d !important;
      }
    }
  }

  // 只读状态
  &-readonly {
    :global(.ant-select-selector) {
      background-color: #F9FAFB;
      cursor: default;
      
      &:hover {
        border-color: #D0D5DD;
      }
    }
  }

  // 标签样式定制
  :global {
    .ant-select-selection-item {
      background-color: #F2F4F7;
      border: 1px solid #D0D5DD;
      border-radius: 6px;
      padding: 2px 8px;
      
      &-remove {
        color: #667085;
        
        &:hover {
          color: #344054;
        }
      }
    }

    // 聚合标签 (+N)
    .ant-select-selection-overflow-item-suffix {
      .ant-select-selection-item {
        background-color: #E4D9FC;
        color: #5F3DA8;
        border-color: #D4C3F7;
      }
    }
  }
}
```

### 3. 标签聚合样式

通过Ant Design的`maxTagCount`和`maxTagPlaceholder`实现标签聚合：

```
<Select
  mode="multiple"
  maxTagCount={maxTagCount}
  maxTagPlaceholder={(omittedValues) => (
    <Tooltip title={omittedValues.map(v => v.label).join(', ')}>
      <span className="pisell-multiple-select-more-tag">
        +{omittedValues.length}
      </span>
    </Tooltip>
  )}
/>
```

对应Less样式：

```
.pisell-multiple-select-more-tag {
  background-color: #E4D9FC;
  color: #5F3DA8;
  border: 1px solid #D4C3F7;
  border-radius: 6px;
  padding: 2px 8px;
  font-size: 14px;
  cursor: pointer;
  
  &:hover {
    background-color: #D4C3F7;
  }
}
```

## 第五部分：高级特性实现

### 1. 标签聚合与收敛逻辑

**核心逻辑** (在 `@pisell/utils/src/select/utils/tagAggregation.ts`):

```
/**
 * 计算需要显示的标签和聚合信息
 */
export function calculateTagDisplay<T>(
  selectedValues: T[],
  options: SelectOption<T>[],
  maxTagCount: number
): {
  visibleTags: SelectOption<T>[];
  hiddenTags: SelectOption<T>[];
  hiddenCount: number;
} {
  const selectedOptions = selectedValues
    .map(v => options.find(opt => opt.value === v))
    .filter(Boolean) as SelectOption<T>[];

  if (selectedOptions.length <= maxTagCount) {
    return {
      visibleTags: selectedOptions,
      hiddenTags: [],
      hiddenCount: 0,
    };
  }

  return {
    visibleTags: selectedOptions.slice(0, maxTagCount),
    hiddenTags: selectedOptions.slice(maxTagCount),
    hiddenCount: selectedOptions.length - maxTagCount,
  };
}
```

### 2. 最大可选数量限制

```
// 在组件内部
const handleSelect = useCallback((selectedValue: T) => {
  if (maxCount && value.length >= maxCount) {
    // 显示提示消息
    message.warning(`最多只能选择 ${maxCount} 项`);
    return;
  }
  
  // 继续处理选择逻辑
}, [maxCount, value]);
```

### 3. 搜索过滤与防抖

**防抖Hook实现** (在 `@pisell/utils/src/select/hooks/useDebouncedSearch.ts`):

```
import { useState, useEffect } from 'react';

export function useDebouncedSearch<T>(
  initialValue: T,
  delay: number,
  callback?: (value: T) => void
): [T, (value: T) => void] {
  const [value, setValue] = useState<T>(initialValue);
  const [debouncedValue, setDebouncedValue] = useState<T>(initialValue);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
      callback?.(value);
    }, delay);

    return () => clearTimeout(timer);
  }, [value, delay, callback]);

  return [debouncedValue, setValue];
}
```

### 4. 键盘导航

Ant Design Select 已经内置了完整的键盘导航支持，包括：

- **ArrowDown/ArrowUp**: 在选项间导航
- **Space**: 切换当前高亮项的选中状态
- **Enter**: 确认选择
- **Escape**: 关闭下拉框
- **Backspace**: 删除最后一个已选标签

组件无需额外实现，只需确保正确设置ARIA属性。

### 5. 可访问性（ARIA属性）

基于Ant Design Select的可访问性基础，确保：

```
<PisellMultipleSelect
  aria-label={label}
  aria-required={required}
  aria-invalid={error}
  aria-describedby={helperText ? `${id}-helper` : undefined}
  aria-multiselectable="true"
/>
```

## 第六部分：性能优化

### 1. 记忆化渲染策略

**使用`useMemo`缓存计算值**:

```
const filteredOptions = useMemo(() => {
  return options.filter(opt =>
    !searchText || opt.label.toLowerCase().includes(searchText.toLowerCase())
  );
}, [options, searchText]);

const tagDisplayInfo = useMemo(() => {
  return calculateTagDisplay(value, options, maxTagCount);
}, [value, options, maxTagCount]);
```

**使用`useCallback`缓存事件处理函数**:

```
const handleChange = useCallback((newValue: T[]) => {
  const selectedOptions = newValue.map(v => 
    options.find(opt => opt.value === v)
  ).filter(Boolean) as SelectOption<T>[];
  
  onChange?.(newValue, selectedOptions);
}, [onChange, options]);

const handleDeselect = useCallback((deselectedValue: T) => {
  const option = options.find(opt => opt.value === deselectedValue);
  onDeselect?.(deselectedValue, option!);
}, [onDeselect, options]);
```

### 2. 防抖应用

| 场景   | 技术            | 实现位置                      | 说明                   |
|------|---------------|---------------------------|----------------------|
| 搜索输入 | 防抖 (Debounce) | `useDebouncedSearch` Hook | 用户停止输入300ms后才触发搜索逻辑。 |

## 第七部分：测试策略

### 1. 单元测试用例

使用Jest + React Testing Library。

```
import { render, screen, fireEvent } from '@testing-library/react';
import { PisellMultipleSelect } from './PisellMultipleSelect';

const mockOptions = [
  { value: '1', label: '选项一' },
  { value: '2', label: '选项二' },
  { value: '3', label: '选项三' },
];

describe('PisellMultipleSelect Component', () => {
  test('渲染基础多选组件', () => {
    render(<PisellMultipleSelect options={mockOptions} />);
    expect(screen.getByRole('combobox')).toBeInTheDocument();
  });

  test('选择多个选项触发onChange', () => {
    const handleChange = jest.fn();
    render(
      <PisellMultipleSelect
        options={mockOptions}
        onChange={handleChange}
      />
    );

    fireEvent.click(screen.getByRole('combobox'));
    fireEvent.click(screen.getByText('选项一'));
    fireEvent.click(screen.getByText('选项二'));

    expect(handleChange).toHaveBeenLastCalledWith(
      ['1', '2'],
      [mockOptions[0], mockOptions[1]]
    );
  });

  test('标签聚合功能', () => {
    render(
      <PisellMultipleSelect
        options={mockOptions}
        value={['1', '2', '3']}
        maxTagCount={2}
      />
    );

    // 应该显示 2 个标签 + 1 个 "+1" 聚合标签
    expect(screen.getByText('+1')).toBeInTheDocument();
  });

  test('最大可选数量限制', () => {
    const handleChange = jest.fn();
    render(
      <PisellMultipleSelect
        options={mockOptions}
        maxCount={2}
        value={['1', '2']}
        onChange={handleChange}
      />
    );

    // 尝试选择第3项，应该被阻止
    fireEvent.click(screen.getByRole('combobox'));
    fireEvent.click(screen.getByText('选项三'));

    // onChange 不应该被调用
    expect(handleChange).not.toHaveBeenCalled();
  });

  test('禁用状态下不可交互', () => {
    render(<PisellMultipleSelect options={mockOptions} disabled />);
    const trigger = screen.getByRole('combobox');
    expect(trigger).toHaveClass('ant-select-disabled');
  });
});
```

### 2. 可访问性测试

使用`jest-axe`进行自动化可访问性测试。

```
import { axe } from 'jest-axe';

test('组件没有可访问性违规', async () => {
  const { container } = render(<PisellMultipleSelect options={mockOptions} />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});
```

## 第八部分：与其他组件的关系

### 1. 复用逻辑

核心逻辑抽取到 `@pisell/utils/src/select`，可供以下组件复用：

- **PisellSingleSelect**: 单选下拉组件
- **PisellCascader**: 级联选择组件（未来）
- **PisellTreeSelect**: 树形选择组件（未来）

### 2. 设计系统对齐

- 样式与 `PisellInput`、`PisellDatePicker` 等表单组件保持一致
- 使用统一的 Token 系统
- 遵循相同的尺寸规范（small/medium/large）
- 标签样式与 `PisellTag` 组件保持一致

## 总结

PisellMultipleSelect 是一个基于Ant Design Select封装的多选下拉组件，通过Token系统和Less样式实现Pisell设计规范，支持标签聚合、最大可选数量限制等高级功能。核心逻辑抽取到
`@pisell/utils`以提高复用性。组件设计遵循单一职责原则，提供完整的类型安全和可访问性支持。
