Skip to content

Commit 4fdb8a0

Browse files
authored
feat(select): add scrollToSelected prop to auto-scroll dropdown to selected option (#96)
1 parent 0ae2f7f commit 4fdb8a0

File tree

7 files changed

+129
-1
lines changed

7 files changed

+129
-1
lines changed
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: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render, fireEvent } from '@testing-library/react';
1+
import { render, fireEvent, 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>
@@ -349,6 +357,55 @@ describe('<Select />', () => {
349357
expect(onSelect).toHaveBeenCalledWith('a');
350358
});
351359

360+
// scrollToSelected
361+
it('should set scrollTop when dropdown opens with a selected value', () => {
362+
const options = Array.from({ length: 50 }, (_, i) => ({
363+
value: `opt-${i}`,
364+
label: `Option ${i}`,
365+
}));
366+
367+
const { container } = render(<Select options={options} defaultValue="opt-40" />);
368+
const selector = container.querySelector('.ty-select__selector') as HTMLElement;
369+
370+
// Mock offsetTop on selected option before opening
371+
const origDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetTop');
372+
Object.defineProperty(HTMLElement.prototype, 'offsetTop', {
373+
configurable: true,
374+
get() {
375+
if (this.getAttribute?.('aria-selected') === 'true') return 400;
376+
return 0;
377+
},
378+
});
379+
380+
fireEvent.click(selector);
381+
jest.runAllTimers();
382+
383+
const dropdown = document.querySelector('.ty-select__dropdown') as HTMLElement;
384+
expect(dropdown.scrollTop).toBe(400);
385+
386+
if (origDescriptor) {
387+
Object.defineProperty(HTMLElement.prototype, 'offsetTop', origDescriptor);
388+
}
389+
});
390+
391+
it('should not scroll when scrollToSelected is false', () => {
392+
const options = Array.from({ length: 50 }, (_, i) => ({
393+
value: `opt-${i}`,
394+
label: `Option ${i}`,
395+
}));
396+
397+
const { container } = render(
398+
<Select options={options} defaultValue="opt-40" scrollToSelected={false} />
399+
);
400+
const selector = container.querySelector('.ty-select__selector') as HTMLElement;
401+
fireEvent.click(selector);
402+
403+
jest.runAllTimers();
404+
405+
const dropdown = document.querySelector('.ty-select__dropdown') as HTMLElement;
406+
expect(dropdown.scrollTop).toBe(0);
407+
});
408+
352409
// Custom filter function
353410
it('should support custom filterOption function', () => {
354411
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: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const Select = (props: SelectProps): React.ReactElement => {
3434
placeholder,
3535
className,
3636
children,
37+
scrollToSelected = true,
3738
dropdownStyle,
3839
...otherProps
3940
} = props;
@@ -48,6 +49,18 @@ const Select = (props: SelectProps): React.ReactElement => {
4849

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

5366
const configContext = useContext(ConfigContext);
@@ -360,6 +373,7 @@ const Select = (props: SelectProps): React.ReactElement => {
360373
return (
361374
<SelectContext.Provider value={contextValue}>
362375
<ul
376+
ref={dropdownRef}
363377
className={`${prefixCls}__dropdown`}
364378
style={{ minWidth: selectorWidth || undefined, ...dropdownStyle }}
365379
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)