Skip to content
Open
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
4 changes: 3 additions & 1 deletion packages/components/cascader/Cascader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ const Cascader: React.FC<CascaderProps> = (originalProps) => {
const { setVisible, visible, inputVal, setInputVal } = cascaderContext;

const updateScrollTop = (content: HTMLDivElement) => {
// virtual scroll not trigger event
if (props.scroll) return;
Copy link

Copilot AI Oct 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This disables updateScrollTop for any scroll config, but the intent appears to be only when using virtual scrolling; if scroll also configures lazy loading, this change will unintentionally suppress the alignment behavior. Check the scroll type instead, e.g., if (props.scroll?.type === 'virtual') return;.

Suggested change
if (props.scroll) return;
if (props.scroll?.type === 'virtual') return;

Copilot uses AI. Check for mistakes.
const cascaderMenuList = content.querySelectorAll(`.${COMPONENT_NAME}__menu`);
requestAnimationFrame(() => {
cascaderMenuList.forEach((menu: HTMLDivElement) => {
Expand Down Expand Up @@ -235,7 +237,7 @@ const Cascader: React.FC<CascaderProps> = (originalProps) => {
{props.panelTopContent && parseTNode(props.panelTopContent)}
<Panel
cascaderContext={cascaderContext}
{...pick(props, ['trigger', 'onChange', 'empty', 'loading', 'loadingText', 'option'])}
{...pick(props, ['trigger', 'onChange', 'empty', 'loading', 'loadingText', 'option', 'scroll'])}
></Panel>
{props.panelBottomContent && parseTNode(props.panelBottomContent)}
</>
Expand Down
2 changes: 1 addition & 1 deletion packages/components/cascader/CascaderPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const CascaderPanel: React.FC<CascaderProps> = (originalProps) => {
className={classNames(props.className)}
style={props.style}
cascaderContext={cascaderContext}
{...pick(props, ['trigger', 'onChange', 'empty', 'option'])}
{...pick(props, ['trigger', 'onChange', 'empty', 'option', 'scroll'])}
></Panel>
);
};
Expand Down
50 changes: 50 additions & 0 deletions packages/components/cascader/_example/virtual-scroll.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React, { useState } from 'react';
import { Cascader } from 'tdesign-react';
import type { CascaderProps, CascaderValue } from 'tdesign-react';

const list = [];
for (let i = 1; i < 100; i++) {
const children = [];
for (let j = 1; j < 100; j++) {
const child = [];
for (let k = 1; k < 100; k++) {
Comment on lines +6 to +10
Copy link

Copilot AI Oct 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This creates ~970k nodes (99×99×99), which is very heavy for demos/tests and can cause excessive memory/CPU usage even if virtualized rendering is enabled. Reduce the data size (e.g., 30×30×30 or less) or switch to a lazy-loading example to illustrate virtualization without constructing such a large tree eagerly.

Suggested change
for (let i = 1; i < 100; i++) {
const children = [];
for (let j = 1; j < 100; j++) {
const child = [];
for (let k = 1; k < 100; k++) {
for (let i = 1; i < 30; i++) {
const children = [];
for (let j = 1; j < 30; j++) {
const child = [];
for (let k = 1; k < 30; k++) {

Copilot uses AI. Check for mistakes.
child.push({
label: `子选项${i}.${j}.${k}`,
value: `${i}.${j}.${k}`,
});
}
children.push({
label: `子选项${i}.${j}`,
value: `${i}.${j}`,
children: child,
});
}

list.push({
label: `选项${i}`,
value: `${i}`,
children,
});
}

export default function Example() {
const [value, setValue] = useState<CascaderValue>(['20.1.20']);
const options = list;

const onChange: CascaderProps['onChange'] = (value) => {
setValue(value);
};

return (
<div className="tdesign-demo-block-row">
<Cascader
options={options}
onChange={onChange}
value={value}
multiple
clearable
scroll={{ type: 'virtual', bufferSize: 5, threshold: 10 }}
/>
</div>
);
}
1 change: 1 addition & 0 deletions packages/components/cascader/cascader.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ defaultPopupVisible | Boolean | - | uncontrolled property | N
prefixIcon | TElement | - | Typescript:`TNode`。[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N
readonly | Boolean | false | \- | N
reserveKeyword | Boolean | false | \- | N
scroll | \- | - | Lazy loading and virtual scrolling. To maximize the benefits of the component, when the amount of data is less than the threshold `scroll.threshold`, regardless of whether the virtual scrolling configuration exists, virtual scrolling will not be enabled within the component. `scroll.threshold` defaults to `100`。Typescript:`TScroll`。[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N
selectInputProps | Object | - | Typescript:`SelectInputProps`,[SelectInput API Documents](./select-input?tab=api)。[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/cascader/type.ts) | N
showAllLevels | Boolean | true | \- | N
size | String | medium | options: large/medium/small。Typescript:`SizeEnum`。[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N
Expand Down
1 change: 1 addition & 0 deletions packages/components/cascader/cascader.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ defaultPopupVisible | Boolean | - | 是否显示下拉框。非受控属性 | N
prefixIcon | TElement | - | 组件前置图标。TS 类型:`TNode`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N
readonly | Boolean | false | 只读状态,值为真会隐藏输入框,且无法打开下拉框 | N
reserveKeyword | Boolean | false | 多选且可搜索时,是否在选中一个选项后保留当前的搜索关键词 | N
scroll | \- | - | 懒加载和虚拟滚动。为保证组件收益最大化,当数据量小于阈值 `scroll.threshold` 时,无论虚拟滚动的配置是否存在,组件内部都不会开启虚拟滚动,`scroll.threshold` 默认为 `100`。TS 类型:`TScroll`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N
selectInputProps | Object | - | 透传 SelectInput 筛选器输入框组件的全部属性。TS 类型:`SelectInputProps`,[SelectInput API Documents](./select-input?tab=api)。[详细类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/cascader/type.ts) | N
showAllLevels | Boolean | true | 选中值使用完整路径,输入框在单选时也显示完整路径 | N
size | String | medium | 组件尺寸。可选项:large/medium/small。TS 类型:`SizeEnum`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N
Expand Down
124 changes: 124 additions & 0 deletions packages/components/cascader/components/List.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import React, { useEffect, useRef } from 'react';
import classNames from 'classnames';
import { TreeNode } from '../interface';
import useConfig from '../../hooks/useConfig';
import Item from './Item';
import parseTNode from '../../_util/parseTNode';
import { expendClickEffect, valueChangeEffect } from '../core/effect';
import { useListVirtualScroll } from '../../list/hooks/useListVirtualScroll';
import useEventCallback from '../../hooks/useEventCallback';
import { usePanelContext } from '../context';

interface PanelList {
treeNodes: TreeNode[];
isFilter: boolean;
segment?: boolean;
listKey?: string;
level?: number;
}

const List = (props: PanelList) => {
const { treeNodes, isFilter = false, segment = true, listKey: key, level = 0 } = props;
const ctx = usePanelContext();
const panelWrapperRef = useRef<HTMLDivElement>(null);
const { classPrefix } = useConfig();
const COMPONENT_NAME = `${classPrefix}-cascader`;

const { virtualConfig, cursorStyle, listStyle, isVirtualScroll, onInnerVirtualScroll, scrollToElement } =
useListVirtualScroll(ctx.scroll, panelWrapperRef, treeNodes);
Comment on lines +21 to +28
Copy link

Copilot AI Oct 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

usePanelContext() may return null per its context initializer; dereferencing ctx.scroll without a non-null assertion or guard can cause type/runtime issues under strictNullChecks. Use a non-null assertion (const ctx = usePanelContext()!) or add an early return/assertion to guarantee non-null.

Copilot uses AI. Check for mistakes.

const onScrollIntoView = useEventCallback(() => {
const checkedNodes = ctx.cascaderContext.treeStore.getCheckedNodes();
let lastCheckedNodes = checkedNodes[checkedNodes.length - 1];
let index = -1;
if (lastCheckedNodes?.level === level) {
index = treeNodes.findLastIndex((item) => item.value === lastCheckedNodes.value);
} else {
while (lastCheckedNodes) {
if (lastCheckedNodes?.level === level) {
// eslint-disable-next-line no-loop-func
index = treeNodes.findIndex((item) => item.value === lastCheckedNodes.value);
break;
Comment on lines +30 to +41
Copy link

Copilot AI Oct 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Array.prototype.findLastIndex is not supported in all target environments and can break in older browsers; replace with a reverse loop to compute the last index match for better compatibility.

Copilot uses AI. Check for mistakes.
}
lastCheckedNodes = lastCheckedNodes?.parent;
}
}

if (index !== -1) {
scrollToElement({
index,
});
}
});

const handleExpand = (node: TreeNode, trigger: 'hover' | 'click') => {
expendClickEffect(ctx.trigger, trigger, node, ctx.cascaderContext);
};

const renderItem = (node: TreeNode, index: number) => (
<Item
ref={(el) => {
if (el) {
virtualConfig.handleRowMounted({
ref: el,
data: node,
});
}
}}
key={index}
node={node}
optionChild={node.data.content || parseTNode(ctx.option, { item: node.data, index, context: { node } })}
cascaderContext={ctx.cascaderContext}
onClick={() => {
handleExpand(node, 'click');
}}
onMouseEnter={() => {
handleExpand(node, 'hover');
}}
onChange={() => {
valueChangeEffect(node, ctx.cascaderContext);
}}
/>
);

const handleScroll = (event: React.WheelEvent<HTMLDivElement>): void => {
if (isVirtualScroll) onInnerVirtualScroll(event as unknown as globalThis.WheelEvent);
};
Comment on lines +84 to +86
Copy link

Copilot AI Oct 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onScroll handlers receive UIEvent, not WheelEvent; typing this as React.WheelEvent and casting to globalThis.WheelEvent is incorrect and may break virtualization logic that relies on wheel delta. Use an onWheel handler and pass event.nativeEvent directly to onInnerVirtualScroll.

Copilot uses AI. Check for mistakes.

useEffect(() => {
if (ctx.scroll && ctx.cascaderContext.visible) {
const timer = setTimeout(onScrollIntoView, 16);

return () => {
clearTimeout(timer);
};
}
}, [onScrollIntoView, ctx.scroll, ctx.cascaderContext.visible]);

return (
<div
ref={panelWrapperRef}
onScroll={handleScroll}
Copy link

Copilot AI Oct 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This binds the wheel-based virtualization handler to the scroll event. Bind the corrected wheel handler instead, e.g., onWheel={handleWheel}, to ensure delta-based virtualization works as designed.

Suggested change
onScroll={handleScroll}
onWheel={handleScroll}

Copilot uses AI. Check for mistakes.
className={classNames(`${COMPONENT_NAME}__menu`, 'narrow-scrollbar', {
[`${COMPONENT_NAME}__menu--segment`]: segment,
[`${COMPONENT_NAME}__menu--filter`]: isFilter,
})}
style={{
position: isVirtualScroll ? 'relative' : undefined,
}}
>
{isVirtualScroll ? (
<>
<div style={cursorStyle}></div>
<ul key={key} style={listStyle}>
{virtualConfig.visibleData.map((node, index) => renderItem(node, index))}
</ul>
</>
) : (
<ul key={key}>{treeNodes.map((node: TreeNode, index: number) => renderItem(node, index))}</ul>
)}
</div>
);
};

export default List;
99 changes: 39 additions & 60 deletions packages/components/cascader/components/Panel.tsx
Original file line number Diff line number Diff line change
@@ -1,74 +1,45 @@
import React, { useMemo } from 'react';
import classNames from 'classnames';
import Item from './Item';

import useConfig from '../../hooks/useConfig';
import { useLocaleReceiver } from '../../locale/LocalReceiver';
import { getPanels } from '../core/helper';
import { expendClickEffect, valueChangeEffect } from '../core/effect';

import { TreeNode, CascaderContextType } from '../interface';
import { CascaderContextType } from '../interface';
import { TdCascaderProps } from '../type';
import { StyledProps } from '../../common';
import parseTNode from '../../_util/parseTNode';
import List from './List';
import { PanelContext } from '../context';

export interface CascaderPanelProps
extends StyledProps,
Pick<TdCascaderProps, 'trigger' | 'empty' | 'onChange' | 'loading' | 'loadingText' | 'option'> {
Pick<TdCascaderProps, 'trigger' | 'empty' | 'onChange' | 'loading' | 'loadingText' | 'option' | 'scroll'> {
cascaderContext: CascaderContextType;
}

const Panel = (props: CascaderPanelProps) => {
const { cascaderContext, option } = props;
const { cascaderContext, option, scroll, trigger } = props;

const panels = useMemo(() => getPanels(cascaderContext.treeNodes), [cascaderContext.treeNodes]);

const handleExpand = (node: TreeNode, trigger: 'hover' | 'click') => {
const { trigger: propsTrigger, cascaderContext } = props;
expendClickEffect(propsTrigger, trigger, node, cascaderContext);
};

const { classPrefix } = useConfig();
const [global] = useLocaleReceiver('cascader');
const { classPrefix } = useConfig();
const COMPONENT_NAME = `${classPrefix}-cascader`;

const renderItem = (node: TreeNode, index: number) => (
<Item
key={index}
node={node}
optionChild={node.data.content || parseTNode(option, { item: node.data, index, context: { node } })}
cascaderContext={cascaderContext}
onClick={() => {
handleExpand(node, 'click');
}}
onMouseEnter={() => {
handleExpand(node, 'hover');
}}
onChange={() => {
valueChangeEffect(node, cascaderContext);
}}
/>
);

const renderList = (treeNodes: TreeNode[], isFilter = false, segment = true, key = '1') => (
<ul
className={classNames(`${COMPONENT_NAME}__menu`, 'narrow-scrollbar', {
[`${COMPONENT_NAME}__menu--segment`]: segment,
[`${COMPONENT_NAME}__menu--filter`]: isFilter,
})}
key={key}
>
{treeNodes.map((node: TreeNode, index: number) => renderItem(node, index))}
</ul>
);
const panels = useMemo(() => getPanels(cascaderContext.treeNodes), [cascaderContext.treeNodes]);

const renderPanels = () => {
const { inputVal, treeNodes } = props.cascaderContext;
return inputVal
? renderList(treeNodes, true)
: panels.map((treeNodes, index: number) =>
renderList(treeNodes, false, index !== panels.length - 1, `${COMPONENT_NAME}__menu${index}`),
);
return inputVal ? (
<List treeNodes={treeNodes} isFilter />
) : (
panels.map((panelNodes, index: number) => (
<List
treeNodes={panelNodes}
isFilter={false}
segment={index !== panels.length - 1}
key={`${COMPONENT_NAME}__menu${index}`}
listKey={`${COMPONENT_NAME}__menu${index}`}
level={index}
/>
))
);
};

let content;
Expand All @@ -81,17 +52,25 @@ const Panel = (props: CascaderPanelProps) => {
<div className={`${COMPONENT_NAME}__panel--empty`}>{props.empty ?? global.empty}</div>
);
}

const memoContext = useMemo(
() => ({ option, cascaderContext, scroll, trigger }),
[cascaderContext, option, scroll, trigger],
);

return (
<div
className={classNames(
`${COMPONENT_NAME}__panel`,
{ [`${COMPONENT_NAME}--normal`]: panels.length && !props.loading },
props.className,
)}
style={props.style}
>
{content}
</div>
<PanelContext.Provider value={memoContext}>
<div
className={classNames(
`${COMPONENT_NAME}__panel`,
{ [`${COMPONENT_NAME}--normal`]: panels.length && !props.loading },
props.className,
)}
style={props.style}
>
{content}
</div>
</PanelContext.Provider>
);
};

Expand Down
12 changes: 12 additions & 0 deletions packages/components/cascader/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { createContext, useContext } from 'react';
import { CascaderContextType } from './interface';
import { CascaderPanelProps } from './components/Panel';

export const PanelContext = createContext<{
cascaderContext: CascaderContextType;
trigger: CascaderPanelProps['trigger'];
option: CascaderPanelProps['option'];
scroll: CascaderPanelProps['scroll'];
Comment on lines +3 to +9
Copy link

Copilot AI Oct 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

context.ts imports CascaderPanelProps from Panel.tsx, and Panel.tsx imports PanelContext from context.ts, creating a circular dependency risk at runtime. Avoid value imports for types here and decouple from Panel by switching to type-only import from the prop source (e.g., TdCascaderProps) or asserting non-value type import. Suggested fix: remove the CascaderPanelProps import and use import type { TdCascaderProps } from './type', then reference TdCascaderProps['trigger' | 'option' | 'scroll'] in the context type.

Suggested change
import { CascaderPanelProps } from './components/Panel';
export const PanelContext = createContext<{
cascaderContext: CascaderContextType;
trigger: CascaderPanelProps['trigger'];
option: CascaderPanelProps['option'];
scroll: CascaderPanelProps['scroll'];
import type { TdCascaderProps } from './type';
export const PanelContext = createContext<{
cascaderContext: CascaderContextType;
trigger: TdCascaderProps['trigger'];
option: TdCascaderProps['option'];
scroll: TdCascaderProps['scroll'];

Copilot uses AI. Check for mistakes.
} | null>(null);

export const usePanelContext = () => useContext(PanelContext);
6 changes: 5 additions & 1 deletion packages/components/cascader/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { TagInputProps } from '../tag-input';
import { TagProps } from '../tag';
import { TreeNodeModel } from '../tree';
import { PopupVisibleChangeContext } from '../popup';
import { TNode, TElement, TreeOptionData, SizeEnum, TreeKeysType } from '../common';
import { TNode, TElement, TreeOptionData, SizeEnum, TreeKeysType, TScroll } from '../common';
import { MouseEvent, FocusEvent } from 'react';

export interface TdCascaderProps<CascaderOption extends TreeOptionData = TreeOptionData> {
Expand Down Expand Up @@ -153,6 +153,10 @@ export interface TdCascaderProps<CascaderOption extends TreeOptionData = TreeOpt
* @default false
*/
reserveKeyword?: boolean;
/**
* 懒加载和虚拟滚动。为保证组件收益最大化,当数据量小于阈值 `scroll.threshold` 时,无论虚拟滚动的配置是否存在,组件内部都不会开启虚拟滚动,`scroll.threshold` 默认为 `100`
*/
scroll?: TScroll;
/**
* 透传 SelectInput 筛选器输入框组件的全部属性
*/
Expand Down
Loading
Loading