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 <sxzz@sxzz.moe>
This commit is contained in:
msidolphin
2021-09-27 11:12:50 +08:00
committed by GitHub
parent 7ee4f44e22
commit 2d2878eb53
25 changed files with 2732 additions and 2 deletions

View File

@@ -1,3 +1,4 @@
{
"MD033": false
"MD033": false,
"MD013": false
}

View File

@@ -205,6 +205,10 @@
{
"link": "/tree",
"text": "Tree"
},
{
"link": "/tree-v2",
"text": "Virtualized Tree"
}
]
},

View File

@@ -0,0 +1,126 @@
# <ElBadge value="beta">Tree V2 virtualized tree</ElBadge>
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 } |

View File

@@ -0,0 +1,44 @@
<template>
<el-tree-v2 :data="data" :props="props" :height="208"></el-tree-v2>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
const getKey = (prefix, id) => {
return `${prefix}-${id}`
}
const createData = (
maxDeep,
maxChildren,
minNodesNumber,
deep = 1,
key = 'node'
) => {
let id = 0
return new Array(minNodesNumber).fill(deep).map(() => {
const childrenNumber =
deep === maxDeep ? 0 : Math.round(Math.random() * maxChildren)
const nodeKey = getKey(key, ++id)
return {
id: nodeKey,
label: nodeKey,
children: childrenNumber
? createData(maxDeep, maxChildren, childrenNumber, deep + 1, nodeKey)
: undefined,
}
})
}
export default defineComponent({
setup() {
return {
data: createData(4, 30, 40),
props: ref({
id: 'id',
label: 'label',
children: 'children',
}),
}
},
})
</script>

View File

@@ -0,0 +1,61 @@
<template>
<el-tree-v2 :data="data" :props="props" :height="208">
<template #default="{ node }">
<span class="prefix" :class="{ 'is-leaf': node.isLeaf }"
>[ElementPlus]</span
>
<span>{{ node.label }}</span>
</template>
</el-tree-v2>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
const getKey = (prefix, id) => {
return `${prefix}-${id}`
}
const createData = (
maxDeep,
maxChildren,
minNodesNumber,
deep = 1,
key = 'node'
) => {
let id = 0
return new Array(minNodesNumber).fill(deep).map(() => {
const childrenNumber =
deep === maxDeep ? 0 : Math.round(Math.random() * maxChildren)
const nodeKey = getKey(key, ++id)
return {
id: nodeKey,
label: nodeKey,
children: childrenNumber
? createData(maxDeep, maxChildren, childrenNumber, deep + 1, nodeKey)
: undefined,
}
})
}
export default defineComponent({
setup() {
return {
data: createData(4, 30, 40),
props: ref({
id: 'id',
label: 'label',
children: 'children',
}),
}
},
})
</script>
<style lang="scss" scoped>
.prefix {
color: var(--el-color-primary);
margin-right: 10px;
&.is-leaf {
color: var(--el-color-success);
}
}
</style>

View File

@@ -0,0 +1,64 @@
<template>
<el-tree-v2
:data="data"
:height="208"
:props="props"
show-checkbox
:default-checked-keys="defaultCheckedKeys"
:default-expanded-keys="defaultExpandedKeys"
></el-tree-v2>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
const getKey = (prefix, id) => {
return `${prefix}-${id}`
}
const createData = (
maxDeep,
maxChildren,
minNodesNumber,
deep = 1,
key = 'node'
) => {
let id = 0
return new Array(minNodesNumber).fill(deep).map(() => {
const childrenNumber =
deep === maxDeep ? 0 : Math.round(Math.random() * maxChildren)
const nodeKey = getKey(key, ++id)
return {
id: nodeKey,
label: nodeKey,
children: childrenNumber
? createData(maxDeep, maxChildren, childrenNumber, deep + 1, nodeKey)
: undefined,
}
})
}
export default defineComponent({
setup() {
const data = createData(4, 30, 40)
const checkedKeys: any[] = []
const expanedKeys: any[] = []
for (let i = 0; i < data.length; ++i) {
const children = data[i].children
if (children) {
expanedKeys.push(data[i].id)
checkedKeys.push(children[0].id)
break
}
}
return {
data,
props: ref({
id: 'id',
label: 'label',
children: 'children',
}),
defaultCheckedKeys: checkedKeys,
defaultExpandedKeys: expanedKeys,
}
},
})
</script>

View File

@@ -0,0 +1,51 @@
<template>
<el-tree-v2
:data="data"
:props="props"
show-checkbox
:height="208"
></el-tree-v2>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
const getKey = (prefix, id) => {
return `${prefix}-${id}`
}
const createData = (
maxDeep,
maxChildren,
minNodesNumber,
deep = 1,
key = 'node'
) => {
let id = 0
return new Array(minNodesNumber).fill(deep).map(() => {
const childrenNumber =
deep === maxDeep ? 0 : Math.round(Math.random() * maxChildren)
const nodeKey = getKey(key, ++id)
return {
id: nodeKey,
label: nodeKey,
disabled: Math.random() > 0.6,
children: childrenNumber
? createData(maxDeep, maxChildren, childrenNumber, deep + 1, nodeKey)
: undefined,
}
})
}
export default defineComponent({
setup() {
return {
data: createData(4, 30, 40),
props: ref({
id: 'id',
label: 'label',
children: 'children',
disabled: 'disabled',
}),
}
},
})
</script>

View File

@@ -0,0 +1,65 @@
<template>
<el-input
v-model="query"
placeholder="Please enter keyword"
@input="onQueryChanged"
></el-input>
<el-tree-v2
ref="treeRef"
:data="data"
:props="props"
:filter-method="filterMethod"
:height="208"
></el-tree-v2>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
const getKey = (prefix, id) => {
return `${prefix}-${id}`
}
const createData = (
maxDeep,
maxChildren,
minNodesNumber,
deep = 1,
key = 'node'
) => {
let id = 0
return new Array(minNodesNumber).fill(deep).map(() => {
const childrenNumber =
deep === maxDeep ? 0 : Math.round(Math.random() * maxChildren)
const nodeKey = getKey(key, ++id)
return {
id: nodeKey,
label: nodeKey,
children: childrenNumber
? createData(maxDeep, maxChildren, childrenNumber, deep + 1, nodeKey)
: undefined,
}
})
}
export default defineComponent({
setup() {
const treeRef = ref(null)
return {
treeRef,
data: createData(4, 30, 5),
query: ref(''),
props: ref({
id: 'id',
label: 'label',
children: 'children',
}),
onQueryChanged(query) {
const tree = treeRef.value as any
tree?.filter(query)
},
filterMethod(query, node) {
return node.label.indexOf(query) !== -1
},
}
},
})
</script>

View File

@@ -0,0 +1,49 @@
<template>
<el-tree-v2
:data="data"
:props="props"
show-checkbox
:height="208"
></el-tree-v2>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
const getKey = (prefix, id) => {
return `${prefix}-${id}`
}
const createData = (
maxDeep,
maxChildren,
minNodesNumber,
deep = 1,
key = 'node'
) => {
let id = 0
return new Array(minNodesNumber).fill(deep).map(() => {
const childrenNumber =
deep === maxDeep ? 0 : Math.round(Math.random() * maxChildren)
const nodeKey = getKey(key, ++id)
return {
id: nodeKey,
label: nodeKey,
children: childrenNumber
? createData(maxDeep, maxChildren, childrenNumber, deep + 1, nodeKey)
: undefined,
}
})
}
export default defineComponent({
setup() {
return {
data: createData(4, 30, 40),
props: ref({
id: 'id',
label: 'label',
children: 'children',
}),
}
},
})
</script>

View File

@@ -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

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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<Tree | undefined>) {
const checkedKeys = ref<Set<TreeKey>>(new Set())
const indeterminateKeys = ref<Set<TreeKey>>(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<TreeKey>()
// 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,
}
}

View File

@@ -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<Tree | undefined>) {
const hiddenNodeKeySet = ref<Set<TreeKey>>(new Set([]))
const hiddenExpandIconKeySet = ref<Set<TreeKey>>(new Set([]))
const filterable = computed(() => {
return isFunction(props.filterMethod)
})
function doFilter(query: string) {
if (!filterable.value) {
return
}
const expandKeySet = new Set<TreeKey>()
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,
}
}

View File

@@ -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<Set<TreeKey>>(new Set(props.defaultExpandedKeys))
const currentKey = ref<TreeKey | undefined>()
const tree = shallowRef<Tree | undefined>()
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<TreeKey, TreeNode> = new Map()
const levelTreeNodeMap: Map<number, TreeNode[]> = 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,
}
}

View File

@@ -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])
}
},
})

View File

@@ -0,0 +1,113 @@
<template>
<div
ref="node$"
class="el-tree-node"
:class="{
'is-expanded': expanded,
'is-current': current,
'is-focusable': !disabled,
'is-checked': !disabled && checked,
}"
role="treeitem"
tabindex="-1"
:aria-expanded="expanded"
:aria-disabled="disabled"
:aria-checked="checked"
:data-key="node?.key"
@click.stop="handleClick"
@contextmenu="handleContextMenu"
>
<div
class="el-tree-node__content"
:style="{ paddingLeft: `${(node.level - 1) * indent}px` }"
>
<el-icon
:class="[
{
'is-leaf': node?.isLeaf,
'is-hidden': hiddenExpandIcon,
expanded: !node?.isLeaf && expanded,
},
'el-tree-node__expand-icon',
]"
@click.stop="handleExpandIconClick"
>
<component :is="icon" />
</el-icon>
<el-checkbox
v-if="showCheckbox"
:model-value="checked"
:indeterminate="indeterminate"
:disabled="disabled"
@change="handleCheckChange"
@click.stop
/>
<el-node-content :node="node" />
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, inject } from 'vue'
import { CaretRight } from '@element-plus/icons'
import ElIcon from '@element-plus/components/icon'
import ElCheckbox from '@element-plus/components/checkbox'
import ElNodeContent from './tree-node-content'
import {
ROOT_TREE_INJECTION_KEY,
NODE_CONTEXTMENU,
treeNodeEmits,
treeNodeProps,
} from './virtual-tree'
const DEFAULT_ICON = 'caret-right'
export default defineComponent({
name: 'ElTreeNode',
components: {
ElIcon,
CaretRight,
ElCheckbox,
ElNodeContent,
},
props: treeNodeProps,
emits: treeNodeEmits,
setup(props, { emit }) {
const tree = inject(ROOT_TREE_INJECTION_KEY)
const indent = computed(() => {
return tree?.props.indent || 16
})
const icon = computed(() => {
return tree?.props.icon ? tree.props.icon : DEFAULT_ICON
})
const handleClick = () => {
emit('click', props.node)
}
const handleExpandIconClick = () => {
emit('toggle', props.node)
}
const handleCheckChange = (value: boolean) => {
emit('check', props.node, value)
}
const handleContextMenu = (event: Event) => {
if (tree?.instance?.vnode?.props?.['onNodeContextmenu']) {
event.stopPropagation()
event.preventDefault()
}
tree?.ctx.emit(NODE_CONTEXTMENU, event, props.node?.data, props.node)
}
return {
indent,
icon,
handleClick,
handleExpandIconClick,
handleCheckChange,
handleContextMenu,
}
},
})
</script>

View File

@@ -0,0 +1,127 @@
<template>
<div
class="el-tree"
:class="{
'el-tree--highlight-current': highlightCurrent,
}"
role="tree"
>
<fixed-size-list
v-if="isNotEmpty"
class-name="el-tree-virtual-list"
:data="flattenTree"
:total="flattenTree.length"
:height="height"
:item-size="itemSize"
:perf-mode="perfMode"
>
<template #default="{ data, index, style }">
<el-tree-node
:key="data[index].key"
:style="style"
:node="data[index]"
:expanded="isExpanded(data[index])"
:show-checkbox="showCheckbox"
:checked="isChecked(data[index])"
:indeterminate="isIndeterminate(data[index])"
:disabled="isDisabled(data[index])"
:current="isCurrent(data[index])"
:hidden-expand-icon="isForceHiddenExpandIcon(data[index])"
@click="handleNodeClick"
@toggle="toggleExpand"
@check="handleNodeCheck"
></el-tree-node>
</template>
</fixed-size-list>
<div v-else class="el-tree__empty-block">
<span class="el-tree__empty-text">{{
emptyText || t('el.tree.emptyText')
}}</span>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, getCurrentInstance, provide } from 'vue'
import { useLocaleInject } from '@element-plus/hooks'
import { FixedSizeList } from '@element-plus/components/virtual-list'
import { useTree } from './composables/useTree'
import ElTreeNode from './tree-node.vue'
import { ROOT_TREE_INJECTION_KEY, treeEmits, treeProps } from './virtual-tree'
import type { TreeProps } from './types'
export default defineComponent({
name: 'ElTreeV2',
components: {
ElTreeNode,
FixedSizeList,
},
props: treeProps,
emits: treeEmits,
setup(props: TreeProps, ctx) {
provide(ROOT_TREE_INJECTION_KEY, {
ctx,
props,
instance: getCurrentInstance(),
})
const { t } = useLocaleInject()
const {
flattenTree,
isNotEmpty,
toggleExpand,
isExpanded,
isIndeterminate,
isChecked,
isDisabled,
isCurrent,
isForceHiddenExpandIcon,
toggleCheckbox,
handleNodeClick,
handleNodeCheck,
// expose
getCurrentNode,
getCurrentKey,
setCurrentKey,
getCheckedKeys,
getCheckedNodes,
getHalfCheckedKeys,
getHalfCheckedNodes,
setChecked,
setCheckedKeys,
filter,
setData,
} = useTree(props, ctx.emit)
ctx.expose({
getCurrentNode,
getCurrentKey,
setCurrentKey,
getCheckedKeys,
getCheckedNodes,
getHalfCheckedKeys,
getHalfCheckedNodes,
setChecked,
setCheckedKeys,
filter,
setData,
})
return {
t,
flattenTree,
itemSize: 26,
isNotEmpty,
toggleExpand,
toggleCheckbox,
isExpanded,
isIndeterminate,
isChecked,
isDisabled,
isCurrent,
isForceHiddenExpandIcon,
handleNodeClick,
handleNodeCheck,
}
},
})
</script>

View File

@@ -0,0 +1,54 @@
import type {
ComponentInternalInstance,
SetupContext,
ExtractPropTypes,
} from 'vue'
import type { treeProps, treeEmits } from './virtual-tree'
export type TreeNodeData = Record<string, any>
export type TreeData = TreeNodeData[]
export type TreeKey = string | number
export interface TreeOptionProps {
children?: string
label?: string
value?: string
disabled?: string
}
export type TreeProps = ExtractPropTypes<typeof treeProps>
export interface TreeNode {
key: TreeKey
level: number
parent?: TreeNode
children?: TreeNode[]
data: TreeNodeData
disabled?: boolean
label?: string
isLeaf?: boolean
}
export interface TreeContext {
ctx: SetupContext<typeof treeEmits>
instance: ComponentInternalInstance
props: TreeProps
}
export interface Tree {
treeNodeMap: Map<TreeKey, TreeNode>
levelTreeNodeMap: Map<number, TreeNode[]>
treeNodes: TreeNode[]
maxLevel: number
}
export type FilterMethod = (query: string, node: TreeNodeData) => boolean
export interface CheckedInfo {
checkedKeys: TreeKey[]
checkedNodes: TreeData
halfCheckedKeys: TreeKey[]
halfCheckedNodes: TreeData
}

View File

@@ -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<TreeContext> = 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<TreeData>(Array),
default: () => mutable([] as const),
} as const),
emptyText: buildProp({
type: String,
}),
height: buildProp({
type: Number,
default: 200,
}),
props: buildProp({
type: definePropType<TreeOptionProps>(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<TreeKey[]>(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<TreeKey[]>(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<TreeKey>([String, Number]),
} as const),
// TODO need to optimization
accordion: buildProp({
type: Boolean,
default: false,
}),
filterMethod: buildProp({
type: definePropType<FilterMethod>(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<TreeNode>(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<TreeNode>(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',
}

View File

@@ -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'

View File

@@ -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'

View File

@@ -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,
]

View File

@@ -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']

View File

@@ -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) {