From 2d2878eb531dac4384e47006bcf7d1d67473e6e3 Mon Sep 17 00:00:00 2001 From: msidolphin Date: Mon, 27 Sep 2021 11:12:50 +0800 Subject: [PATCH] feat(components): add virtual tree (#3398) * feat(components): add virtual tree * perf: optimize compute performance * perf: optimize update checked states performance * feat(components): [tree-v2] supports filter method * feat(components): [el-tree-v2] exposes check api * feat(components): [el-tree-v2] exposes current api * feat(components): [el-tree-v2] support contextmenu event * style(components): [el-tree-v2] optimized code style * refactor(components): [el-tree-v2] using svg icon * refactor(components): [el-tree-v2] replace all PropType with buildProp replace all PropType with buildProp, support perfMode, expose setData * refactor: improve prop * docs: [el-tree-v2] improve documention * refactor(components): [el-tree-v2] optimized code Co-authored-by: Kevin --- .markdownlint.json | 3 +- .../crowdin/en-US/pages/component.json | 4 + docs/en-US/component/tree-v2.md | 126 ++ docs/examples/tree-v2/basic.vue | 44 + docs/examples/tree-v2/custom-node.vue | 61 + docs/examples/tree-v2/default-state.vue | 64 + docs/examples/tree-v2/disabled.vue | 51 + docs/examples/tree-v2/filter.vue | 65 + docs/examples/tree-v2/selectable.vue | 49 + packages/components/index.ts | 1 + .../components/tree-v2/__tests__/tree.spec.ts | 1159 +++++++++++++++++ packages/components/tree-v2/index.ts | 5 + .../tree-v2/src/composables/useCheck.ts | 221 ++++ .../tree-v2/src/composables/useFilter.ts | 79 ++ .../tree-v2/src/composables/useTree.ts | 295 +++++ .../tree-v2/src/tree-node-content.ts | 17 + packages/components/tree-v2/src/tree-node.vue | 113 ++ packages/components/tree-v2/src/tree.vue | 127 ++ packages/components/tree-v2/src/types.ts | 54 + .../components/tree-v2/src/virtual-tree.ts | 182 +++ packages/components/tree-v2/style/css.ts | 3 + packages/components/tree-v2/style/index.ts | 3 + packages/element-plus/component.ts | 2 + packages/element-plus/global.d.ts | 1 + packages/theme-chalk/src/tree.scss | 5 +- 25 files changed, 2732 insertions(+), 2 deletions(-) create mode 100644 docs/en-US/component/tree-v2.md create mode 100644 docs/examples/tree-v2/basic.vue create mode 100644 docs/examples/tree-v2/custom-node.vue create mode 100644 docs/examples/tree-v2/default-state.vue create mode 100644 docs/examples/tree-v2/disabled.vue create mode 100644 docs/examples/tree-v2/filter.vue create mode 100644 docs/examples/tree-v2/selectable.vue create mode 100644 packages/components/tree-v2/__tests__/tree.spec.ts create mode 100644 packages/components/tree-v2/index.ts create mode 100644 packages/components/tree-v2/src/composables/useCheck.ts create mode 100644 packages/components/tree-v2/src/composables/useFilter.ts create mode 100644 packages/components/tree-v2/src/composables/useTree.ts create mode 100644 packages/components/tree-v2/src/tree-node-content.ts create mode 100644 packages/components/tree-v2/src/tree-node.vue create mode 100644 packages/components/tree-v2/src/tree.vue create mode 100644 packages/components/tree-v2/src/types.ts create mode 100644 packages/components/tree-v2/src/virtual-tree.ts create mode 100644 packages/components/tree-v2/style/css.ts create mode 100644 packages/components/tree-v2/style/index.ts diff --git a/.markdownlint.json b/.markdownlint.json index 79aa8d7f67..ae399b8aae 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1,3 +1,4 @@ { - "MD033": false + "MD033": false, + "MD013": false } diff --git a/docs/.vitepress/crowdin/en-US/pages/component.json b/docs/.vitepress/crowdin/en-US/pages/component.json index 2b42297f8b..062c0c021d 100644 --- a/docs/.vitepress/crowdin/en-US/pages/component.json +++ b/docs/.vitepress/crowdin/en-US/pages/component.json @@ -205,6 +205,10 @@ { "link": "/tree", "text": "Tree" + }, + { + "link": "/tree-v2", + "text": "Virtualized Tree" } ] }, diff --git a/docs/en-US/component/tree-v2.md b/docs/en-US/component/tree-v2.md new file mode 100644 index 0000000000..4315b56cab --- /dev/null +++ b/docs/en-US/component/tree-v2.md @@ -0,0 +1,126 @@ +# Tree V2 virtualized tree + +Tree view with blazing fast scrolling performance for any amount of data + +## Basic usage + +Basic tree structure. + +:::demo + +tree-v2/basic + +::: + +## Selectable + +Used for node selection. + +:::demo + +tree-v2/selectable + +::: + +## Disabled checkbox + +The checkbox of a node can be set as disabled. + +:::demo In the example, `disabled` property is declared in defaultProps, and some nodes are set as `disabled: true`. The corresponding checkboxes are disabled and can't be clicked. + +tree-v2/disabled + +::: + +## Default expanded and default checked + +Tree nodes can be initially expanded or checked + +:::demo Use `default-expanded-keys` and `default-checked-keys` to set initially expanded and initially checked nodes respectively. + +tree-v2/default-state + +::: + +## Custom node content + +The content of tree nodes can be customized, so you can add icons or buttons as you will + +:::demo + +tree-v2/custom-node + +::: + +## Tree node filtering + +Tree nodes can be filtered + +:::demo Invoke the `filter` method of the Tree instance to filter tree nodes. Its parameter is the filtering keyword. Note that for it to work, `filter-node-method` is required, and its value is the filtering method. + +tree-v2/filter + +::: + +## Attributes + +| Attribute | Description | Type | Default | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ------- | +| data | tree data | array | — | +| empty-text | text displayed when data is void | string | — | +| props | configuration options, see the following table | object | — | +| highlight-current | whether current node is highlighted | boolean | false | +| expand-on-click-node | whether to expand or collapse node when clicking on the node, if false, then expand or collapse node only when clicking on the arrow icon. | boolean | true | +| check-on-click-node | whether to check or uncheck node when clicking on the node, if false, the node can only be checked or unchecked by clicking on the checkbox. | boolean | false | +| default-expanded-keys | array of keys of initially expanded nodes | array | — | +| show-checkbox | whether node is selectable | boolean | false | +| check-strictly | whether checked state of a node not affects its father and child nodes when `show-checkbox` is `true` | boolean | false | +| default-checked-keys | array of keys of initially checked nodes | array | — | +| current-node-key | key of initially selected node | string, number | — | +| filter-method | this function will be executed on each node when use filter method. if return `false`, tree node will be hidden. | Function(value, data) | — | +| indent | horizontal indentation of nodes in adjacent levels in pixels | number | 16 | +| icon | custome tree node icon | string | - | + +## props + +| Attribute | Description | Type | Default | +| --------- | ------------------------------------------------------------------------------------ | -------------- | -------- | +| id | unique identity key name for nodes, its value should be unique across the whole tree | string, number | id | +| label | specify which key of node object is used as the node's label | string | label | +| children | specify which node object is used as the node's subtree | string | children | +| disabled | specify which key of node object represents if node's checkbox is disabled | boolean | disabled | + +## Method + +`Tree` has the following method, which returns the currently selected array of nodes. +| Method | Description | Parameters | +| --------------- | ---------------------------------------- | ---------------------------------------- | +| filter | filter all tree nodes, filtered nodes will be hidden | (query: string) | +| getCheckedNodes | If the node can be selected (`show-checkbox` is `true`), it returns the currently selected array of nodes | (leafOnly: boolean) | +| getCheckedKeys | If the node can be selected (`show-checkbox` is `true`), it returns the currently selected array of node's keys | (leafOnly: boolean) | +| setCheckedKeys | set certain nodes to be checked | (keys: TreeKey[]) | +| setChecked | set node to be checked or not | (key: TreeKey, checked: boolean) | +| getHalfCheckedNodes | If the node can be selected (`show-checkbox` is `true`), it returns the currently half selected array of nodes | - | +| getHalfCheckedKeys | If the node can be selected (`show-checkbox` is `true`), it returns the currently half selected array of node's keys | - | +| getCurrentKey | return the highlight node's key (undefined if no node is highlighted) | — | +| getCurrentNode | return the highlight node's data (undefined if no node is highlighted) | — | +| setCurrentKey | set highlighted node by key | (key: TreeKey) | +| setData | When the data is very large, using reactive data will cause the poor performance, so we provide a way to avoid this situation | (data: TreeData) | + +## Events + +| Event Name | Description | Parameters | +| ---------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| node-click | triggers when a node is clicked | (data: TreeNodeData, node: TreeNode) | +| node-contextmenu | triggers when a node is clicked by right button | (e: Event, data: TreeNodeData, node: TreeNode) | +| check-change | triggers when the selected state of the node changes | (data: TreeNodeData, checked: boolean) | +| check | triggers after clicking the checkbox of a node | (data: TreeNodeData, info: { checkedKeys: TreeKey[],checkedNodes: TreeData, halfCheckedKeys: TreeKey[], halfCheckedNodes: TreeData,}) | +| current-change | triggers when current node changes | (data: TreeNodeData, node: TreeNode) | +| node-expand | triggers when current node open | (data: TreeNodeData, node: TreeNode) | +| node-collapse | triggers when current node close | (data: TreeNodeData, node: TreeNode) | + +## Slots + +| Name | Description | +| ---- | -------------------------------------------------------------------------------------------- | +| — | Custom content for tree nodes. The scope parameter is { node: TreeNode, data: TreeNodeData } | diff --git a/docs/examples/tree-v2/basic.vue b/docs/examples/tree-v2/basic.vue new file mode 100644 index 0000000000..8dee6861b6 --- /dev/null +++ b/docs/examples/tree-v2/basic.vue @@ -0,0 +1,44 @@ + + diff --git a/docs/examples/tree-v2/custom-node.vue b/docs/examples/tree-v2/custom-node.vue new file mode 100644 index 0000000000..c616e79362 --- /dev/null +++ b/docs/examples/tree-v2/custom-node.vue @@ -0,0 +1,61 @@ + + + + diff --git a/docs/examples/tree-v2/default-state.vue b/docs/examples/tree-v2/default-state.vue new file mode 100644 index 0000000000..00d1f8a212 --- /dev/null +++ b/docs/examples/tree-v2/default-state.vue @@ -0,0 +1,64 @@ + + diff --git a/docs/examples/tree-v2/disabled.vue b/docs/examples/tree-v2/disabled.vue new file mode 100644 index 0000000000..723f4c54fd --- /dev/null +++ b/docs/examples/tree-v2/disabled.vue @@ -0,0 +1,51 @@ + + diff --git a/docs/examples/tree-v2/filter.vue b/docs/examples/tree-v2/filter.vue new file mode 100644 index 0000000000..d226f2cdbc --- /dev/null +++ b/docs/examples/tree-v2/filter.vue @@ -0,0 +1,65 @@ + + diff --git a/docs/examples/tree-v2/selectable.vue b/docs/examples/tree-v2/selectable.vue new file mode 100644 index 0000000000..d436db02d7 --- /dev/null +++ b/docs/examples/tree-v2/selectable.vue @@ -0,0 +1,49 @@ + + diff --git a/packages/components/index.ts b/packages/components/index.ts index d59b1aa8db..5f204b82fb 100644 --- a/packages/components/index.ts +++ b/packages/components/index.ts @@ -61,6 +61,7 @@ export * from './timeline' export * from './tooltip' export * from './transfer' export * from './tree' +export * from './tree-v2' export * from './upload' export * from './virtual-list' // plugins diff --git a/packages/components/tree-v2/__tests__/tree.spec.ts b/packages/components/tree-v2/__tests__/tree.spec.ts new file mode 100644 index 0000000000..f271fe97a7 --- /dev/null +++ b/packages/components/tree-v2/__tests__/tree.spec.ts @@ -0,0 +1,1159 @@ +import { nextTick } from 'vue' +import { NOOP } from '@vue/shared' +import { makeMountFunc } from '@element-plus/test-utils/make-mount' +import Tree from '../src/tree.vue' +import type { + TreeData, + TreeNodeData, + TreeNode, + TreeOptionProps, + TreeKey, + FilterMethod, +} from '../src/types' + +jest.useFakeTimers() + +let id = 1 + +const NODE_NUMBER = 5 +const TREE_NODE_CLASS_NAME = '.el-tree-node' +const TREE_NODE_CONTENT_CLASS_NAME = '.el-tree-node__content' +const TREE_NODE_EXPAND_ICON_CLASS_NAME = '.el-tree-node__expand-icon' + +const getUniqueId = () => { + return id++ +} + +const createData = ( + maxDeep, + maxChildren, + minNodesNumber, + deep = 1, + disabled = false +) => { + return new Array(minNodesNumber).fill(deep).map(() => { + const id = getUniqueId() + const childrenNumber = + deep === maxDeep ? 0 : Math.round(Math.random() * maxChildren) + return { + id, + disabled: disabled ? Math.random() > 0.7 : false, + label: `node-${id}`, + children: childrenNumber + ? createData(maxDeep, maxChildren, childrenNumber, deep + 1, disabled) + : [], + } + }) +} + +const data = createData(4, 30, NODE_NUMBER) + +const _mount = makeMountFunc({ + components: { + 'el-tree': Tree, + }, +}) + +interface TreeProps { + data?: TreeData + emptyText?: string + height?: number + props?: TreeOptionProps + highlightCurrent?: boolean + showCheckbox?: boolean + defaultCheckedKeys?: TreeKey[] + checkStrictly?: boolean + defaultExpandedKeys?: TreeKey[] + indent?: number + iconClass?: string + expandOnClickNode?: boolean + checkOnClickNode?: boolean + currentNodeKey?: TreeKey + filterMethod?: FilterMethod +} + +interface TreeEvents { + onNodeClick?: (nodeData?: TreeNodeData, node?: TreeNode) => void + onNodeExpand?: (nodeData?: TreeNodeData, node?: TreeNode) => void + onNodeCheck?: ( + nodeData?: TreeNodeData, + checked?: { + checkedKeys: TreeKey[] + checkedNodes: TreeNodeData[] + halfCheckedKeys: TreeKey[] + halfCheckedNodes: TreeNodeData[] + } + ) => void + onCurrentChange?: (nodeData?: TreeNodeData, node?: TreeNode) => void + onNodeContextMenu?: ( + e?: Event, + nodeData?: TreeNodeData, + node?: TreeNode + ) => void +} + +const createTree = ( + options: { + data?: () => TreeProps + methods?: TreeEvents + slots?: { + default?: string + } + } = {} +) => { + const defaultSlot = + (options.slots && + options.slots.default && + ``) || + '' + const wrapper = _mount( + ` + ${defaultSlot} + `, + { + data() { + return { + data, + emptyText: undefined, + height: undefined, + props: { + children: 'children', + label: 'label', + disabled: 'disabled', + value: 'id', + }, + highlightCurrent: false, + showCheckbox: false, + defaultCheckedKeys: undefined, + checkStrictly: false, + defaultExpandedKeys: undefined, + indent: 16, + iconClass: undefined, + expandOnClickNode: true, + checkOnClickNode: false, + currentNodeKey: undefined, + filterMethod: undefined, + ...(options.data && options.data()), + } + }, + methods: { + onNodeClick: NOOP, + onNodeExpand: NOOP, + onNodeCheck: NOOP, + onCurrentChange: NOOP, + onNodeContextMenu: NOOP, + ...options.methods, + }, + } + ) + const treeWrapper = wrapper.findComponent(Tree) + const vm = wrapper.vm as any + return { + wrapper, + treeRef: vm.$refs.tree, + vm, + treeWrapper, + treeVm: treeWrapper.vm as any, + } +} + +describe('Virtual Tree', () => { + test('create', async () => { + const { treeVm } = createTree() + await nextTick() + expect(treeVm.flattenTree.length).toEqual(NODE_NUMBER) + }) + + test('click node', async () => { + const onNodeClick = jest.fn() + const { wrapper, treeVm } = createTree({ + methods: { + onNodeClick, + }, + }) + await nextTick() + const nodes = wrapper.findAll(TREE_NODE_CLASS_NAME) + await nodes[0].trigger('click') + expect(onNodeClick).toBeCalled() + expect(treeVm.flattenTree.length).toBeGreaterThanOrEqual(NODE_NUMBER) + }) + + test('emptyText', async () => { + const emptyText = '暂无数据' + const { wrapper } = createTree({ + data() { + return { + emptyText, + data: [], + } + }, + }) + await nextTick() + expect(wrapper.find('.el-tree__empty-text').text()).toBe(emptyText) + }) + + test('height', async () => { + const { wrapper } = createTree({ + data() { + return { + height: 300, + } + }, + }) + await nextTick() + const el = wrapper.find('.el-tree-virtual-list').element as any + expect(el.style.height).toBe('300px') + }) + + test('props', async () => { + const { wrapper } = createTree({ + data() { + return { + data: [ + { + key: '1', + text: 'node-1', + readonly: false, + sub: [ + { + key: '1-1', + text: 'node-1-1', + readonly: false, + }, + ], + }, + { + key: '2', + text: 'node-2', + readonly: false, + sub: [ + { + key: '2-1', + text: 'node-2-1', + }, + { + key: '2-2', + text: 'node-2-2', + readonly: true, + }, + ], + }, + ], + props: { + value: 'key', + label: 'text', + disabled: 'readonly', + children: 'sub', + }, + } + }, + }) + await nextTick() + let nodes = wrapper.findAll(TREE_NODE_CLASS_NAME) + // test props.label + expect(nodes[0].text()).toBe('node-1') + expect(nodes[1].text()).toBe('node-2') + // expand node-2 + await nodes[1].trigger('click') + nodes = wrapper.findAll(TREE_NODE_CLASS_NAME) + // test props.children + expect(nodes[2].text()).toBe('node-2-1') + expect(nodes[3].text()).toBe('node-2-2') + // test props.disabled + expect(nodes[3].classes()).not.toContain('is-focusable') + }) + + test('highlightCurrent', async () => { + const { wrapper } = createTree({ + data() { + return { + highlightCurrent: true, + } + }, + }) + await nextTick() + expect(wrapper.classes()).toContain('el-tree--highlight-current') + }) + + test('showCheckbox', async () => { + const { wrapper } = createTree({ + data() { + return { + height: 400, + data: [ + { + id: '1', + label: 'node-1', + children: [ + { + id: '1-1', + label: 'node-1-1', + children: [ + { + id: '1-1-1', + label: 'node-1-1-1', + }, + { + id: '1-1-2', + label: 'node-1-1-2', + }, + ], + }, + { + id: '1-2', + label: 'node-1-2', + children: [ + { + id: '1-2-1', + label: 'node-1-2-1', + }, + ], + }, + { + id: '1-3', + label: 'node-1-3', + }, + ], + }, + { + id: '2', + label: 'node-2', + }, + ], + showCheckbox: true, + } + }, + }) + await nextTick() + expect(wrapper.find('.el-checkbox').exists()).toBeTruthy() + // expand all nodes + let nodes = wrapper.findAll(TREE_NODE_CLASS_NAME) + await nodes[0].trigger('click') + nodes = wrapper.findAll(TREE_NODE_CLASS_NAME) + await nodes[1].trigger('click') + nodes = wrapper.findAll(TREE_NODE_CLASS_NAME) + await nodes[4].trigger('click') + nodes = wrapper.findAll(TREE_NODE_CLASS_NAME) + expect(nodes.length).toBe(8) + // When node-1 is checked, all child nodes should be checked + await nodes[0].find('.el-checkbox').trigger('click') + expect(wrapper.findAll('.el-checkbox.is-checked').length).toBe(7) + // When cancel node-1 checked, all child nodes should not be checked + await nodes[0].find('.el-checkbox').trigger('click') + expect(wrapper.findAll('.el-checkbox.is-checked').length).toBe(0) + // When node-1-1 is checked, node-1-1-1 and node-1-1-2 should be checked + await nodes[1].find('.el-checkbox').trigger('click') + expect( + wrapper + .findAll(`${TREE_NODE_CLASS_NAME}.is-checked`) + .map((el) => el.text()) + .toString() + ).toBe(['node-1-1', 'node-1-1-1', 'node-1-1-2'].toString()) + // When cancel node-1-1, node-1-1-1 and node-1-1-2 should not be checked + await nodes[1].find('.el-checkbox').trigger('click') + expect(wrapper.findAll('.el-checkbox.is-checked').length).toBe(0) + // When node-1-1-1 is checked, node-1 and node-1-1 should be indeterminate + await nodes[2].find('.el-checkbox').trigger('click') + expect(wrapper.findAll('.el-checkbox.is-checked').length).toBe(1) + expect(wrapper.findAll('.el-checkbox .is-indeterminate').length).toBe(2) + // When node-1-1-1 and node-1-1-2 are checked, node-1-1 should be checked, node-1 should be indeterminate + await nodes[3].find('.el-checkbox').trigger('click') + expect(wrapper.findAll('.el-checkbox.is-checked').length).toBe(3) + expect(wrapper.findAll('.el-checkbox .is-indeterminate').length).toBe(1) + await nodes[3].find('.el-checkbox').trigger('click') + await nodes[2].find('.el-checkbox').trigger('click') + // test one leaf node + // When node-1-2-1 is checked, node-1-2 should be checked + await nodes[5].find('.el-checkbox').trigger('click') + expect( + wrapper + .findAll(`${TREE_NODE_CLASS_NAME}.is-checked`) + .map((el) => el.text()) + .toString() + ).toBe(['node-1-2', 'node-1-2-1'].toString()) + // cancel node-1-2-1, node-1-2 should not be checked + await nodes[5].find('.el-checkbox').trigger('click') + expect(wrapper.findAll('.el-checkbox.is-checked').length).toBe(0) + expect(wrapper.findAll('.el-checkbox .is-indeterminate').length).toBe(0) + }) + + test('defaultCheckedKeys', async () => { + const { treeRef } = createTree({ + data() { + return { + height: 400, + data: [ + { + id: '1', + label: 'node-1', + children: [ + { + id: '1-1', + label: 'node-1-1', + children: [ + { + id: '1-1-1', + label: 'node-1-1-1', + }, + { + id: '1-1-2', + label: 'node-1-1-2', + }, + ], + }, + { + id: '1-2', + label: 'node-1-2', + children: [ + { + id: '1-2-1', + label: 'node-1-2-1', + }, + ], + }, + { + id: '1-3', + label: 'node-1-3', + }, + ], + }, + { + id: '2', + label: 'node-2', + }, + ], + defaultCheckedKeys: ['1-1-1', '1-1-2'], + showCheckbox: true, + } + }, + }) + await nextTick() + // node-1-1 should be checked + expect(treeRef.getCheckedKeys().length).toBe(3) + // node-1-1 should be indeterminate + expect(treeRef.getHalfCheckedKeys().length).toBe(1) + }) + + test('checkStrictly', async () => { + const { treeRef, wrapper } = createTree({ + data() { + return { + height: 400, + data: [ + { + id: '1', + label: 'node-1', + children: [ + { + id: '1-1', + label: 'node-1-1', + children: [ + { + id: '1-1-1', + label: 'node-1-1-1', + }, + { + id: '1-1-2', + label: 'node-1-1-2', + }, + ], + }, + { + id: '1-2', + label: 'node-1-2', + children: [ + { + id: '1-2-1', + label: 'node-1-2-1', + }, + ], + }, + { + id: '1-3', + label: 'node-1-3', + }, + ], + }, + { + id: '2', + label: 'node-2', + }, + ], + defaultCheckedKeys: ['1-1-1', '1-1-2'], + showCheckbox: true, + checkStrictly: true, + } + }, + }) + await nextTick() + // node-1-1 should not be checked + expect(treeRef.getCheckedKeys().length).toBe(2) + // node-1-1 should not be indeterminate + expect(treeRef.getHalfCheckedKeys().length).toBe(0) + // manual + const nodes = wrapper.findAll(TREE_NODE_CLASS_NAME) + await nodes[0].find('.el-checkbox').trigger('click') + expect(treeRef.getCheckedKeys().length).toBe(3) + }) + + test('defaultExpandedKeys', async () => { + const { wrapper } = createTree({ + data() { + return { + height: 400, + data: [ + { + id: '1', + label: 'node-1', + children: [ + { + id: '1-1', + label: 'node-1-1', + children: [ + { + id: '1-1-1', + label: 'node-1-1-1', + }, + { + id: '1-1-2', + label: 'node-1-1-2', + }, + ], + }, + { + id: '1-2', + label: 'node-1-2', + children: [ + { + id: '1-2-1', + label: 'node-1-2-1', + }, + ], + }, + { + id: '1-3', + label: 'node-1-3', + }, + ], + }, + { + id: '2', + label: 'node-2', + }, + ], + defaultExpandedKeys: ['1'], + } + }, + }) + await nextTick() + const nodes = wrapper.findAll(TREE_NODE_CLASS_NAME) + expect(nodes.length).toBe(5) + }) + + test('indent', async () => { + const { wrapper } = createTree({ + data() { + return { + indent: 20, + data: [ + { + id: '1', + label: 'node-1', + children: [ + { + id: '1-1', + label: 'node-1-1', + children: [ + { + id: '1-1-1', + label: 'node-1-1-1', + }, + { + id: '1-1-2', + label: 'node-1-1-2', + }, + ], + }, + { + id: '1-2', + label: 'node-1-2', + children: [ + { + id: '1-2-1', + label: 'node-1-2-1', + }, + ], + }, + { + id: '1-3', + label: 'node-1-3', + }, + ], + }, + { + id: '2', + label: 'node-2', + }, + ], + defaultExpandedKeys: ['1'], + } + }, + }) + await nextTick() + const nodes = wrapper.findAll(TREE_NODE_CLASS_NAME) + const node = nodes[1].element.querySelector( + TREE_NODE_CONTENT_CLASS_NAME + ) as any + expect(node.style.paddingLeft).toBe('20px') + }) + + test('expandOnClickNode', async () => { + const onNodeExpand = jest.fn() + const { wrapper } = createTree({ + data() { + return { + expandOnClickNode: false, + } + }, + methods: { + onNodeExpand, + }, + }) + await nextTick() + const nodes = wrapper.findAll(TREE_NODE_CLASS_NAME) + await nodes[0].trigger('click') + expect(onNodeExpand).not.toHaveBeenCalled() + await nodes[0].find(TREE_NODE_EXPAND_ICON_CLASS_NAME).trigger('click') + expect(onNodeExpand).toHaveBeenCalled() + }) + + test('checkOnClickNode', async () => { + const { wrapper, treeRef } = createTree({ + data() { + return { + showCheckbox: true, + expandOnClickNode: false, + checkOnClickNode: true, + checkStrictly: true, + } + }, + }) + await nextTick() + const nodes = wrapper.findAll(TREE_NODE_CLASS_NAME) + await nodes[0].trigger('click') + expect(treeRef.getCheckedKeys().toString()).toBe([1].toString()) + }) + + test('currentNodeKey', async () => { + const { wrapper } = createTree({ + data() { + return { + currentNodeKey: '2', + data: [ + { + id: '1', + label: 'node-1', + children: [ + { + id: '1-1', + label: 'node-1-1', + children: [ + { + id: '1-1-1', + label: 'node-1-1-1', + }, + { + id: '1-1-2', + label: 'node-1-1-2', + }, + ], + }, + { + id: '1-2', + label: 'node-1-2', + children: [ + { + id: '1-2-1', + label: 'node-1-2-1', + }, + ], + }, + { + id: '1-3', + label: 'node-1-3', + }, + ], + }, + { + id: '2', + label: 'node-2', + }, + ], + } + }, + }) + await nextTick() + const nodes = wrapper.findAll(TREE_NODE_CLASS_NAME) + expect(nodes[1].classes()).toContain('is-current') + }) + + test('custom node content', async () => { + const { wrapper } = createTree({ + slots: { + default: `
cc {{node.label}}
`, + }, + }) + await nextTick() + expect(wrapper.find('.custom-tree-node-content').text()).toBe('cc node-1') + }) + + test('filter', async () => { + const { treeRef, wrapper } = createTree({ + data() { + return { + currentNodeKey: '2', + data: [ + { + id: '1', + label: 'node-1', + children: [ + { + id: '1-1', + label: 'node-1-1', + children: [ + { + id: '1-1-1', + label: 'node-1-1-1', + }, + { + id: '1-1-2', + label: 'node-1-1-2', + }, + ], + }, + { + id: '1-2', + label: 'node-1-2', + children: [ + { + id: '1-2-1', + label: 'node-1-2-1', + }, + ], + }, + { + id: '1-3', + label: 'node-1-3', + }, + ], + }, + { + id: '2', + label: 'node-2', + }, + ], + filterMethod(query: string, node: TreeNodeData) { + return node.label.indexOf(query) !== -1 + }, + } + }, + }) + await nextTick() + treeRef.filter('node-1-1-1') + await nextTick() + const nodes = wrapper.findAll(TREE_NODE_CLASS_NAME) + expect(nodes.map((node) => node.text()).toString()).toBe( + ['node-1', 'node-1-1', 'node-1-1-1'].toString() + ) + }) + + describe('events', () => { + test('current-change', async () => { + const onCurrentChange = jest.fn() + const { wrapper, vm, treeVm } = createTree({ + methods: { + onCurrentChange, + }, + }) + await nextTick() + await wrapper.find(TREE_NODE_CLASS_NAME).trigger('click') + expect(onCurrentChange).toHaveBeenCalledTimes(1) + expect(onCurrentChange).toHaveBeenCalledWith( + vm.data[0], + treeVm.flattenTree[0] + ) + }) + test('check', async () => { + const onNodeCheck = jest.fn() + const { wrapper } = createTree({ + data() { + return { + showCheckbox: true, + defaultExpandedKeys: ['1-1', '1'], + data: [ + { + id: '1', + label: 'node-1', + children: [ + { + id: '1-1', + label: 'node-1-1', + children: [ + { + id: '1-1-1', + label: 'node-1-1-1', + }, + { + id: '1-1-2', + label: 'node-1-1-2', + }, + ], + }, + { + id: '1-2', + label: 'node-1-2', + children: [ + { + id: '1-2-1', + label: 'node-1-2-1', + }, + ], + }, + { + id: '1-3', + label: 'node-1-3', + }, + ], + }, + { + id: '2', + label: 'node-2', + }, + ], + } + }, + methods: { + onNodeCheck, + }, + }) + await nextTick() + const nodes = wrapper.findAll(TREE_NODE_CLASS_NAME) + await nodes[2].find('.el-checkbox').trigger('click') + expect(onNodeCheck).toHaveBeenCalledTimes(1) + expect(onNodeCheck).toHaveBeenCalledWith( + { id: '1-1-1', label: 'node-1-1-1' }, + { + checkedKeys: ['1-1-1'], + checkedNodes: [{ id: '1-1-1', label: 'node-1-1-1' }], + halfCheckedKeys: ['1-1', '1'], + halfCheckedNodes: [ + { + children: [ + { id: '1-1-1', label: 'node-1-1-1' }, + { id: '1-1-2', label: 'node-1-1-2' }, + ], + id: '1-1', + label: 'node-1-1', + }, + { + children: [ + { + children: [ + { id: '1-1-1', label: 'node-1-1-1' }, + { id: '1-1-2', label: 'node-1-1-2' }, + ], + id: '1-1', + label: 'node-1-1', + }, + { + children: [{ id: '1-2-1', label: 'node-1-2-1' }], + id: '1-2', + label: 'node-1-2', + }, + { id: '1-3', label: 'node-1-3' }, + ], + id: '1', + label: 'node-1', + }, + ], + } + ) + }) + test('context-menu', async () => { + const onNodeContextMenu = jest.fn() + const { wrapper } = createTree({ + methods: { + onNodeContextMenu, + }, + }) + await nextTick() + await wrapper.find(TREE_NODE_CLASS_NAME).trigger('contextmenu') + expect(onNodeContextMenu).toHaveBeenCalledTimes(1) + }) + }) + + describe('methods', () => { + test('getChecked', async () => { + const { treeRef } = createTree({ + data() { + return { + showCheckbox: true, + defaultCheckedKeys: ['1-1-2'], + data: [ + { + id: '1', + label: 'node-1', + children: [ + { + id: '1-1', + label: 'node-1-1', + children: [ + { + id: '1-1-1', + label: 'node-1-1-1', + }, + { + id: '1-1-2', + label: 'node-1-1-2', + }, + ], + }, + { + id: '1-2', + label: 'node-1-2', + children: [ + { + id: '1-2-1', + label: 'node-1-2-1', + }, + ], + }, + { + id: '1-3', + label: 'node-1-3', + }, + ], + }, + { + id: '2', + label: 'node-2', + }, + ], + } + }, + }) + await nextTick() + const checkedKeys = treeRef.getCheckedKeys() + const checkedNodes = treeRef.getCheckedNodes() + const halfCheckedKeys = treeRef.getHalfCheckedKeys() + const halfCheckedNodes = treeRef.getHalfCheckedNodes() + expect(checkedKeys.toString()).toBe(['1-1-2'].toString()) + expect(checkedNodes.map((node) => node.id).toString()).toBe( + ['1-1-2'].toString() + ) + expect(halfCheckedKeys.toString()).toBe(['1-1', '1'].toString()) + expect(halfCheckedNodes.map((node) => node.id).toString()).toBe( + ['1-1', '1'].toString() + ) + }) + + test('setCheckedKeys', async () => { + const { treeRef } = createTree({ + data() { + return { + showCheckbox: true, + data: [ + { + id: '1', + label: 'node-1', + children: [ + { + id: '1-1', + label: 'node-1-1', + children: [ + { + id: '1-1-1', + label: 'node-1-1-1', + }, + { + id: '1-1-2', + label: 'node-1-1-2', + }, + ], + }, + { + id: '1-2', + label: 'node-1-2', + children: [ + { + id: '1-2-1', + label: 'node-1-2-1', + }, + ], + }, + { + id: '1-3', + label: 'node-1-3', + }, + ], + }, + { + id: '2', + label: 'node-2', + }, + ], + } + }, + }) + await nextTick() + treeRef.setCheckedKeys(['1-1']) + await nextTick() + const checkedKeys = treeRef.getCheckedKeys() + const halfCheckedKeys = treeRef.getHalfCheckedKeys() + expect(checkedKeys.toString()).toBe(['1-1', '1-1-1', '1-1-2'].toString()) + expect(halfCheckedKeys.toString()).toBe(['1'].toString()) + }) + + test('setChecked', async () => { + const { treeRef } = createTree({ + data() { + return { + showCheckbox: true, + data: [ + { + id: '1', + label: 'node-1', + children: [ + { + id: '1-1', + label: 'node-1-1', + children: [ + { + id: '1-1-1', + label: 'node-1-1-1', + }, + { + id: '1-1-2', + label: 'node-1-1-2', + }, + ], + }, + { + id: '1-2', + label: 'node-1-2', + children: [ + { + id: '1-2-1', + label: 'node-1-2-1', + }, + ], + }, + { + id: '1-3', + label: 'node-1-3', + }, + ], + }, + { + id: '2', + label: 'node-2', + }, + ], + } + }, + }) + await nextTick() + treeRef.setChecked('1-1', true) + const checkedKeys = treeRef.getCheckedKeys() + const halfCheckedKeys = treeRef.getHalfCheckedKeys() + expect(checkedKeys.toString()).toBe(['1-1', '1-1-1', '1-1-2'].toString()) + expect(halfCheckedKeys.toString()).toBe(['1'].toString()) + }) + + test('getCurrent', async () => { + const { treeRef, wrapper } = createTree({ + data() { + return { + defaultExpandedKeys: ['1', '1-1'], + data: [ + { + id: '1', + label: 'node-1', + children: [ + { + id: '1-1', + label: 'node-1-1', + children: [ + { + id: '1-1-1', + label: 'node-1-1-1', + }, + { + id: '1-1-2', + label: 'node-1-1-2', + }, + ], + }, + { + id: '1-2', + label: 'node-1-2', + children: [ + { + id: '1-2-1', + label: 'node-1-2-1', + }, + ], + }, + { + id: '1-3', + label: 'node-1-3', + }, + ], + }, + { + id: '2', + label: 'node-2', + }, + ], + } + }, + }) + await nextTick() + const nodes = wrapper.findAll(TREE_NODE_CLASS_NAME) + await nodes[2].trigger('click') + expect(treeRef.getCurrentNode()).toMatchObject({ + id: '1-1-1', + label: 'node-1-1-1', + }) + expect(treeRef.getCurrentKey()).toBe('1-1-1') + treeRef.setCurrentKey('1-1-2') + expect(treeRef.getCurrentNode()).toMatchObject({ + id: '1-1-2', + label: 'node-1-1-2', + }) + expect(treeRef.getCurrentKey()).toBe('1-1-2') + }) + }) +}) diff --git a/packages/components/tree-v2/index.ts b/packages/components/tree-v2/index.ts new file mode 100644 index 0000000000..90f6bbc468 --- /dev/null +++ b/packages/components/tree-v2/index.ts @@ -0,0 +1,5 @@ +import { withInstall } from '@element-plus/utils/with-install' +import TreeV2 from './src/tree.vue' + +export const ElTreeV2 = withInstall(TreeV2) +export default ElTreeV2 diff --git a/packages/components/tree-v2/src/composables/useCheck.ts b/packages/components/tree-v2/src/composables/useCheck.ts new file mode 100644 index 0000000000..772b6594a6 --- /dev/null +++ b/packages/components/tree-v2/src/composables/useCheck.ts @@ -0,0 +1,221 @@ +import { nextTick, ref, watch, getCurrentInstance } from 'vue' +import { + NODE_CHECK_CHANGE, + NODE_CHECK, + SetOperationEnum, +} from '../virtual-tree' +import type { Ref } from 'vue' +import type { TreeProps, TreeKey, TreeNode, Tree, TreeNodeData } from '../types' + +export function useCheck(props: TreeProps, tree: Ref) { + const checkedKeys = ref>(new Set()) + const indeterminateKeys = ref>(new Set()) + const { emit } = getCurrentInstance()! + + watch( + () => tree.value, + () => { + return nextTick(() => { + _setCheckedKeys(props.defaultCheckedKeys) + }) + }, + { + immediate: true, + } + ) + + const updateCheckedKeys = () => { + if (!tree.value || !props.showCheckbox || props.checkStrictly) { + return + } + const { levelTreeNodeMap, maxLevel } = tree.value + const checkedKeySet = checkedKeys.value + const indeterminateKeySet = new Set() + // It is easier to determine the indeterminate state by + // traversing from bottom to top + // leaf nodes not have indeterminate status and can be skipped + for (let level = maxLevel - 1; level >= 1; --level) { + const nodes = levelTreeNodeMap.get(level) + if (!nodes) continue + nodes.forEach((node) => { + const children = node.children + if (children) { + // Whether all child nodes are selected + let allChecked = true + // Whether a child node is selected + let hasChecked = false + for (let i = 0; i < children.length; ++i) { + const childNode = children[i] + const key = childNode.key + if (checkedKeySet.has(key)) { + hasChecked = true + } else if (indeterminateKeySet.has(key)) { + allChecked = false + hasChecked = true + break + } else { + allChecked = false + } + } + if (allChecked) { + checkedKeySet.add(node.key) + } else if (hasChecked) { + indeterminateKeySet.add(node.key) + checkedKeySet.delete(node.key) + } else { + checkedKeySet.delete(node.key) + indeterminateKeySet.delete(node.key) + } + } + }) + } + indeterminateKeys.value = indeterminateKeySet + } + + const isChecked = (node: TreeNode) => checkedKeys.value.has(node.key) + + const isIndeterminate = (node: TreeNode) => + indeterminateKeys.value.has(node.key) + + const toggleCheckbox = ( + node: TreeNode, + isChecked: boolean, + nodeClick = true + ) => { + const checkedKeySet = checkedKeys.value + const toggle = (node: TreeNode, checked: boolean) => { + checkedKeySet[checked ? SetOperationEnum.ADD : SetOperationEnum.DELETE]( + node.key + ) + const children = node.children + if (!props.checkStrictly && children) { + children.forEach((childNode) => { + if (!childNode.disabled) { + toggle(childNode, checked) + } + }) + } + } + toggle(node, isChecked) + updateCheckedKeys() + if (nodeClick) { + afterNodeCheck(node, isChecked) + } + } + + const afterNodeCheck = (node: TreeNode, checked: boolean) => { + const { checkedNodes, checkedKeys } = getChecked() + const { halfCheckedNodes, halfCheckedKeys } = getHalfChecked() + emit(NODE_CHECK, node.data, { + checkedKeys, + checkedNodes, + halfCheckedKeys, + halfCheckedNodes, + }) + emit(NODE_CHECK_CHANGE, node.data, checked) + } + + // expose + function getCheckedKeys(leafOnly = false): TreeKey[] { + return getChecked(leafOnly).checkedKeys + } + + function getCheckedNodes(leafOnly = false): TreeNodeData[] { + return getChecked(leafOnly).checkedNodes + } + + function getHalfCheckedKeys(): TreeKey[] { + return getHalfChecked().halfCheckedKeys + } + + function getHalfCheckedNodes(): TreeNodeData[] { + return getHalfChecked().halfCheckedNodes + } + + function getChecked(leafOnly = false): { + checkedKeys: TreeKey[] + checkedNodes: TreeNodeData[] + } { + const checkedNodes: TreeNodeData[] = [] + const keys: TreeKey[] = [] + if (tree?.value && props.showCheckbox) { + const { treeNodeMap } = tree.value + checkedKeys.value.forEach((key) => { + const node = treeNodeMap.get(key) + if (node && (!leafOnly || (leafOnly && node.isLeaf))) { + keys.push(key) + checkedNodes.push(node.data) + } + }) + } + return { + checkedKeys: keys, + checkedNodes, + } + } + + function getHalfChecked(): { + halfCheckedKeys: TreeKey[] + halfCheckedNodes: TreeNodeData[] + } { + const halfCheckedNodes: TreeNodeData[] = [] + const halfCheckedKeys: TreeKey[] = [] + if (tree?.value && props.showCheckbox) { + const { treeNodeMap } = tree.value + indeterminateKeys.value.forEach((key) => { + const node = treeNodeMap.get(key) + if (node) { + halfCheckedKeys.push(key) + halfCheckedNodes.push(node.data) + } + }) + } + return { + halfCheckedNodes, + halfCheckedKeys, + } + } + + function setCheckedKeys(keys: TreeKey[]) { + checkedKeys.value.clear() + _setCheckedKeys(keys) + } + + function setChecked(key: TreeKey, isChecked: boolean) { + if (tree?.value && props.showCheckbox) { + const node = tree.value.treeNodeMap.get(key) + if (node) { + toggleCheckbox(node, isChecked, false) + } + } + } + + function _setCheckedKeys(keys: TreeKey[]) { + if (tree?.value) { + const { treeNodeMap } = tree.value + if (props.showCheckbox && treeNodeMap && keys) { + for (let i = 0; i < keys.length; ++i) { + const key = keys[i] + const node = treeNodeMap.get(key) + if (node && !isChecked(node)) { + toggleCheckbox(node, true, false) + } + } + } + } + } + + return { + updateCheckedKeys, + toggleCheckbox, + isChecked, + isIndeterminate, + // expose + getCheckedKeys, + getCheckedNodes, + getHalfCheckedKeys, + getHalfCheckedNodes, + setChecked, + setCheckedKeys, + } +} diff --git a/packages/components/tree-v2/src/composables/useFilter.ts b/packages/components/tree-v2/src/composables/useFilter.ts new file mode 100644 index 0000000000..2bf8be747d --- /dev/null +++ b/packages/components/tree-v2/src/composables/useFilter.ts @@ -0,0 +1,79 @@ +import { computed, ref } from 'vue' +import { isFunction } from '@vue/shared' +import type { Ref } from 'vue' +import type { TreeProps, TreeKey, TreeNode, Tree } from '../types' + +// When the data volume is very large using filter will cause lag +// I haven't found a better way to optimize it for now +// Maybe this problem should be left to the server side +export function useFilter(props: TreeProps, tree: Ref) { + const hiddenNodeKeySet = ref>(new Set([])) + const hiddenExpandIconKeySet = ref>(new Set([])) + + const filterable = computed(() => { + return isFunction(props.filterMethod) + }) + + function doFilter(query: string) { + if (!filterable.value) { + return + } + const expandKeySet = new Set() + const hiddenExpandIconKeys = hiddenExpandIconKeySet.value + const hiddenKeys = hiddenNodeKeySet.value + const family: TreeNode[] = [] + const nodes = tree.value?.treeNodes || [] + const filter = props.filterMethod + hiddenKeys.clear() + function traverse(nodes: TreeNode[]) { + nodes.forEach((node) => { + family.push(node) + if (filter?.(query, node.data)) { + family.forEach((member) => { + expandKeySet.add(member.key) + }) + } else if (node.isLeaf) { + hiddenKeys.add(node.key) + } + const children = node.children + if (children) { + traverse(children) + } + if (!node.isLeaf) { + if (!expandKeySet.has(node.key)) { + hiddenKeys.add(node.key) + } else if (children) { + // If all child nodes are hidden, then the expand icon will be hidden + let allHidden = true + for (let i = 0; i < children.length; ++i) { + const childNode = children[i] + if (!hiddenKeys.has(childNode.key)) { + allHidden = false + break + } + } + if (allHidden) { + hiddenExpandIconKeys.add(node.key) + } else { + hiddenExpandIconKeys.delete(node.key) + } + } + } + family.pop() + }) + } + traverse(nodes) + return expandKeySet + } + + function isForceHiddenExpandIcon(node: TreeNode): boolean { + return hiddenExpandIconKeySet.value.has(node.key) + } + + return { + hiddenExpandIconKeySet, + hiddenNodeKeySet, + doFilter, + isForceHiddenExpandIcon, + } +} diff --git a/packages/components/tree-v2/src/composables/useTree.ts b/packages/components/tree-v2/src/composables/useTree.ts new file mode 100644 index 0000000000..50c04d2ef6 --- /dev/null +++ b/packages/components/tree-v2/src/composables/useTree.ts @@ -0,0 +1,295 @@ +import { computed, nextTick, ref, shallowRef, watch } from 'vue' +import { + NODE_CLICK, + NODE_COLLAPSE, + NODE_EXPAND, + CURRENT_CHANGE, + TreeOptionsEnum, +} from '../virtual-tree' +import { useCheck } from './useCheck' +import { useFilter } from './useFilter' +import type { + TreeProps, + TreeNodeData, + TreeKey, + TreeNode, + TreeData, + Tree, +} from '../types' + +export function useTree(props: TreeProps, emit) { + const expandedKeySet = ref>(new Set(props.defaultExpandedKeys)) + const currentKey = ref() + const tree = shallowRef() + + watch( + () => props.currentNodeKey, + (key) => { + currentKey.value = key + }, + { + immediate: true, + } + ) + + watch( + () => props.data, + (data: TreeData) => { + setData(data) + }, + { + immediate: true, + } + ) + + const { + isIndeterminate, + isChecked, + toggleCheckbox, + getCheckedKeys, + getCheckedNodes, + getHalfCheckedKeys, + getHalfCheckedNodes, + setChecked, + setCheckedKeys, + } = useCheck(props, tree) + + const { doFilter, hiddenNodeKeySet, isForceHiddenExpandIcon } = useFilter( + props, + tree + ) + + const valueKey = computed(() => { + return props.props?.value || TreeOptionsEnum.KEY + }) + const childrenKey = computed(() => { + return props.props?.children || TreeOptionsEnum.CHILDREN + }) + const disabledKey = computed(() => { + return props.props?.disabled || TreeOptionsEnum.DISABLED + }) + const labelKey = computed(() => { + return props.props?.label || TreeOptionsEnum.LABEL + }) + + const flattenTree = computed(() => { + const expandedKeys = expandedKeySet.value + const hiddenKeys = hiddenNodeKeySet.value + const flattenNodes: TreeNode[] = [] + const nodes = (tree.value && tree.value.treeNodes) || [] + function traverse() { + const stack: TreeNode[] = [] + for (let i = nodes.length - 1; i >= 0; --i) { + stack.push(nodes[i]) + } + while (stack.length) { + const node = stack.pop() + if (!node) continue + if (!hiddenKeys.has(node.key)) { + flattenNodes.push(node) + } + // Only "visible" nodes will be rendered + if (expandedKeys.has(node.key)) { + const children = node.children + if (children) { + const length = children.length + for (let i = length - 1; i >= 0; --i) { + stack.push(children[i]) + } + } + } + } + } + traverse() + return flattenNodes + }) + + const isNotEmpty = computed(() => { + return flattenTree.value.length > 0 + }) + + function createTree(data: TreeData): Tree { + const treeNodeMap: Map = new Map() + const levelTreeNodeMap: Map = new Map() + let maxLevel = 1 + function traverse( + nodes: TreeData, + level = 1, + parent: TreeNode | undefined = undefined + ) { + const siblings: TreeNode[] = [] + for (let index = 0; index < nodes.length; ++index) { + const rawNode = nodes[index] + const value = getKey(rawNode) + const node: TreeNode = { + level, + key: value, + data: rawNode, + } + node.label = getLabel(rawNode) + node.parent = parent + const children = getChildren(rawNode) + node.disabled = getDisabled(rawNode) + node.isLeaf = !children || children.length === 0 + if (children && children.length) { + node.children = traverse(children, level + 1, node) + } + siblings.push(node) + treeNodeMap.set(value, node) + if (!levelTreeNodeMap.has(level)) { + levelTreeNodeMap.set(level, []) + } + levelTreeNodeMap.get(level)?.push(node) + } + if (level > maxLevel) { + maxLevel = level + } + return siblings + } + const treeNodes: TreeNode[] = traverse(data) + return { + treeNodeMap, + levelTreeNodeMap, + maxLevel, + treeNodes, + } + } + + function filter(query: string) { + const keys = doFilter(query) + if (keys) { + expandedKeySet.value = keys + } + } + + function getChildren(node: TreeNodeData): TreeNodeData[] { + return node[childrenKey.value] + } + + function getKey(node: TreeNodeData): TreeKey { + if (!node) { + return '' + } + return node[valueKey.value] + } + + function getDisabled(node: TreeNodeData): boolean { + return node[disabledKey.value] + } + + function getLabel(node: TreeNodeData): string { + return node[labelKey.value] + } + + function toggleExpand(node: TreeNode) { + const expandedKeys = expandedKeySet.value + if (expandedKeys.has(node.key)) { + collapse(node) + } else { + expand(node) + } + } + + function handleNodeClick(node: TreeNode) { + emit(NODE_CLICK, node.data, node) + handleCurrentChange(node) + if (props.expandOnClickNode) { + toggleExpand(node) + } + if (props.showCheckbox && props.checkOnClickNode && !node.disabled) { + toggleCheckbox(node, !isChecked(node), true) + } + } + + function handleCurrentChange(node: TreeNode) { + if (!isCurrent(node)) { + currentKey.value = node.key + emit(CURRENT_CHANGE, node.data, node) + } + } + + function handleNodeCheck(node: TreeNode, checked: boolean) { + toggleCheckbox(node, checked) + } + + function expand(node: TreeNode) { + const keySet = expandedKeySet.value + if (tree?.value && props.accordion) { + // whether only one node among the same level can be expanded at one time + const { treeNodeMap } = tree.value + keySet.forEach((key) => { + const node = treeNodeMap.get(key) + if (node && node.level === node.level) { + keySet.delete(key) + } + }) + } + keySet.add(node.key) + emit(NODE_EXPAND, node.data, node) + } + + function collapse(node: TreeNode) { + expandedKeySet.value.delete(node.key) + emit(NODE_COLLAPSE, node.data, node) + } + + function isExpanded(node: TreeNode): boolean { + return expandedKeySet.value.has(node.key) + } + + function isDisabled(node: TreeNode): boolean { + return !!node.disabled + } + + function isCurrent(node: TreeNode): boolean { + const current = currentKey.value + return !!current && current === node.key + } + + function getCurrentNode(): TreeNodeData | undefined { + if (!currentKey.value) return undefined + return tree?.value?.treeNodeMap.get(currentKey.value)?.data + } + + function getCurrentKey(): TreeKey | undefined { + return currentKey.value + } + + function setCurrentKey(key: TreeKey): void { + currentKey.value = key + } + + function setData(data: TreeData) { + nextTick(() => (tree.value = createTree(data))) + } + + return { + tree, + flattenTree, + isNotEmpty, + getKey, + getChildren, + toggleExpand, + toggleCheckbox, + isExpanded, + isChecked, + isIndeterminate, + isDisabled, + isCurrent, + isForceHiddenExpandIcon, + handleNodeClick, + handleNodeCheck, + // expose + getCurrentNode, + getCurrentKey, + setCurrentKey, + getCheckedKeys, + getCheckedNodes, + getHalfCheckedKeys, + getHalfCheckedNodes, + setChecked, + setCheckedKeys, + filter, + setData, + } +} diff --git a/packages/components/tree-v2/src/tree-node-content.ts b/packages/components/tree-v2/src/tree-node-content.ts new file mode 100644 index 0000000000..348de8ba23 --- /dev/null +++ b/packages/components/tree-v2/src/tree-node-content.ts @@ -0,0 +1,17 @@ +import { h, defineComponent, inject } from 'vue' +import { ROOT_TREE_INJECTION_KEY, treeNodeContentProps } from './virtual-tree' + +export default defineComponent({ + name: 'ElTreeNodeContent', + props: treeNodeContentProps, + setup(props) { + const tree = inject(ROOT_TREE_INJECTION_KEY) + return () => { + const node = props.node + const { data } = node! + return tree?.ctx.slots.default + ? tree.ctx.slots.default({ node, data }) + : h('span', { class: 'el-tree-node__label' }, [node?.label]) + } + }, +}) diff --git a/packages/components/tree-v2/src/tree-node.vue b/packages/components/tree-v2/src/tree-node.vue new file mode 100644 index 0000000000..ece4a7feb6 --- /dev/null +++ b/packages/components/tree-v2/src/tree-node.vue @@ -0,0 +1,113 @@ + + + diff --git a/packages/components/tree-v2/src/tree.vue b/packages/components/tree-v2/src/tree.vue new file mode 100644 index 0000000000..11fc6f20a1 --- /dev/null +++ b/packages/components/tree-v2/src/tree.vue @@ -0,0 +1,127 @@ + + + diff --git a/packages/components/tree-v2/src/types.ts b/packages/components/tree-v2/src/types.ts new file mode 100644 index 0000000000..5c4bef4b41 --- /dev/null +++ b/packages/components/tree-v2/src/types.ts @@ -0,0 +1,54 @@ +import type { + ComponentInternalInstance, + SetupContext, + ExtractPropTypes, +} from 'vue' +import type { treeProps, treeEmits } from './virtual-tree' + +export type TreeNodeData = Record + +export type TreeData = TreeNodeData[] + +export type TreeKey = string | number + +export interface TreeOptionProps { + children?: string + label?: string + value?: string + disabled?: string +} + +export type TreeProps = ExtractPropTypes + +export interface TreeNode { + key: TreeKey + level: number + parent?: TreeNode + children?: TreeNode[] + data: TreeNodeData + disabled?: boolean + label?: string + isLeaf?: boolean +} + +export interface TreeContext { + ctx: SetupContext + instance: ComponentInternalInstance + props: TreeProps +} + +export interface Tree { + treeNodeMap: Map + levelTreeNodeMap: Map + treeNodes: TreeNode[] + maxLevel: number +} + +export type FilterMethod = (query: string, node: TreeNodeData) => boolean + +export interface CheckedInfo { + checkedKeys: TreeKey[] + checkedNodes: TreeData + halfCheckedKeys: TreeKey[] + halfCheckedNodes: TreeData +} diff --git a/packages/components/tree-v2/src/virtual-tree.ts b/packages/components/tree-v2/src/virtual-tree.ts new file mode 100644 index 0000000000..5a18769e2b --- /dev/null +++ b/packages/components/tree-v2/src/virtual-tree.ts @@ -0,0 +1,182 @@ +import { buildProp, definePropType, mutable } from '@element-plus/utils/props' +import type { InjectionKey } from 'vue' +import type { TreeNodeData } from '../../tree/src/tree.type' +import type { + TreeNode, + TreeKey, + TreeData, + TreeOptionProps, + FilterMethod, + CheckedInfo, + TreeContext, +} from './types' + +// constants +export const ROOT_TREE_INJECTION_KEY: InjectionKey = Symbol() +const EMPTY_NODE = { + key: -1, + level: -1, + data: {}, +} as const + +// enums +export enum TreeOptionsEnum { + KEY = 'id', + LABEL = 'label', + CHILDREN = 'children', + DISABLED = 'disabled', +} + +export const enum SetOperationEnum { + ADD = 'add', + DELETE = 'delete', +} + +// props +export const treeProps = { + data: buildProp({ + type: definePropType(Array), + default: () => mutable([] as const), + } as const), + emptyText: buildProp({ + type: String, + }), + height: buildProp({ + type: Number, + default: 200, + }), + props: buildProp({ + type: definePropType(Object), + default: () => + mutable({ + children: TreeOptionsEnum.CHILDREN, + label: TreeOptionsEnum.LABEL, + disabled: TreeOptionsEnum.DISABLED, + value: TreeOptionsEnum.KEY, + } as const), + } as const), + highlightCurrent: buildProp({ + type: Boolean, + default: false, + }), + showCheckbox: buildProp({ + type: Boolean, + default: false, + }), + defaultCheckedKeys: buildProp({ + type: definePropType(Array), + default: () => mutable([] as const), + } as const), + // Whether checked state of a node not affects its father and + // child nodes when show-checkbox is true + checkStrictly: buildProp({ + type: Boolean, + default: false, + }), + defaultExpandedKeys: buildProp({ + type: definePropType(Array), + default: () => mutable([] as const), + } as const), + indent: buildProp({ + type: Number, + default: 16, + }), + icon: buildProp({ + type: String, + }), + expandOnClickNode: buildProp({ + type: Boolean, + default: true, + }), + checkOnClickNode: buildProp({ + type: Boolean, + default: false, + }), + currentNodeKey: buildProp({ + type: definePropType([String, Number]), + } as const), + // TODO need to optimization + accordion: buildProp({ + type: Boolean, + default: false, + }), + filterMethod: buildProp({ + type: definePropType(Function), + } as const), + // Performance mode will increase memory usage, but scrolling will be smoother + perfMode: buildProp({ + type: Boolean, + default: true, + }), +} as const + +export const treeNodeProps = { + node: buildProp({ + type: definePropType(Object), + default: () => mutable(EMPTY_NODE), + } as const), + expanded: buildProp({ + type: Boolean, + default: false, + }), + checked: buildProp({ + type: Boolean, + default: false, + }), + indeterminate: buildProp({ + type: Boolean, + default: false, + }), + showCheckbox: buildProp({ + type: Boolean, + default: false, + }), + disabled: buildProp({ + type: Boolean, + default: false, + }), + current: buildProp({ + type: Boolean, + default: false, + }), + hiddenExpandIcon: buildProp({ + type: Boolean, + default: false, + }), +} as const + +export const treeNodeContentProps = { + node: buildProp({ + type: definePropType(Object), + required: true, + } as const), +} as const + +// emits +export const NODE_CLICK = 'node-click' +export const NODE_EXPAND = 'node-expand' +export const NODE_COLLAPSE = 'node-collapse' +export const CURRENT_CHANGE = 'current-change' +export const NODE_CHECK = 'check' +export const NODE_CHECK_CHANGE = 'check-change' +export const NODE_CONTEXTMENU = 'node-contextmenu' + +export const treeEmits = { + [NODE_CLICK]: (data: TreeNodeData, node: TreeNode) => data && node, + [NODE_EXPAND]: (data: TreeNodeData, node: TreeNode) => data && node, + [NODE_COLLAPSE]: (data: TreeNodeData, node: TreeNode) => data && node, + [CURRENT_CHANGE]: (data: TreeNodeData, node: TreeNode) => data && node, + [NODE_CHECK]: (data: TreeNodeData, checkedInfo: CheckedInfo) => + data && checkedInfo, + [NODE_CHECK_CHANGE]: (data: TreeNodeData, checked: boolean) => + data && typeof checked === 'boolean', + [NODE_CONTEXTMENU]: (event: Event, data: TreeNodeData, node: TreeNode) => + event && data && node, +} + +export const treeNodeEmits = { + click: (node: TreeNode) => !!node, + toggle: (node: TreeNode) => !!node, + check: (node: TreeNode, checked: boolean) => + node && typeof checked === 'boolean', +} diff --git a/packages/components/tree-v2/style/css.ts b/packages/components/tree-v2/style/css.ts new file mode 100644 index 0000000000..9f1f148777 --- /dev/null +++ b/packages/components/tree-v2/style/css.ts @@ -0,0 +1,3 @@ +import '@element-plus/components/base/style/css' +import '@element-plus/theme-chalk/el-tree.css' +import '@element-plus/components/checkbox/style/css' diff --git a/packages/components/tree-v2/style/index.ts b/packages/components/tree-v2/style/index.ts new file mode 100644 index 0000000000..e8a9962f0f --- /dev/null +++ b/packages/components/tree-v2/style/index.ts @@ -0,0 +1,3 @@ +import '@element-plus/components/base/style' +import '@element-plus/theme-chalk/src/tree.scss' +import '@element-plus/components/checkbox/style/index' diff --git a/packages/element-plus/component.ts b/packages/element-plus/component.ts index 6d19b3953d..e5e23b563b 100644 --- a/packages/element-plus/component.ts +++ b/packages/element-plus/component.ts @@ -93,6 +93,7 @@ import { ElTimeline, ElTimelineItem } from '@element-plus/components/timeline' import { ElTooltip } from '@element-plus/components/tooltip' import { ElTransfer } from '@element-plus/components/transfer' import { ElTree } from '@element-plus/components/tree' +import { ElTreeV2 } from '@element-plus/components/tree-v2' import { ElUpload } from '@element-plus/components/upload' export default [ @@ -184,5 +185,6 @@ export default [ ElTooltip, ElTransfer, ElTree, + ElTreeV2, ElUpload, ] diff --git a/packages/element-plus/global.d.ts b/packages/element-plus/global.d.ts index 3d755f3c1d..26d862bd7c 100644 --- a/packages/element-plus/global.d.ts +++ b/packages/element-plus/global.d.ts @@ -81,6 +81,7 @@ declare module 'vue' { ElTooltip: typeof import('element-plus')['ElTooltip'] ElTransfer: typeof import('element-plus')['ElTransfer'] ElTree: typeof import('element-plus')['ElTree'] + ElTreeV2: typeof import('element-plus')['ElTreeV2'] ElUpload: typeof import('element-plus')['ElUpload'] ElSpace: typeof import('element-plus')['ElSpace'] ElSkeleton: typeof import('element-plus')['ElSkeleton'] diff --git a/packages/theme-chalk/src/tree.scss b/packages/theme-chalk/src/tree.scss index a11d3badcb..0be63b911a 100644 --- a/packages/theme-chalk/src/tree.scss +++ b/packages/theme-chalk/src/tree.scss @@ -63,7 +63,7 @@ cursor: pointer; & > .#{$namespace}-tree-node__expand-icon { - padding: 6px; + margin: 6px; } & > label.#{$namespace}-checkbox { margin-right: 8px; @@ -101,6 +101,9 @@ color: transparent; cursor: default; } + &.is-hidden { + visibility: hidden; + } } @include e(label) {