diff --git a/src/cascader/__tests__/index.test.jsx b/src/cascader/__tests__/index.test.jsx index 6e40c2215..e1eefdb42 100644 --- a/src/cascader/__tests__/index.test.jsx +++ b/src/cascader/__tests__/index.test.jsx @@ -82,7 +82,8 @@ describe('Cascader', () => { }, }).findComponent(CascaderPanel); const firstItem = wrapper.find('.t-cascader__item'); - firstItem.trigger('click'); + + await firstItem.trigger('click'); await wrapper.vm.$nextTick(); expect(fn).toBeCalled(); diff --git a/src/cascader/_example-composition/virtual-scroll.vue b/src/cascader/_example-composition/virtual-scroll.vue new file mode 100644 index 000000000..6c7a1e299 --- /dev/null +++ b/src/cascader/_example-composition/virtual-scroll.vue @@ -0,0 +1,54 @@ + + + diff --git a/src/cascader/_example/virtual-scroll.vue b/src/cascader/_example/virtual-scroll.vue new file mode 100644 index 000000000..7d121a705 --- /dev/null +++ b/src/cascader/_example/virtual-scroll.vue @@ -0,0 +1,57 @@ + + + diff --git a/src/cascader/cascader-panel.tsx b/src/cascader/cascader-panel.tsx index 77d88c494..4cf1dfe2d 100644 --- a/src/cascader/cascader-panel.tsx +++ b/src/cascader/cascader-panel.tsx @@ -29,6 +29,7 @@ export default defineComponent({ empty={this.empty} trigger={this.trigger} cascaderContext={this.cascaderContext} + scroll={this.scroll} scopedSlots={{ empty: this.slots.empty, option: this.slots.option, loadingText: this.slots.loadingText }} onClick={this.onClick} /> diff --git a/src/cascader/cascader.tsx b/src/cascader/cascader.tsx index c033efa91..2692deebe 100644 --- a/src/cascader/cascader.tsx +++ b/src/cascader/cascader.tsx @@ -231,6 +231,7 @@ export default defineComponent({ loading={this.loading} loadingText={this.loadingText} cascaderContext={cascaderContext} + scroll={this.scroll} scopedSlots={{ option: slots.option, empty: slots.empty, loadingText: slots.loadingText }} /> ), diff --git a/src/cascader/components/Item.tsx b/src/cascader/components/Item.tsx index 7d32ea2e2..f0638c19a 100644 --- a/src/cascader/components/Item.tsx +++ b/src/cascader/components/Item.tsx @@ -1,4 +1,6 @@ -import { defineComponent, PropType, computed } from '@vue/composition-api'; +import { + defineComponent, PropType, computed, onMounted, ref, nextTick, +} from '@vue/composition-api'; import { ChevronRightIcon as TdChevronRightIcon } from 'tdesign-icons-vue'; import { getFullPathLabel } from '../core/helper'; import { getCascaderItemClass, getCascaderItemIconClass } from '../core/className'; @@ -33,6 +35,11 @@ const props = { onChange: Function as PropType<() => void>, onClick: Function as PropType<() => void>, onMouseenter: Function as PropType<() => void>, + handleRowMounted: Function as PropType<(rowData: any) => void>, + isVirtual: { + type: Boolean, + default: false, + }, }; export default defineComponent({ @@ -44,12 +51,25 @@ export default defineComponent({ const classPrefix = usePrefixClass(); const { STATUS, SIZE } = useCommonClassName(); const { ChevronRightIcon } = useGlobalIcon({ ChevronRightIcon: TdChevronRightIcon }); + const itemRef = ref(null); const itemClass = computed(() => getCascaderItemClass(classPrefix.value, props.node, SIZE.value, STATUS.value, props.cascaderContext)); const iconClass = computed(() => getCascaderItemIconClass(classPrefix.value, props.node, STATUS.value, props.cascaderContext)); + onMounted(() => { + if (props.isVirtual) { + nextTick(() => { + props?.handleRowMounted({ + ref: itemRef, + data: props.node, + }); + }); + } + }); + return { + itemRef, COMPONENT_NAME, ChevronRightIcon, iconClass, @@ -123,6 +143,7 @@ export default defineComponent({ return (
  • { e.stopPropagation(); diff --git a/src/cascader/components/List.tsx b/src/cascader/components/List.tsx new file mode 100644 index 000000000..efeeab830 --- /dev/null +++ b/src/cascader/components/List.tsx @@ -0,0 +1,210 @@ +import { + computed, defineComponent, nextTick, PropType, ref, watch, +} from '@vue/composition-api'; +import { useTNodeDefault } from '../../hooks/tnode'; +import { getDefaultNode } from '../../hooks/render-tnode'; +import useListVirtualScroll from '../../list/hooks/useListVirtualScroll'; +import { usePrefixClass } from '../../hooks'; +import Item from './Item'; +import CascaderProps from '../props'; +import { CascaderContextType, TreeNode } from '../interface'; +import { expendClickEffect, valueChangeEffect } from '../core/effect'; + +const props = { + treeNodes: { + type: Array as PropType, + default: [] as PropType, + }, + isFilter: { + type: Boolean, + default: false, + }, + segment: { + type: Boolean, + default: true, + }, + listKey: { + type: String, + }, + level: { + type: Number, + default: 0, + }, + option: CascaderProps.option, + trigger: CascaderProps.trigger, + scroll: CascaderProps.scroll, + cascaderContext: { + type: Object as PropType, + }, +}; + +export default defineComponent({ + name: 'TCascaderList', + props, + setup(props, { emit }) { + const renderTNodeJSXDefault = useTNodeDefault(); + const COMPONENT_NAME = usePrefixClass('cascader'); + const panelWrapperRef = ref(null); + + const treeNodes = computed(() => props.treeNodes); + const isVisible = computed(() => props.cascaderContext.visible); + const scroll = computed(() => ({ + rowHeight: 28, + ...props.scroll, + })); + + const { + virtualConfig, + cursorStyle, + listStyle, + isVirtualScroll, + onInnerVirtualScroll, + scrollToElement, + handleRowMounted, + } = useListVirtualScroll(scroll.value, panelWrapperRef, treeNodes as any); + + const handleExpand = (node: TreeNode, trigger: 'hover' | 'click') => { + const { trigger: propsTrigger, cascaderContext } = props; + expendClickEffect(propsTrigger, trigger, node, cascaderContext); + }; + + const onScrollIntoView = () => { + const { level, treeNodes, cascaderContext } = props; + const checkedNodes = 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 handleScroll = (event: WheelEvent): void => { + if (isVirtualScroll.value) onInnerVirtualScroll(event as unknown as WheelEvent); + }; + + watch( + isVisible, + () => { + if (props.scroll && props.cascaderContext.visible) { + setTimeout(() => { + nextTick(() => { + onScrollIntoView(); + }); + }, 16); + } + }, + { + immediate: true, + }, + ); + + return { + panelWrapperRef, + COMPONENT_NAME, + virtualConfig, + cursorStyle, + listStyle, + isVirtualScroll, + renderTNodeJSXDefault, + emit, + handleExpand, + handleScroll, + handleRowMounted, + }; + }, + render() { + const { + COMPONENT_NAME, + treeNodes, + isFilter, + segment, + listKey: key, + cascaderContext, + virtualConfig, + cursorStyle, + listStyle, + isVirtualScroll, + renderTNodeJSXDefault, + emit, + handleExpand, + handleScroll, + handleRowMounted, + } = this; + + const renderItem = (node: TreeNode, index: number) => { + const optionChild = node.data.content + ? getDefaultNode(node.data.content(this.$createElement)) + : renderTNodeJSXDefault('option', { + params: { item: node.data, index }, + }); + return ( + { + emit('click', node.value, node); + handleExpand(node, 'click'); + }, + onMouseenter: () => { + handleExpand(node, 'hover'); + }, + onChange: () => { + valueChangeEffect(node, cascaderContext); + }, + }, + }} + handleRowMounted={handleRowMounted} + /> + ); + }; + + return ( +
    + {isVirtualScroll ?
    : null} + {isVirtualScroll ? ( +
      + {virtualConfig.visibleData.value.map((node, index) => renderItem(node, index))} +
    + ) : ( +
      {treeNodes.map((node: TreeNode, index: number) => renderItem(node, index))}
    + )} +
    + ); + }, +}); diff --git a/src/cascader/components/Panel.tsx b/src/cascader/components/Panel.tsx index b7986f915..4728035c8 100644 --- a/src/cascader/components/Panel.tsx +++ b/src/cascader/components/Panel.tsx @@ -1,13 +1,12 @@ import { PropType } from 'vue'; import { defineComponent, computed } from '@vue/composition-api'; -import Item from './Item'; import { TreeNode, CascaderContextType, CascaderValue } from '../interface'; import CascaderProps from '../props'; import { usePrefixClass, useConfig } from '../../hooks/useConfig'; import { useTNodeDefault } from '../../hooks/tnode'; -import { getDefaultNode } from '../../hooks/render-tnode'; import { getPanels } from '../core/helper'; -import { expendClickEffect, valueChangeEffect } from '../core/effect'; +import { expendClickEffect } from '../core/effect'; +import List from './List'; export default defineComponent({ name: 'TCascaderSubPanel', @@ -21,6 +20,7 @@ export default defineComponent({ cascaderContext: { type: Object as PropType, }, + scroll: CascaderProps.scroll, }, setup(props, { emit }) { const renderTNodeJSXDefault = useTNodeDefault(); @@ -39,14 +39,13 @@ export default defineComponent({ // 异步加载回显时默认触发第一个值 if (treeStore?.config?.load && valueType === 'full' && (cascaderValue as Array).length > 0) { - const firstValue = multiple ? cascaderValue[0][0] : cascaderValue[0]; + const firstValue = multiple ? (cascaderValue as any)[0][0] : (cascaderValue as any)[0]; const firstExpandNode = treeStore.nodes.find((node: TreeNode) => node.value === firstValue); handleExpand(firstExpandNode, 'click'); } return { global, panels, - handleExpand, renderTNodeJSXDefault, COMPONENT_NAME, emit, @@ -54,57 +53,9 @@ export default defineComponent({ }, render() { const { - global, COMPONENT_NAME, handleExpand, renderTNodeJSXDefault, cascaderContext, panels, emit, + global, COMPONENT_NAME, renderTNodeJSXDefault, option, cascaderContext, panels, scroll, trigger, } = this; - const renderItem = (node: TreeNode, index: number) => { - const optionChild = node.data.content - ? getDefaultNode(node.data.content(this.$createElement)) - : renderTNodeJSXDefault('option', { - params: { item: node.data, index }, - }); - return ( - { - emit('click', node.value, node); - handleExpand(node, 'click'); - }, - onMouseenter: () => { - handleExpand(node, 'hover'); - }, - onChange: () => { - valueChangeEffect(node, cascaderContext); - }, - }, - }} - /> - ); - }; - - const renderList = (treeNodes: TreeNode[], isFilter = false, segment = true, index = 1) => ( -
      - {treeNodes.map((node: TreeNode) => renderItem(node, index))} -
    - ); - const renderEmpty = () => { if (this.empty && typeof this.empty === 'string') { return
    {this.empty}
    ; @@ -114,9 +65,31 @@ export default defineComponent({ const renderPanels = () => { const { inputVal, treeNodes } = cascaderContext; - return inputVal - ? renderList(treeNodes, true) - : panels.map((treeNodes, index: number) => renderList(treeNodes, false, index !== panels.length - 1, index)); + return inputVal ? ( + + ) : ( + panels.map((treeNodes, index: number) => ( + + )) + ); }; let content; diff --git a/src/cascader/props.ts b/src/cascader/props.ts index cf697a9e5..7dfe72d77 100644 --- a/src/cascader/props.ts +++ b/src/cascader/props.ts @@ -99,6 +99,12 @@ export default { readonly: Boolean, /** 多选且可搜索时,是否在选中一个选项后保留当前的搜索关键词 */ reserveKeyword: Boolean, + /** + * 懒加载和虚拟滚动。为保证组件收益最大化,当数据量小于阈值 `scroll.threshold` 时,无论虚拟滚动的配置是否存在,组件内部都不会开启虚拟滚动,`scroll.threshold` 默认为 `100` + */ + scroll: { + type: Object as PropType, + }, /** 透传 SelectInput 筛选器输入框组件的全部属性 */ selectInputProps: { type: Object as PropType, diff --git a/src/cascader/type.ts b/src/cascader/type.ts index 4d7698090..f4f895cb6 100644 --- a/src/cascader/type.ts +++ b/src/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, TreeOptionData, SizeEnum, TreeKeysType } from '../common'; +import { TNode, TreeOptionData, SizeEnum, TreeKeysType, TScroll } from '../common'; export interface TdCascaderProps { /** @@ -140,6 +140,10 @@ export interface TdCascaderProps, listItems: Ref) => { const virtualScrollParams = computed(() => ({ @@ -45,12 +46,28 @@ const useListVirtualScroll = (scroll: TdListProps['scroll'], listRef: Ref { + const { index, key } = params; + const targetIndex = index === 0 ? index : index ?? Number(key); + if (!targetIndex && targetIndex !== 0) { + log.error('List', 'scrollTo: `index` or `key` must exist.'); + return; + } + if (targetIndex < 0 || targetIndex >= listItems.value.length) { + log.error('List', `${targetIndex} does not exist in data, check \`index\` or \`key\` please.`); + return; + } + virtualConfig.scrollToElement({ ...params, index: targetIndex - 1 }); + }; + return { virtualConfig, cursorStyle, listStyle, isVirtualScroll, onInnerVirtualScroll, + scrollToElement: handleScrollTo, + handleRowMounted: virtualConfig.handleRowMounted, }; };