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 @@
+
+
+
+ [ElementPlus]
+ {{ node.label }}
+
+
+
+
+
+
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 &&
+ `${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 @@
+
+
+
+
+
+
+
+
+ {{
+ emptyText || t('el.tree.emptyText')
+ }}
+
+
+
+
+
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) {