From 1147ec72cff4fa4c4ce0ac627d8ba95ee5d3c2d5 Mon Sep 17 00:00:00 2001 From: zwight Date: Sat, 10 Oct 2026 11:30:00 +0800 Subject: [PATCH 1/2] feat(select): add select component with maxRows and IME-safe search --- src/index.ts | 1 + src/select/__tests__/select.test.tsx | 154 +++++++++++++++++++++++++++ src/select/demos/empty.tsx | 26 +++++ src/select/demos/maxRows.tsx | 18 ++++ src/select/demos/search.tsx | 22 ++++ src/select/index.md | 27 +++++ src/select/index.scss | 17 +++ src/select/index.tsx | 99 +++++++++++++++++ 8 files changed, 364 insertions(+) create mode 100644 src/select/__tests__/select.test.tsx create mode 100644 src/select/demos/empty.tsx create mode 100644 src/select/demos/maxRows.tsx create mode 100644 src/select/demos/search.tsx create mode 100644 src/select/index.md create mode 100644 src/select/index.scss create mode 100644 src/select/index.tsx diff --git a/src/index.ts b/src/index.ts index 9a1c52dae..a2a605a27 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/select/__tests__/select.test.tsx b/src/select/__tests__/select.test.tsx new file mode 100644 index 000000000..44c7408af --- /dev/null +++ b/src/select/__tests__/select.test.tsx @@ -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(); + expect(container.querySelector('.ant-select')).toHaveStyle({ maxHeight: `${maxHeight}px` }); + rerender( + ); + expect(container.firstChild).toHaveClass('enhanced-select--scroll'); + expect(container.querySelector('.ant-select')).toHaveStyle({ + maxHeight: '88px', + width: '240px', + }); + expect(document.querySelector('.ant-select-dropdown')).toHaveClass( + 'custom-dropdown', + 'select-with-scroll-dropdown' + ); + rerender( + ); + const selector = container.querySelector('.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(); + 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(); + expect(document.querySelector('.ant-select-item-empty .ant-spin')).toBeInTheDocument(); + expect(document.querySelector('.dtc-empty')).not.toBeInTheDocument(); + rerender(); + expect(getByText('暂无匹配结果')).toBeInTheDocument(); + expect(document.querySelector('.ant-select-item-empty .ant-spin')).not.toBeInTheDocument(); + }); +}); diff --git a/src/select/demos/empty.tsx b/src/select/demos/empty.tsx new file mode 100644 index 000000000..43e3e3054 --- /dev/null +++ b/src/select/demos/empty.tsx @@ -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 ( + + + 加载中 + + + + ); +}; diff --git a/src/select/demos/maxRows.tsx b/src/select/demos/maxRows.tsx new file mode 100644 index 000000000..fc21c3816 --- /dev/null +++ b/src/select/demos/maxRows.tsx @@ -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 () => ( + + + ); +}; diff --git a/src/select/index.md b/src/select/index.md new file mode 100644 index 000000000..605750aef --- /dev/null +++ b/src/select/index.md @@ -0,0 +1,27 @@ +--- +title: Select 选择器 +group: 组件 +toc: content +demo: + cols: 2 +--- + +# Select 选择器 + +基于 antd Select,增加多选区域限高和自动滚动,并统一加载、空状态展示。搜索时支持中文输入法:合成输入期间不调用 `onSearch`,合成结束后发送完整内容,并抑制紧随其后的同值搜索回调。 + +## 示例 + +多选区域限高 +中文搜索 +加载与空状态 + +## API + +### Select + +其余属性沿用 antd Select。 + +| 参数 | 说明 | 类型 | 默认值 | +| ------- | ---------------------------------------------------------------------------------- | -------- | ------ | +| maxRows | 选择器最大展示行数。大于 0 时启用限高和选中后自动滚动;未设置或小于等于 0 时不启用 | `number` | - | diff --git a/src/select/index.scss b/src/select/index.scss new file mode 100644 index 000000000..053f368ea --- /dev/null +++ b/src/select/index.scss @@ -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; + } + } +} diff --git a/src/select/index.tsx b/src/select/index.tsx new file mode 100644 index 000000000..d77e17477 --- /dev/null +++ b/src/select/index.tsx @@ -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 { + maxRows?: number; +} + +export default function Select< + ValueType = any, + OptionType extends BaseOptionType | DefaultOptionType = DefaultOptionType +>({ + style, + maxRows, + dropdownClassName, + onSearch, + onSelect, + ...restProps +}: ExtendedSelectProps) { + const containerRef = useRef(null); + const isComposingRef = useRef(false); + const compositionEndValueRef = useRef(); + // 三种 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['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) => { + 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['onSelect'] = (value, option) => { + if (isScrollEnabled) { + // 等待选中项渲染完成后滚动到底部 + requestAnimationFrame(() => { + const scrollElement = + containerRef.current?.querySelector('.ant-select-selector'); + + scrollElement?.scrollTo({ + top: scrollElement.scrollHeight, + behavior: 'smooth', + }); + }); + } + + onSelect?.(value, option); + }; + + return ( +
+ : } + {...restProps} + dropdownClassName={classNames( + dropdownClassName, + isScrollEnabled && 'dtc-select__scroll-dropdown' + )} + style={{ maxHeight, ...style }} + onSearch={handleSearch} + onSelect={handleSelect} + /> +
+ ); +} From 0e948dee2de8164bdc3a9c2fad156da3fbd83e57 Mon Sep 17 00:00:00 2001 From: zwight Date: Tue, 13 Oct 2026 16:33:56 +0800 Subject: [PATCH 2/2] fix(select): fix select tests for IME search and scroll behavior --- src/select/__tests__/select.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/select/__tests__/select.test.tsx b/src/select/__tests__/select.test.tsx index 44c7408af..b094046e4 100644 --- a/src/select/__tests__/select.test.tsx +++ b/src/select/__tests__/select.test.tsx @@ -29,10 +29,10 @@ describe('Select', () => { it.each([undefined, 0, -1])('should disable scrolling when maxRows is %s', (maxRows) => { const { container } = render( ); - expect(container.firstChild).toHaveClass('enhanced-select--scroll'); + 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', - 'select-with-scroll-dropdown' + 'dtc-select__scroll-dropdown' ); rerender(