diff --git a/packages/components/cascader/Cascader.tsx b/packages/components/cascader/Cascader.tsx index a12d17c111..5b3e5436b2 100644 --- a/packages/components/cascader/Cascader.tsx +++ b/packages/components/cascader/Cascader.tsx @@ -102,6 +102,8 @@ const Cascader: React.FC = (originalProps) => { const { setVisible, visible, inputVal, setInputVal } = cascaderContext; const updateScrollTop = (content: HTMLDivElement) => { + // virtual scroll not trigger event + if (props.scroll) return; const cascaderMenuList = content.querySelectorAll(`.${COMPONENT_NAME}__menu`); requestAnimationFrame(() => { cascaderMenuList.forEach((menu: HTMLDivElement) => { @@ -235,7 +237,7 @@ const Cascader: React.FC = (originalProps) => { {props.panelTopContent && parseTNode(props.panelTopContent)} {props.panelBottomContent && parseTNode(props.panelBottomContent)} diff --git a/packages/components/cascader/CascaderPanel.tsx b/packages/components/cascader/CascaderPanel.tsx index ab658e24ed..1c38adb6da 100644 --- a/packages/components/cascader/CascaderPanel.tsx +++ b/packages/components/cascader/CascaderPanel.tsx @@ -18,7 +18,7 @@ const CascaderPanel: React.FC = (originalProps) => { className={classNames(props.className)} style={props.style} cascaderContext={cascaderContext} - {...pick(props, ['trigger', 'onChange', 'empty', 'option'])} + {...pick(props, ['trigger', 'onChange', 'empty', 'option', 'scroll'])} > ); }; diff --git a/packages/components/cascader/_example/virtual-scroll.tsx b/packages/components/cascader/_example/virtual-scroll.tsx new file mode 100644 index 0000000000..f68713f143 --- /dev/null +++ b/packages/components/cascader/_example/virtual-scroll.tsx @@ -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++) { + 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(['20.1.20']); + const options = list; + + const onChange: CascaderProps['onChange'] = (value) => { + setValue(value); + }; + + return ( +
+ +
+ ); +} diff --git a/packages/components/cascader/cascader.en-US.md b/packages/components/cascader/cascader.en-US.md index f741fd30b1..c1d4063f6c 100644 --- a/packages/components/cascader/cascader.en-US.md +++ b/packages/components/cascader/cascader.en-US.md @@ -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 diff --git a/packages/components/cascader/cascader.md b/packages/components/cascader/cascader.md index b169a36fbc..2c91d5a190 100644 --- a/packages/components/cascader/cascader.md +++ b/packages/components/cascader/cascader.md @@ -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 diff --git a/packages/components/cascader/components/List.tsx b/packages/components/cascader/components/List.tsx new file mode 100644 index 0000000000..296ed1fa0d --- /dev/null +++ b/packages/components/cascader/components/List.tsx @@ -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(null); + const { classPrefix } = useConfig(); + const COMPONENT_NAME = `${classPrefix}-cascader`; + + const { virtualConfig, cursorStyle, listStyle, isVirtualScroll, onInnerVirtualScroll, scrollToElement } = + useListVirtualScroll(ctx.scroll, panelWrapperRef, treeNodes); + + 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; + } + 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) => ( + { + 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): void => { + if (isVirtualScroll) onInnerVirtualScroll(event as unknown as globalThis.WheelEvent); + }; + + useEffect(() => { + if (ctx.scroll && ctx.cascaderContext.visible) { + const timer = setTimeout(onScrollIntoView, 16); + + return () => { + clearTimeout(timer); + }; + } + }, [onScrollIntoView, ctx.scroll, ctx.cascaderContext.visible]); + + return ( +
+ {isVirtualScroll ? ( + <> +
+
    + {virtualConfig.visibleData.map((node, index) => renderItem(node, index))} +
+ + ) : ( +
    {treeNodes.map((node: TreeNode, index: number) => renderItem(node, index))}
+ )} +
+ ); +}; + +export default List; diff --git a/packages/components/cascader/components/Panel.tsx b/packages/components/cascader/components/Panel.tsx index a8960b802e..4ad2512c56 100644 --- a/packages/components/cascader/components/Panel.tsx +++ b/packages/components/cascader/components/Panel.tsx @@ -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 { + Pick { 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) => ( - { - handleExpand(node, 'click'); - }} - onMouseEnter={() => { - handleExpand(node, 'hover'); - }} - onChange={() => { - valueChangeEffect(node, cascaderContext); - }} - /> - ); - - const renderList = (treeNodes: TreeNode[], isFilter = false, segment = true, key = '1') => ( -
    - {treeNodes.map((node: TreeNode, index: number) => renderItem(node, index))} -
- ); + 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 ? ( + + ) : ( + panels.map((panelNodes, index: number) => ( + + )) + ); }; let content; @@ -81,17 +52,25 @@ const Panel = (props: CascaderPanelProps) => {
{props.empty ?? global.empty}
); } + + const memoContext = useMemo( + () => ({ option, cascaderContext, scroll, trigger }), + [cascaderContext, option, scroll, trigger], + ); + return ( -
- {content} -
+ +
+ {content} +
+
); }; diff --git a/packages/components/cascader/context.ts b/packages/components/cascader/context.ts new file mode 100644 index 0000000000..f5ce018a70 --- /dev/null +++ b/packages/components/cascader/context.ts @@ -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']; +} | null>(null); + +export const usePanelContext = () => useContext(PanelContext); diff --git a/packages/components/cascader/type.ts b/packages/components/cascader/type.ts index 649c00e8d4..f8a216be12 100644 --- a/packages/components/cascader/type.ts +++ b/packages/components/cascader/type.ts @@ -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 { @@ -153,6 +153,10 @@ export interface TdCascaderProps csr test packages/components/cascader/_example/pane
-
    -
  • - - 选项一 - - +
  • - - - - -
  • -
  • - - 选项二 - - + + + + + +
  • +
  • - - - - -
  • -
+ 选项二 + + + + + + + + +
@@ -22483,100 +22485,102 @@ exports[`csr snapshot test > csr test packages/components/cascader/_example/pane
-
    -
  • - - +
  • - - - - -
  • -
  • - + - 选项二 - - - + + + +
  • +
  • - - - - -
  • -
+ + + 选项二 + + + + + + + + + +
@@ -23520,6 +23524,91 @@ exports[`csr snapshot test > csr test packages/components/cascader/_example/valu `; +exports[`csr snapshot test > csr test packages/components/cascader/_example/virtual-scroll.tsx 1`] = ` +
+
+
+
+
+
+
+ + 选项20/子选项20.1/子选项20.1.20 + + + + + + +
+
+ + + + + + + +
+
+
+
+
+`; + exports[`csr snapshot test > csr test packages/components/checkbox/_example/base.tsx 1`] = `
ssr test packages/components/cascader/_example/valu exports[`ssr snapshot test > ssr test packages/components/cascader/_example/value-type.tsx 1`] = `"
["1","1.1"]
[["1","1.1"],["1","1.2"]]
请选择
"`; +exports[`ssr snapshot test > ssr test packages/components/cascader/_example/virtual-scroll.tsx 1`] = `"
请选择
"`; + exports[`ssr snapshot test > ssr test packages/components/checkbox/_example/base.tsx 1`] = `"
"`; exports[`ssr snapshot test > ssr test packages/components/checkbox/_example/controlled.tsx 1`] = `"
"`; diff --git a/test/snap/__snapshots__/ssr.test.jsx.snap b/test/snap/__snapshots__/ssr.test.jsx.snap index be8eaee006..117a30a733 100644 --- a/test/snap/__snapshots__/ssr.test.jsx.snap +++ b/test/snap/__snapshots__/ssr.test.jsx.snap @@ -216,6 +216,8 @@ exports[`ssr snapshot test > ssr test packages/components/cascader/_example/valu exports[`ssr snapshot test > ssr test packages/components/cascader/_example/value-type.tsx 1`] = `"
["1","1.1"]
[["1","1.1"],["1","1.2"]]
请选择
"`; +exports[`ssr snapshot test > ssr test packages/components/cascader/_example/virtual-scroll.tsx 1`] = `"
请选择
"`; + exports[`ssr snapshot test > ssr test packages/components/checkbox/_example/base.tsx 1`] = `"
"`; exports[`ssr snapshot test > ssr test packages/components/checkbox/_example/controlled.tsx 1`] = `"
"`;