Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/feat-select-scroll-to-selected.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tiny-design/react": minor
---

Add `scrollToSelected` prop to Select component that automatically scrolls the dropdown to the first selected option when opened
59 changes: 58 additions & 1 deletion packages/react/src/select/__tests__/select.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, fireEvent } from '@testing-library/react';
import { render, fireEvent, act } from '@testing-library/react';
import Select from '../index';

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

describe('<Select />', () => {
beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

it('should match the snapshot', () => {
const { asFragment } = render(
<Select>
Expand Down Expand Up @@ -349,6 +357,55 @@ describe('<Select />', () => {
expect(onSelect).toHaveBeenCalledWith('a');
});

// scrollToSelected
it('should set scrollTop when dropdown opens with a selected value', () => {
const options = Array.from({ length: 50 }, (_, i) => ({
value: `opt-${i}`,
label: `Option ${i}`,
}));

const { container } = render(<Select options={options} defaultValue="opt-40" />);
const selector = container.querySelector('.ty-select__selector') as HTMLElement;

// Mock offsetTop on selected option before opening
const origDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetTop');
Object.defineProperty(HTMLElement.prototype, 'offsetTop', {
configurable: true,
get() {
if (this.getAttribute?.('aria-selected') === 'true') return 400;
return 0;
},
});

fireEvent.click(selector);
jest.runAllTimers();

const dropdown = document.querySelector('.ty-select__dropdown') as HTMLElement;
expect(dropdown.scrollTop).toBe(400);

if (origDescriptor) {
Object.defineProperty(HTMLElement.prototype, 'offsetTop', origDescriptor);
}
});

it('should not scroll when scrollToSelected is false', () => {
const options = Array.from({ length: 50 }, (_, i) => ({
value: `opt-${i}`,
label: `Option ${i}`,
}));

const { container } = render(
<Select options={options} defaultValue="opt-40" scrollToSelected={false} />
);
const selector = container.querySelector('.ty-select__selector') as HTMLElement;
fireEvent.click(selector);

jest.runAllTimers();

const dropdown = document.querySelector('.ty-select__dropdown') as HTMLElement;
expect(dropdown.scrollTop).toBe(0);
});

// Custom filter function
it('should support custom filterOption function', () => {
const { container } = render(
Expand Down
27 changes: 27 additions & 0 deletions packages/react/src/select/demo/ScrollToSelected.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from 'react';
import { Select } from '@tiny-design/react';

const options = Array.from({ length: 30 }, (_, i) => ({
value: `option-${i + 1}`,
label: `Option ${i + 1}`,
}));

export default function ScrollToSelectedDemo() {
return (
<div style={{ display: 'flex', gap: 16 }}>
<Select
style={{ width: 200 }}
placeholder="Enabled (default)"
defaultValue="option-25"
options={options}
/>
<Select
style={{ width: 200 }}
placeholder="Disabled"
defaultValue="option-25"
scrollToSelected={false}
options={options}
/>
</div>
);
}
12 changes: 12 additions & 0 deletions packages/react/src/select/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import CustomDemo from './demo/Custom';
import CustomSource from './demo/Custom.tsx?raw';
import RenderDemo from './demo/Render';
import RenderSource from './demo/Render.tsx?raw';
import ScrollToSelectedDemo from './demo/ScrollToSelected';
import ScrollToSelectedSource from './demo/ScrollToSelected.tsx?raw';

# Select

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

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

</Demo>
<Demo>

### Scroll to Selected

Automatically scrolls the dropdown to the selected option when opened. Enabled by default, set `scrollToSelected={false}` to disable.

<DemoBlock component={ScrollToSelectedDemo} source={ScrollToSelectedSource} />

</Demo>
</Column>
<Column>
Expand Down Expand Up @@ -130,6 +141,7 @@ Use `optionRender` to customize dropdown items and `labelRender` to customize th
| defaultOpen | Initial open state of dropdown | boolean | false |
| open | Controlled open state of dropdown | boolean | - |
| onDropdownVisibleChange | Callback when dropdown open state changes | (open: boolean) => void | - |
| scrollToSelected | Scroll to selected option when dropdown opens | boolean | true |
| dropdownStyle | Style of dropdown menu | CSSProperties | - |
| style | Style of container | CSSProperties | - |
| className | Class name of container | string | - |
Expand Down
12 changes: 12 additions & 0 deletions packages/react/src/select/index.zh_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import CustomDemo from './demo/Custom';
import CustomSource from './demo/Custom.tsx?raw';
import RenderDemo from './demo/Render';
import RenderSource from './demo/Render.tsx?raw';
import ScrollToSelectedDemo from './demo/ScrollToSelected';
import ScrollToSelectedSource from './demo/ScrollToSelected.tsx?raw';

# Select 选择器

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

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

</Demo>
<Demo>

### 滚动到选中项

打开下拉菜单时自动滚动到已选中的选项。默认开启,设置 `scrollToSelected={false}` 可关闭。

<DemoBlock component={ScrollToSelectedDemo} source={ScrollToSelectedSource} />

</Demo>
</Column>
<Column>
Expand Down Expand Up @@ -130,6 +141,7 @@ Select 组件的基本用法。
| defaultOpen | 下拉菜单的初始展开状态 | boolean | false |
| open | 下拉菜单的受控展开状态 | boolean | - |
| onDropdownVisibleChange | 下拉菜单展开状态变化时的回调 | (open: boolean) => void | - |
| scrollToSelected | 下拉菜单打开时是否滚动到已选中的选项 | boolean | true |
| dropdownStyle | 下拉菜单的样式 | CSSProperties | - |
| style | 容器的样式对象 | CSSProperties | - |
| className | 容器的类名 | string | - |
Expand Down
14 changes: 14 additions & 0 deletions packages/react/src/select/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const Select = (props: SelectProps): React.ReactElement => {
placeholder,
className,
children,
scrollToSelected = true,
dropdownStyle,
...otherProps
} = props;
Expand All @@ -48,6 +49,18 @@ const Select = (props: SelectProps): React.ReactElement => {

const ref = useRef<HTMLDivElement | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const dropdownRef = useCallback(
(node: HTMLUListElement | null) => {
if (!node || !scrollToSelected) return;
requestAnimationFrame(() => {
const selectedEl = node.querySelector('[aria-selected="true"]') as HTMLElement | null;
if (selectedEl) {
node.scrollTop = selectedEl.offsetTop - node.offsetTop;
}
});
},
[scrollToSelected]
);
const listboxId = useId();

const configContext = useContext(ConfigContext);
Expand Down Expand Up @@ -360,6 +373,7 @@ const Select = (props: SelectProps): React.ReactElement => {
return (
<SelectContext.Provider value={contextValue}>
<ul
ref={dropdownRef}
className={`${prefixCls}__dropdown`}
style={{ minWidth: selectorWidth || undefined, ...dropdownStyle }}
role="listbox"
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/select/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export interface SelectProps
defaultOpen?: boolean;
open?: boolean;
onDropdownVisibleChange?: (open: boolean) => void;
scrollToSelected?: boolean;
dropdownStyle?: React.CSSProperties;
children?: React.ReactNode;
}
Loading