---
title: 开发文档
order: 3
category: pro
---

﻿# PisellSingleSelect 单选下拉组件 - 开发文档

## 组件基本信息

- **组件名称**: PisellSingleSelect
- **组件版本**: v1.0.0
- **组件分类**: Pro Components > General > Form
- **开发日期**: 2026-01-26
- **基础组件**: Ant Design Select

## 组件描述

PisellSingleSelect 是一个基于 Ant Design Select 组件封装的单选下拉组件，提供符合 Pisell 设计规范的单选功能。组件通过 Token
系统和 Less 样式实现视觉定制，核心逻辑抽取到 `@pisell/utils` 以提高复用性。

## 需求点罗列

### 功能需求

1. ✅ 基础单选功能
  - 支持下拉选择单个选项
  - 支持受控模式和非受控模式
  - 支持默认值设置

2. ✅ 搜索与过滤
  - 支持搜索功能（showSearch）
  - 支持自定义过滤逻辑（filterOption）
  - 搜索输入防抖优化（300ms）

3. ✅ 状态管理
  - 禁用态（disabled）
  - 只读态（readOnly）
  - 错误态（error）
  - 聚焦态（focused）

4. ✅ 表单集成
  - 支持标签（label）
  - 支持辅助文本（helperText）
  - 支持错误提示（errorText）
  - 支持必填标记（required）

5. ✅ 交互功能
  - 支持清空按钮（allowClear）
  - 支持键盘导航（Ant Design 内置）
  - 支持下拉框显隐回调

### 样式需求

1. ✅ Token 系统集成
  - 优先使用 `packages/materials/src/theme/tokens.ts` 中的 Select Token
  - 支持尺寸变体（small/medium/large）

2. ✅ Less 样式定制
  - 自定义标签样式
  - 自定义错误状态样式
  - 自定义只读状态样式

3. ✅ Figma 设计稿对齐
  - 按照设计稿实现视觉样式
  - 确保与其他表单组件样式一致

### 性能需求

1. ✅ 搜索防抖
  - 使用 `useDebouncedSearch` Hook
  - 延迟 300ms

2. ✅ 渲染优化
  - 使用 `useMemo` 缓存过滤后的选项
  - 使用 `useCallback` 缓存事件处理函数

### 可访问性需求

1. ✅ ARIA 属性
  - aria-label
  - aria-required
  - aria-invalid
  - aria-describedby

2. ✅ 键盘导航
  - 基于 Ant Design Select 内置支持

## 组件 API 罗列

### Props

```
interface PisellSingleSelectProps<T = string | number> {
  // ========== 核心数据 ==========
  value?: T | null;                     // 受控模式：当前选中值
  defaultValue?: T;                     // 非受控模式：默认选中值
  onChange?: (value: T | null, option: SelectOption<T> | null) => void;  // 值变化回调
  options: SelectOption<T>[];           // 选项列表（必填）

  // ========== 交互控制 ==========
  placeholder?: string;                 // 占位符文本，默认："请选择"
  showSearch?: boolean;                 // 是否显示搜索框，默认：false
  allowClear?: boolean;                 // 是否显示清空按钮，默认：false
  disabled?: boolean;                   // 是否禁用，默认：false
  readOnly?: boolean;                   // 是否只读，默认：false

  // ========== 样式与布局 ==========
  size?: 'small' | 'medium' | 'large';  // 尺寸，默认：'medium'
  className?: string;                   // 自定义类名
  style?: React.CSSProperties;          // 自定义样式

  // ========== 表单标签与校验 ==========
  label?: string;                       // 标签文本
  helperText?: string;                  // 辅助提示文本
  required?: boolean;                   // 是否必填，默认：false
  error?: boolean;                      // 是否显示错误状态，默认：false
  errorText?: string;                   // 错误提示文本

  // ========== 筛选与排序 ==========
  filterable?: boolean;                 // 是否启用过滤，默认：false
  filterOption?: (input: string, option: SelectOption<T>) => boolean;  // 自定义过滤函数
  sortable?: boolean;                   // 是否启用排序，默认：false
  sortDirection?: 'asc' | 'desc';       // 排序方向

  // ========== 事件回调 ==========
  onSearch?: (value: string) => void;   // 搜索输入回调
  onFocus?: (event: React.FocusEvent) => void;     // 获得焦点回调
  onBlur?: (event: React.FocusEvent) => void;      // 失去焦点回调
  onDropdownVisibleChange?: (visible: boolean) => void;  // 下拉框显隐回调
}
```

### 类型定义

```
// 选项类型
type SelectOption<T = string | number> = {
  value: T;           // 选项值
  label: string;      // 选项显示文本
  disabled?: boolean; // 是否禁用该选项
  icon?: ReactNode;   // 选项图标
};

// Ref 方法
interface SelectRef {
  focus: () => void;              // 聚焦到组件
  blur: () => void;               // 失焦
  getValue: () => T | null;       // 获取当前值
  setValue: (value: T | null) => void;  // 设置值
  clearValue: () => void;         // 清空值
}
```

## 组件模块拆解

### 核心模块

1. **主组件（PisellSingleSelect.tsx）**
  - 职责：组件入口，状态管理协调
  - 依赖：EditView、DisabledView、ReadOnlyView

2. **编辑态视图（EditView.tsx）**
  - 职责：正常可编辑状态下的渲染
  - 依赖：Ant Design Select、@pisell/utils

3. **禁用态视图（DisabledView.tsx）**
  - 职责：禁用状态下的渲染
  - 依赖：Ant Design Select

4. **只读态视图（ReadOnlyView.tsx）**
  - 职责：只读状态下的渲染
  - 依赖：自定义渲染逻辑

### 复用模块（@pisell/utils）

1. **状态管理 Hook（useSelectState.ts）**
  - 职责：管理组件内部状态
  - 包含：value、onChange、受控/非受控模式处理

2. **防抖搜索 Hook（useDebouncedSearch.ts）**
  - 职责：对搜索输入进行防抖处理
  - 延迟：300ms

3. **工具函数（utils/）**
  - filterOptions.ts：选项过滤逻辑
  - sortOptions.ts：选项排序逻辑
  - helpers.ts：辅助函数

### 样式模块

1. **Token 配置（tokens.ts）**
  - 职责：定义组件的 Token 变量
  - 位置：packages/materials/src/theme/tokens.ts

2. **Less 样式（PisellSingleSelect.less）**
  - 职责：补充 Token 无法满足的样式定制
  - 包含：标签、错误状态、只读状态等

## 组件实现方案

### 1. 整体架构

```
┌─────────────────────────────────────────┐
│         PisellSingleSelect              │
│  (主组件 - 状态管理协调)                  │
└────────────┬────────────────────────────┘
             │
       ┌─────┴─────┐
       │ 状态判断   │
       └─────┬─────┘
             │
   ┌─────────┼─────────┐
   │         │         │
   ▼         ▼         ▼
Disabled  ReadOnly   Edit
  View      View     View
   │         │         │
   └─────────┴─────────┘
             │
    ┌────────┴────────┐
    │  Ant Design     │
    │     Select      │
    └─────────────────┘
             │
    ┌────────┴────────┐
    │  @pisell/utils  │
    │  - useSelectState
    │  - useDebouncedSearch
    │  - filterOptions
    │  - sortOptions
    └─────────────────┘
```

### 2. 核心实现逻辑

#### 主组件（PisellSingleSelect.tsx）

```
export const PisellSingleSelect = forwardRef<SelectRef, PisellSingleSelectProps>((props, ref) => {
  const {
    value,
    defaultValue,
    onChange,
    options,
    label,
    helperText,
    error,
    errorText,
    disabled,
    readOnly,
    required,
    size = 'medium',
    className,
    style,
    ...restProps
  } = props;

  // 1. 状态管理（从 @pisell/utils 导入）
  const state = useSelectState({
    value,
    defaultValue,
    onChange,
    mode: 'single',
  });

  // 2. 暴露 Ref 方法
  useImperativeHandle(ref, () => ({
    focus: () => selectRef.current?.focus(),
    blur: () => selectRef.current?.blur(),
    getValue: () => state.value,
    setValue: (newValue) => state.setValue(newValue),
    clearValue: () => state.setValue(null),
  }));

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

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

#### 编辑态视图（EditView.tsx）

```
export const EditView: React.FC<EditViewProps> = (props) => {
  const {
    value,
    options,
    label,
    helperText,
    error,
    errorText,
    required,
    showSearch,
    onSearch,
    filterOption,
    size = 'medium',
    className,
    ...restProps
  } = props;

  // 防抖搜索
  const [debouncedSearch, setSearchInput] = useDebouncedSearch('', 300, (text) => {
    onSearch?.(text);
  });

  // 过滤选项
  const filteredOptions = useMemo(() => {
    if (!debouncedSearch) return options;
    if (filterOption) {
      return options.filter(opt => filterOption(debouncedSearch, opt));
    }
    // 默认过滤逻辑
    return options.filter(opt => 
      opt.label.toLowerCase().includes(debouncedSearch.toLowerCase())
    );
  }, [options, debouncedSearch, filterOption]);

  // 转换为 Ant Design 需要的格式
  const antdOptions = useMemo(() => 
    filteredOptions.map(opt => ({
      label: opt.label,
      value: opt.value,
      disabled: opt.disabled,
    }))
  , [filteredOptions]);

  return (
    <div className={classNames('pisell-single-select-wrapper', className)}>
      {/* 标签 */}
      {label && (
        <label className={classNames('pisell-single-select-label', {
          'pisell-single-select-label-required': required,
        })}>
          {label}
        </label>
      )}
      
      {/* Select 组件 */}
      <Select
        {...restProps}
        value={value}
        options={antdOptions}
        showSearch={showSearch}
        onSearch={setSearchInput}
        size={size}
        className={classNames('pisell-single-select', {
          'pisell-single-select-error': error,
        })}
      />
      
      {/* 辅助文本 */}
      {helperText && !error && (
        <div className="pisell-single-select-helper-text">{helperText}</div>
      )}
      
      {/* 错误文本 */}
      {error && errorText && (
        <div className="pisell-single-select-error-text">{errorText}</div>
      )}
    </div>
  );
};
```

#### 禁用态视图（DisabledView.tsx）

```
export const DisabledView: React.FC<DisabledViewProps> = (props) => {
  const {
    value,
    options,
    label,
    required,
    className,
    ...restProps
  } = props;

  const antdOptions = useMemo(() => 
    options.map(opt => ({
      label: opt.label,
      value: opt.value,
      disabled: opt.disabled,
    }))
  , [options]);

  return (
    <div className={classNames('pisell-single-select-wrapper', className)}>
      {label && (
        <label className={classNames('pisell-single-select-label', {
          'pisell-single-select-label-required': required,
        })}>
          {label}
        </label>
      )}
      
      <Select
        {...restProps}
        value={value}
        options={antdOptions}
        disabled
        className="pisell-single-select"
      />
    </div>
  );
};
```

#### 只读态视图（ReadOnlyView.tsx）

```
export const ReadOnlyView: React.FC<ReadOnlyViewProps> = (props) => {
  const {
    value,
    options,
    label,
    required,
    className,
  } = props;

  // 查找选中的选项
  const selectedOption = useMemo(() => 
    options.find(opt => opt.value === value)
  , [options, value]);

  return (
    <div className={classNames('pisell-single-select-wrapper', 'pisell-single-select-readonly', className)}>
      {label && (
        <label className={classNames('pisell-single-select-label', {
          'pisell-single-select-label-required': required,
        })}>
          {label}
        </label>
      )}
      
      <div className="pisell-single-select-readonly-display">
        {selectedOption ? (
          <>
            {selectedOption.icon && <span className="pisell-single-select-readonly-icon">{selectedOption.icon}</span>}
            <span>{selectedOption.label}</span>
          </>
        ) : (
          <span className="pisell-single-select-readonly-empty">-</span>
        )}
      </div>
    </div>
  );
};
```

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

#### 状态管理 Hook（useSelectState.ts）

```
export function useSelectState<T>(options: UseSelectStateOptions<T>) {
  const { value: controlledValue, defaultValue, onChange, mode } = options;

  // 判断是否为受控组件
  const isControlled = controlledValue !== undefined;

  // 内部状态（非受控模式使用）
  const [internalValue, setInternalValue] = useState<T | null>(
    defaultValue ?? null
  );

  // 当前值
  const value = isControlled ? controlledValue : internalValue;

  // 设置值
  const setValue = useCallback((newValue: T | null) => {
    if (!isControlled) {
      setInternalValue(newValue);
    }
    onChange?.(newValue);
  }, [isControlled, onChange]);

  return {
    value,
    setValue,
  };
}
```

#### 防抖搜索 Hook（useDebouncedSearch.ts）

```
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. 样式实现

#### Token 配置（tokens.ts）

已在 `packages/materials/src/theme/tokens.ts` 中配置：

```
Select: {
  fontSizeIcon: 16,
  fontSize: 16,
  lineHeight: 1.5,
  borderRadius: 8,
  colorBorder: '#D0D5DD',
  controlHeight: 48,
  controlPaddingHorizontal: 16,
  colorText: '#101828',
  colorTextPlaceholder: '#667085',
  colorBgContainer: '#FFFFFF',
  colorBgContainerDisabled: '#F9FAFB',
  colorTextDisabled: '#98A2B3',
}
```

#### Less 样式（PisellSingleSelect.less）

```
.pisell-single-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;
      margin-left: 4px;
    }
  }

  &-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 {
    &-display {
      display: flex;
      align-items: center;
      gap: 8px;
      padding: 12px 16px;
      background-color: #F9FAFB;
      border: 1px solid #D0D5DD;
      border-radius: 8px;
      min-height: 48px;
      color: #101828;
      font-size: 16px;
      line-height: 1.5;
    }

    &-icon {
      display: flex;
      align-items: center;
    }

    &-empty {
      color: #667085;
    }
  }
}

// 尺寸变体
.pisell-single-select-small {
  :global(.ant-select-selector) {
    height: 32px;
    padding: 0 12px;
    font-size: 14px;
  }
}

.pisell-single-select-large {
  :global(.ant-select-selector) {
    height: 56px;
    padding: 0 20px;
    font-size: 18px;
  }
}
```

## 组件组成部分和引用部分

### 外部依赖

1. **React**
  - useState, useEffect, useMemo, useCallback, useRef, useImperativeHandle, forwardRef

2. **Ant Design**
  - Select 组件（核心）

3. **classNames**
  - 用于动态类名组合

4. **@pisell/utils**
  - useSelectState（状态管理）
  - useDebouncedSearch（防抖搜索）
  - filterOptions（选项过滤）
  - sortOptions（选项排序）

### 内部子组件

1. **EditView.tsx** - 编辑态视图
2. **DisabledView.tsx** - 禁用态视图
3. **ReadOnlyView.tsx** - 只读态视图

### 样式依赖

1. **Token 系统** - packages/materials/src/theme/tokens.ts
2. **Less 样式** - PisellSingleSelect.less

### 结构图

```
┌───────────────────────────────────────────────────────────┐
│                   PisellSingleSelect                      │
│                     (主入口组件)                           │
└───────────────────┬───────────────────────────────────────┘
                    │
        ┌───────────┼───────────┐
        │           │           │
        ▼           ▼           ▼
   ┌─────────┐ ┌─────────┐ ┌─────────┐
   │Disabled │ │ReadOnly │ │  Edit   │
   │  View   │ │  View   │ │  View   │
   └────┬────┘ └────┬────┘ └────┬────┘
        │           │           │
        └───────────┴───────────┘
                    │
        ┌───────────┴───────────┐
        │                       │
        ▼                       ▼
┌──────────────┐        ┌──────────────┐
│ Ant Design   │        │ @pisell/utils│
│   Select     │        │              │
│              │        │ - useSelectState
│              │        │ - useDebouncedSearch
│              │        │ - filterOptions
│              │        │ - sortOptions
└──────────────┘        └──────────────┘
        │
        ▼
┌──────────────┐
│ Token System │
│              │
│ tokens.ts    │
└──────────────┘
        │
        ▼
┌──────────────┐
│  Less Style  │
│              │
│ .less 文件   │
└──────────────┘
```

## 扩展性设计

### 1. 泛型支持

组件支持泛型，可以处理不同类型的值：

```
// 字符串类型
<PisellSingleSelect<string> options={stringOptions} />

// 数字类型
<PisellSingleSelect<number> options={numberOptions} />

// 自定义类型
<PisellSingleSelect<CustomType> options={customOptions} />
```

### 2. 自定义渲染

支持自定义选项渲染（通过 Ant Design Select 的 optionRender）：

```
<PisellSingleSelect
  options={options}
  optionRender={(option) => (
    <div>
      <Icon type={option.icon} />
      <span>{option.label}</span>
    </div>
  )}
/>
```

### 3. 插槽扩展

预留插槽位置，支持自定义内容：

```
<PisellSingleSelect
  options={options}
  prefixIcon={<SearchIcon />}
  suffixIcon={<ArrowDownIcon />}
/>
```

### 4. 样式定制

支持多种方式定制样式：

```
// 方式1：通过 className
<PisellSingleSelect className="custom-select" />

// 方式2：通过 style
<PisellSingleSelect style={{ width: 300 }} />

// 方式3：通过 Token 配置（全局）
// 在 tokens.ts 中修改 Select Token
```

## 通用性保证

1. **类型安全**：完整的 TypeScript 类型定义
2. **状态模式**：同时支持受控模式和非受控模式
3. **可访问性**：完整的 ARIA 属性和键盘导航支持
4. **国际化**：文本内容支持外部传入，可配合 i18n 使用
5. **表单集成**：可与 React Hook Form、Formik 等表单库无缝集成
6. **主题定制**：基于 Token 系统，支持全局主题切换

## 开发注意事项

1. **优先使用 Token**：样式修改优先考虑修改 tokens.ts，Less 文件仅用于 Token 无法满足的场景
2. **复用逻辑抽取**：通用逻辑必须放在 @pisell/utils 中，供其他组件复用
3. **状态视图分离**：不同状态使用不同子组件，保持代码清晰
4. **性能优化**：使用 useMemo、useCallback 避免不必要的渲染
5. **类型安全**：充分利用 TypeScript 泛型，确保类型推断正确
6. **可访问性**：确保所有交互都有对应的 ARIA 属性和键盘支持
