Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export { default as Popover } from './popover';
export { default as ProgressBar } from './progressBar';
export { default as ProgressLine } from './progressLine';
export { default as Resize } from './resize';
export { default as Select } from './select';
export { default as SpreadSheet } from './spreadSheet';
export { default as StatusTag } from './statusTag';
export { default as Table } from './table';
Expand Down
154 changes: 154 additions & 0 deletions src/select/__tests__/select.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import React from 'react';
import { act, cleanup, fireEvent, render } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';

import Select from '..';

// 本地尚未构建包入口,映射到真实 Empty 组件。
jest.mock(
'dt-react-component',
() => ({
Empty: jest.requireActual('../../empty').default,
}),
{ virtual: true }
);

describe('Select', () => {
const options = [{ label: '选项一', value: 'one' }];

beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
cleanup();
jest.clearAllTimers();
jest.useRealTimers();
jest.restoreAllMocks();
});

it.each([undefined, 0, -1])('should disable scrolling when maxRows is %s', (maxRows) => {
const { container } = render(<Select maxRows={maxRows} open options={options} />);
expect(container.firstChild).not.toHaveClass('dtc-select--scroll');
expect(container.querySelector('.ant-select')).not.toHaveStyle({ maxHeight: '88px' });
expect(document.querySelector('.ant-select-dropdown')).not.toHaveClass(
'dtc-select__scroll-dropdown'
);
});

it.each([
[undefined, 88],
['small', 64],
['middle', 88],
['large', 112],
] as const)('should adapt maxRows to size=%s', (size, maxHeight) => {
const { container, rerender } = render(<Select mode="multiple" size={size} maxRows={3} />);
expect(container.querySelector('.ant-select')).toHaveStyle({ maxHeight: `${maxHeight}px` });
rerender(<Select mode="multiple" size="small" maxRows={2} />);
expect(container.querySelector('.ant-select')).toHaveStyle({ maxHeight: '44px' });
});

it('should limit height and preserve custom styles and dropdown class', () => {
const { container, rerender } = render(
<Select maxRows={3} open dropdownClassName="custom-dropdown" style={{ width: 240 }} />
);
expect(container.firstChild).toHaveClass('dtc-select--scroll');
expect(container.querySelector('.ant-select')).toHaveStyle({
maxHeight: '88px',
width: '240px',
});
expect(document.querySelector('.ant-select-dropdown')).toHaveClass(
'custom-dropdown',
'dtc-select__scroll-dropdown'
);
rerender(<Select maxRows={3} style={{ maxHeight: 120 }} />);
expect(container.querySelector('.ant-select')).toHaveStyle({ maxHeight: '120px' });
});

it.each([undefined, 2])(
'should forward selection and scroll only with maxRows=%s',
(maxRows) => {
const onSelect = jest.fn();
const onChange = jest.fn();
const { container, getByText } = render(
<Select
mode="multiple"
open
maxRows={maxRows}
options={options}
onSelect={onSelect}
onChange={onChange}
/>
);
const selector = container.querySelector<HTMLElement>('.ant-select-selector');
if (!selector) throw new Error('Select selector not found');
// jsdom 不计算布局,也没有 scrollTo,手动提供滚动高度和方法。
const scrollTo = jest.fn();
selector.scrollTo = scrollTo;
Object.defineProperty(selector, 'scrollHeight', { value: 180 });
fireEvent.click(getByText('选项一'));
expect(onSelect).toHaveBeenCalledWith('one', expect.objectContaining(options[0]));
expect(onChange).toHaveBeenCalledWith(['one'], expect.any(Array));
expect(scrollTo).not.toHaveBeenCalled();
act(() => {
jest.runOnlyPendingTimers();
});
if (maxRows) {
expect(scrollTo).toHaveBeenCalledWith({ top: 180, behavior: 'smooth' });
} else {
expect(scrollTo).not.toHaveBeenCalled();
}
}
);

it('should suppress composing searches and deduplicate the completed value', () => {
const onSearch = jest.fn();
const { getByRole } = render(<Select showSearch onSearch={onSearch} />);
const input = getByRole('combobox');
fireEvent.compositionStart(input);
fireEvent.change(input, { target: { value: 'zhong' } });
expect(onSearch).not.toHaveBeenCalled();
// 结束合成时先更新 DOM 值,再模拟浏览器后续的 change。
fireEvent.compositionEnd(input, { target: { value: '中文' } });
expect(onSearch).toHaveBeenCalledWith('中文');
fireEvent.change(input, { target: { value: '中文' } });
expect(onSearch).toHaveBeenCalledTimes(1);
fireEvent.change(input, { target: { value: '中文搜索' } });
expect(onSearch).toHaveBeenLastCalledWith('中文搜索');
expect(onSearch).toHaveBeenCalledTimes(2);
});

it('should allow the same search value again after the next frame', () => {
const onSearch = jest.fn();
const { getByRole } = render(<Select showSearch onSearch={onSearch} />);
const input = getByRole('combobox');
fireEvent.compositionStart(input);
fireEvent.compositionEnd(input, { target: { value: '中文' } });
act(() => {
jest.advanceTimersByTime(20);
});
fireEvent.change(input, { target: { value: '其他' } });
fireEvent.change(input, { target: { value: '中文' } });
expect(onSearch).toHaveBeenLastCalledWith('中文');
expect(onSearch).toHaveBeenCalledTimes(3);
});

it('should ignore composition end outside the search input', () => {
const onSearch = jest.fn();
const { container } = render(<Select onSearch={onSearch} />);
fireEvent.compositionEnd(container.firstElementChild as HTMLElement);
expect(onSearch).not.toHaveBeenCalled();
});

it('should render loading, empty and custom notFoundContent', () => {
const { rerender, getByText } = render(<Select open loading options={[]} />);
expect(document.querySelector('.ant-select-item-empty .ant-spin')).toBeInTheDocument();
expect(document.querySelector('.dtc-empty')).not.toBeInTheDocument();
rerender(<Select open options={[]} />);
expect(document.querySelector('.dtc-empty')).toBeInTheDocument();
expect(document.querySelector('.ant-select-item-empty .ant-spin')).not.toBeInTheDocument();
rerender(<Select open loading notFoundContent="暂无匹配结果" />);
expect(getByText('暂无匹配结果')).toBeInTheDocument();
expect(document.querySelector('.ant-select-item-empty .ant-spin')).not.toBeInTheDocument();
});
});
26 changes: 26 additions & 0 deletions src/select/demos/empty.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React, { useState } from 'react';
import { Space, Switch } from 'antd';
import { Select } from 'dt-react-component';

export default () => {
const [loading, setLoading] = useState(false);
return (
<Space direction="vertical">
<Space>
加载中 <Switch checked={loading} onChange={setLoading} />
</Space>
<Select
style={{ width: 320 }}
loading={loading}
options={[]}
placeholder="展开查看加载或空状态"
/>
<Select
style={{ width: 320 }}
options={[]}
notFoundContent="暂无匹配结果"
placeholder="自定义空状态"
/>
</Space>
);
};
18 changes: 18 additions & 0 deletions src/select/demos/maxRows.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from 'react';
import { Select } from 'dt-react-component';

const options = Array.from({ length: 20 }, (_, index) => ({
label: `选项 ${index + 1}`,
value: index + 1,
}));

export default () => (
<Select
mode="multiple"
maxRows={3}
style={{ width: 320 }}
defaultValue={[1, 2, 3, 4, 5, 6, 7, 8]}
options={options}
placeholder="请选择"
/>
);
22 changes: 22 additions & 0 deletions src/select/demos/search.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React, { useState } from 'react';
import { Select } from 'dt-react-component';

export default () => {
const [keyword, setKeyword] = useState('');
return (
<>
<p>最近一次搜索:{keyword || '尚未输入'}</p>
<Select
showSearch
style={{ width: 320 }}
placeholder="试试输入中文"
options={[
{ label: '中文搜索', value: 'chinese' },
{ label: '数据分析', value: 'analysis' },
]}
optionFilterProp="label"
onSearch={setKeyword}
/>
</>
);
};
27 changes: 27 additions & 0 deletions src/select/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
title: Select 选择器
group: 组件
toc: content
demo:
cols: 2
---

# Select 选择器

基于 antd Select,增加多选区域限高和自动滚动,并统一加载、空状态展示。搜索时支持中文输入法:合成输入期间不调用 `onSearch`,合成结束后发送完整内容,并抑制紧随其后的同值搜索回调。

## 示例

<code src="./demos/maxRows.tsx" description="`maxRows` 大于 0 时启用限高,选中选项后在下一帧平滑滚动到底部。适用于 `multiple` 和 `tags` 模式;该参数控制选择器区域,不控制下拉列表高度。">多选区域限高</code>
<code src="./demos/search.tsx" description="输入拼音过程中不会调用 `onSearch`。选字完成后才更新搜索内容,适合在 `onSearch` 中触发远程查询;组件本身不提供防抖或请求管理。">中文搜索</code>
<code src="./demos/empty.tsx" description="无匹配选项时,`loading` 为 `true` 展示小尺寸 Spin,否则展示组件库 Empty。可以通过 `notFoundContent` 自定义内容。">加载与空状态</code>

## API

### Select

其余属性沿用 antd Select。

| 参数 | 说明 | 类型 | 默认值 |
| ------- | ---------------------------------------------------------------------------------- | -------- | ------ |
| maxRows | 选择器最大展示行数。大于 0 时启用限高和选中后自动滚动;未设置或小于等于 0 时不启用 | `number` | - |
17 changes: 17 additions & 0 deletions src/select/index.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
$prefix: "dtc-select";

.#{$prefix} {
&--scroll {
.ant-select-selector {
max-height: inherit;
overflow-y: auto;
}
}
&__scroll-dropdown {
.ant-select-item-empty {
display: flex;
align-items: center;
justify-content: center;
}
}
}
99 changes: 99 additions & 0 deletions src/select/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React, { useRef } from 'react';
import { Select as OriginSelect, SelectProps, Spin } from 'antd';
import { BaseOptionType, DefaultOptionType } from 'antd/lib/select';
import classNames from 'classnames';
import { Empty } from 'dt-react-component';

import './index.scss';

interface ExtendedSelectProps<
ValueType = any,
OptionType extends BaseOptionType | DefaultOptionType = DefaultOptionType
> extends SelectProps<ValueType, OptionType> {
maxRows?: number;
}

export default function Select<
ValueType = any,
OptionType extends BaseOptionType | DefaultOptionType = DefaultOptionType
>({
style,
maxRows,
dropdownClassName,
onSearch,
onSelect,
...restProps
}: ExtendedSelectProps<ValueType, OptionType>) {
const containerRef = useRef<HTMLDivElement>(null);
const isComposingRef = useRef(false);
const compositionEndValueRef = useRef<string>();
// 三种 size 的行高相差 8px,保留原有 medium 的行高与边界补偿。
const rowHeight = { small: 20, middle: 28, large: 36 }[restProps.size || 'middle'];
const maxHeight = maxRows && maxRows > 0 ? maxRows * rowHeight + 4 : undefined;
const isScrollEnabled = maxHeight !== undefined;

const handleSearch: SelectProps<ValueType, OptionType>['onSearch'] = (value) => {
if (isComposingRef.current) return;
// compositionEnd 后可能再次触发相同的 onSearch,避免重复请求
if (compositionEndValueRef.current === value) {
compositionEndValueRef.current = undefined;
return;
}
compositionEndValueRef.current = undefined;
onSearch?.(value);
};

const handleCompositionStart = () => {
isComposingRef.current = true;
};

const handleCompositionEnd = (event: React.CompositionEvent<HTMLDivElement>) => {
const target = event.target as HTMLInputElement;
if (!target.classList.contains('ant-select-selection-search-input')) return;

isComposingRef.current = false;
compositionEndValueRef.current = target.value;
onSearch?.(target.value);
requestAnimationFrame(() => {
compositionEndValueRef.current = undefined;
});
};

const handleSelect: SelectProps<ValueType, OptionType>['onSelect'] = (value, option) => {
if (isScrollEnabled) {
// 等待选中项渲染完成后滚动到底部
requestAnimationFrame(() => {
const scrollElement =
containerRef.current?.querySelector<HTMLElement>('.ant-select-selector');

scrollElement?.scrollTo({
top: scrollElement.scrollHeight,
behavior: 'smooth',
});
});
}

onSelect?.(value, option);
};

return (
<div
ref={containerRef}
className={isScrollEnabled ? 'dtc-select--scroll' : undefined}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
>
<OriginSelect
notFoundContent={restProps.loading ? <Spin size="small" /> : <Empty />}
{...restProps}
dropdownClassName={classNames(
dropdownClassName,
isScrollEnabled && 'dtc-select__scroll-dropdown'
)}
style={{ maxHeight, ...style }}
onSearch={handleSearch}
onSelect={handleSelect}
/>
</div>
);
}
Loading