Skip to content

Commit 8f5e206

Browse files
committed
Merge branch 'master' of github.com:wangdicoder/tiny-design
2 parents 9646ab8 + 4fdb8a0 commit 8f5e206

7 files changed

Lines changed: 164 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tiny-design/react": minor
3+
---
4+
5+
Add `scrollToSelected` prop to Select component that automatically scrolls the dropdown to the first selected option when opened

packages/react/src/select/__tests__/select.test.tsx

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render, fireEvent, screen, waitFor } from '@testing-library/react';
1+
import { render, fireEvent, screen, waitFor, act } from '@testing-library/react';
22
import Select from '../index';
33

44
const { Option, OptGroup } = Select;
@@ -7,6 +7,14 @@ const { Option, OptGroup } = Select;
77
const getOptions = () => document.querySelectorAll('.ty-select-option');
88

99
describe('<Select />', () => {
10+
beforeEach(() => {
11+
jest.useFakeTimers();
12+
});
13+
14+
afterEach(() => {
15+
jest.useRealTimers();
16+
});
17+
1018
it('should match the snapshot', () => {
1119
const { asFragment } = render(
1220
<Select>
@@ -157,6 +165,31 @@ describe('<Select />', () => {
157165
expect(onSearch).toHaveBeenCalledWith('test');
158166
});
159167

168+
it('should focus the search input after opening', () => {
169+
const { container } = render(
170+
<Select showSearch>
171+
<Option value="a">Apple</Option>
172+
<Option value="b">Banana</Option>
173+
</Select>
174+
);
175+
176+
const selector = container.querySelector('.ty-select__selector') as HTMLElement;
177+
fireEvent.click(selector);
178+
179+
act(() => {
180+
jest.runAllTimers();
181+
});
182+
183+
const searchInput = container.querySelector('.ty-select__search') as HTMLInputElement;
184+
expect(searchInput).toHaveFocus();
185+
186+
fireEvent.change(searchInput, { target: { value: 'ban' } });
187+
188+
const options = getOptions();
189+
expect(options.length).toBe(1);
190+
expect(options[0]).toHaveTextContent('Banana');
191+
});
192+
160193
// Keyboard
161194
it('should navigate with arrow keys and select with Enter', () => {
162195
const onChange = jest.fn();
@@ -386,6 +419,55 @@ describe('<Select />', () => {
386419
expect(onSelect).toHaveBeenCalledWith('a');
387420
});
388421

422+
// scrollToSelected
423+
it('should set scrollTop when dropdown opens with a selected value', () => {
424+
const options = Array.from({ length: 50 }, (_, i) => ({
425+
value: `opt-${i}`,
426+
label: `Option ${i}`,
427+
}));
428+
429+
const { container } = render(<Select options={options} defaultValue="opt-40" />);
430+
const selector = container.querySelector('.ty-select__selector') as HTMLElement;
431+
432+
// Mock offsetTop on selected option before opening
433+
const origDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetTop');
434+
Object.defineProperty(HTMLElement.prototype, 'offsetTop', {
435+
configurable: true,
436+
get() {
437+
if (this.getAttribute?.('aria-selected') === 'true') return 400;
438+
return 0;
439+
},
440+
});
441+
442+
fireEvent.click(selector);
443+
jest.runAllTimers();
444+
445+
const dropdown = document.querySelector('.ty-select__dropdown') as HTMLElement;
446+
expect(dropdown.scrollTop).toBe(400);
447+
448+
if (origDescriptor) {
449+
Object.defineProperty(HTMLElement.prototype, 'offsetTop', origDescriptor);
450+
}
451+
});
452+
453+
it('should not scroll when scrollToSelected is false', () => {
454+
const options = Array.from({ length: 50 }, (_, i) => ({
455+
value: `opt-${i}`,
456+
label: `Option ${i}`,
457+
}));
458+
459+
const { container } = render(
460+
<Select options={options} defaultValue="opt-40" scrollToSelected={false} />
461+
);
462+
const selector = container.querySelector('.ty-select__selector') as HTMLElement;
463+
fireEvent.click(selector);
464+
465+
jest.runAllTimers();
466+
467+
const dropdown = document.querySelector('.ty-select__dropdown') as HTMLElement;
468+
expect(dropdown.scrollTop).toBe(0);
469+
});
470+
389471
// Custom filter function
390472
it('should support custom filterOption function', () => {
391473
const { container } = render(
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import React from 'react';
2+
import { Select } from '@tiny-design/react';
3+
4+
const options = Array.from({ length: 30 }, (_, i) => ({
5+
value: `option-${i + 1}`,
6+
label: `Option ${i + 1}`,
7+
}));
8+
9+
export default function ScrollToSelectedDemo() {
10+
return (
11+
<div style={{ display: 'flex', gap: 16 }}>
12+
<Select
13+
style={{ width: 200 }}
14+
placeholder="Enabled (default)"
15+
defaultValue="option-25"
16+
options={options}
17+
/>
18+
<Select
19+
style={{ width: 200 }}
20+
placeholder="Disabled"
21+
defaultValue="option-25"
22+
scrollToSelected={false}
23+
options={options}
24+
/>
25+
</div>
26+
);
27+
}

packages/react/src/select/index.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import CustomDemo from './demo/Custom';
1212
import CustomSource from './demo/Custom.tsx?raw';
1313
import RenderDemo from './demo/Render';
1414
import RenderSource from './demo/Render.tsx?raw';
15+
import ScrollToSelectedDemo from './demo/ScrollToSelected';
16+
import ScrollToSelectedSource from './demo/ScrollToSelected.tsx?raw';
1517

1618
# Select
1719

@@ -61,6 +63,15 @@ Multiple selection mode displays selected items as tags. Set `mode="multiple"` a
6163

6264
<DemoBlock component={MultipleDemo} source={MultipleSource} />
6365

66+
</Demo>
67+
<Demo>
68+
69+
### Scroll to Selected
70+
71+
Automatically scrolls the dropdown to the selected option when opened. Enabled by default, set `scrollToSelected={false}` to disable.
72+
73+
<DemoBlock component={ScrollToSelectedDemo} source={ScrollToSelectedSource} />
74+
6475
</Demo>
6576
</Column>
6677
<Column>
@@ -130,6 +141,7 @@ Use `optionRender` to customize dropdown items and `labelRender` to customize th
130141
| defaultOpen | Initial open state of dropdown | boolean | false |
131142
| open | Controlled open state of dropdown | boolean | - |
132143
| onDropdownVisibleChange | Callback when dropdown open state changes | (open: boolean) => void | - |
144+
| scrollToSelected | Scroll to selected option when dropdown opens | boolean | true |
133145
| dropdownStyle | Style of dropdown menu | CSSProperties | - |
134146
| style | Style of container | CSSProperties | - |
135147
| className | Class name of container | string | - |

packages/react/src/select/index.zh_CN.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import CustomDemo from './demo/Custom';
1212
import CustomSource from './demo/Custom.tsx?raw';
1313
import RenderDemo from './demo/Render';
1414
import RenderSource from './demo/Render.tsx?raw';
15+
import ScrollToSelectedDemo from './demo/ScrollToSelected';
16+
import ScrollToSelectedSource from './demo/ScrollToSelected.tsx?raw';
1517

1618
# Select 选择器
1719

@@ -61,6 +63,15 @@ Select 组件的基本用法。
6163

6264
<DemoBlock component={MultipleDemo} source={MultipleSource} />
6365

66+
</Demo>
67+
<Demo>
68+
69+
### 滚动到选中项
70+
71+
打开下拉菜单时自动滚动到已选中的选项。默认开启,设置 `scrollToSelected={false}` 可关闭。
72+
73+
<DemoBlock component={ScrollToSelectedDemo} source={ScrollToSelectedSource} />
74+
6475
</Demo>
6576
</Column>
6677
<Column>
@@ -130,6 +141,7 @@ Select 组件的基本用法。
130141
| defaultOpen | 下拉菜单的初始展开状态 | boolean | false |
131142
| open | 下拉菜单的受控展开状态 | boolean | - |
132143
| onDropdownVisibleChange | 下拉菜单展开状态变化时的回调 | (open: boolean) => void | - |
144+
| scrollToSelected | 下拉菜单打开时是否滚动到已选中的选项 | boolean | true |
133145
| dropdownStyle | 下拉菜单的样式 | CSSProperties | - |
134146
| style | 容器的样式对象 | CSSProperties | - |
135147
| className | 容器的类名 | string | - |

packages/react/src/select/select.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const Select = (props: SelectProps): React.ReactElement => {
3333
placeholder,
3434
className,
3535
children,
36+
scrollToSelected = true,
3637
dropdownStyle,
3738
...otherProps
3839
} = props;
@@ -47,6 +48,18 @@ const Select = (props: SelectProps): React.ReactElement => {
4748

4849
const ref = useRef<HTMLDivElement | null>(null);
4950
const searchInputRef = useRef<HTMLInputElement | null>(null);
51+
const dropdownRef = useCallback(
52+
(node: HTMLUListElement | null) => {
53+
if (!node || !scrollToSelected) return;
54+
requestAnimationFrame(() => {
55+
const selectedEl = node.querySelector('[aria-selected="true"]') as HTMLElement | null;
56+
if (selectedEl) {
57+
node.scrollTop = selectedEl.offsetTop - node.offsetTop;
58+
}
59+
});
60+
},
61+
[scrollToSelected]
62+
);
5063
const listboxId = useId();
5164

5265
const configContext = useContext(ConfigContext);
@@ -198,7 +211,6 @@ const Select = (props: SelectProps): React.ReactElement => {
198211
combo.closeDropdown();
199212
} else {
200213
combo.openDropdown();
201-
setTimeout(() => searchInputRef.current?.focus(), 0);
202214
}
203215
};
204216

@@ -244,6 +256,16 @@ const Select = (props: SelectProps): React.ReactElement => {
244256
// eslint-disable-next-line react-hooks/exhaustive-deps
245257
}, [searchValue]);
246258

259+
useEffect(() => {
260+
if (!combo.isOpen || !showSearch || disabled) return;
261+
262+
const frameId = requestAnimationFrame(() => {
263+
searchInputRef.current?.focus();
264+
});
265+
266+
return () => cancelAnimationFrame(frameId);
267+
}, [combo.isOpen, showSearch, disabled]);
268+
247269
const hasValue = hasSomeValue;
248270

249271
// Context value
@@ -352,6 +374,7 @@ const Select = (props: SelectProps): React.ReactElement => {
352374
return (
353375
<SelectContext.Provider value={contextValue}>
354376
<ul
377+
ref={dropdownRef}
355378
className={`${prefixCls}__dropdown`}
356379
style={{ minWidth: selectorWidth || undefined, ...dropdownStyle }}
357380
role="listbox"

packages/react/src/select/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export interface SelectProps
5252
defaultOpen?: boolean;
5353
open?: boolean;
5454
onDropdownVisibleChange?: (open: boolean) => void;
55+
scrollToSelected?: boolean;
5556
dropdownStyle?: React.CSSProperties;
5657
children?: React.ReactNode;
5758
}

0 commit comments

Comments
 (0)