You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

Element Plus El-Tree组件键盘导航实现问题:节点扩展折叠与焦点控制

Element Plus El-Tree组件键盘导航实现问题:节点扩展折叠与焦点控制

我完全理解你在Element Plus El-Tree键盘可访问性上遇到的麻烦——官方Demo里的Tree明明自带完整的键盘导航,但自己项目用2.12.0版本时却完全没效果,手动实现还踩了expandNode/collapseNode不存在、复选框和节点焦点分离的坑,这确实挺闹心的。我帮你梳理下问题根源,再给出完整的修正方案:

一、先解决实例方法名错误的核心问题

你代码里调用的expandNodecollapseNode是错的!Element Plus El-Tree的官方实例方法里,展开节点是expand,折叠是collapse,这俩方法接收的参数是节点实例(不是节点data),这也是功能没生效的核心原因之一。

正确的调用方式应该是:

const node = tree.getNode(currentKey)
if (node) {
  tree.expand(node, true) // 展开节点
  // tree.collapse(node, true) // 折叠节点
}

二、阻止复选框单独获取焦点

El-Tree的复选框默认是可聚焦的,这会导致键盘焦点在节点和复选框之间来回跳,破坏导航体验。我们可以通过自定义节点插槽,给复选框添加tabindex="-1",让它无法被键盘焦点选中,同时保留点击功能。

三、修正“可见节点列表”的逻辑

你之前的buildVisibleList会遍历所有节点(包括未展开的子节点),导致上下箭头导航时会跳到未展开的节点,这不符合预期。我们需要修改这个方法,只收集当前真正可见的节点(即父节点已展开的节点)。

四、完整修正后的代码

把所有修改整合起来,最终的代码如下:

<template>
  <div ref="wrap" tabindex="0" @keydown="onKeydown" class="tree-wrap">
    <el-tree
      ref="treeRef"
      :data="data"
      :props="defaultProps"
      node-key="id"
      show-checkbox
      :check-strictly="false"
    >
      <template #default="{ node, data }">
        <span class="tree-node-label">
          <!-- 给复选框添加tabindex="-1",阻止其获取键盘焦点 -->
          <el-checkbox
            v-if="node.showCheckbox"
            :model-value="node.checked"
            :indeterminate="node.indeterminate"
            tabindex="-1"
            @change="handleCheckboxChange(node, data)"
          />
          <span>{{ node.label }}</span>
        </span>
      </template>
    </el-tree>
  </div>
</template>

<script>
export default {
  data() {
    return {
      defaultProps: { children: 'children', label: 'label' },
      data: [
        {
          id: 1,
          label: 'Level one 1',
          children: [
            {
              id: 4,
              label: 'Level two 1-1',
              children: [
                { id: 9, label: 'Level three 1-1-1' },
                { id: 10, label: 'Level three 1-1-2' }
              ]
            }
          ]
        },
        {
          id: 2,
          label: 'Level one 2',
          children: [
            { id: 5, label: 'Level two 2-1' },
            { id: 6, label: 'Level two 2-2' }
          ]
        },
        {
          id: 3,
          label: 'Level one 3',
          children: [
            { id: 7, label: 'Level two 3-1' },
            { id: 8, label: 'Level two 3-2' }
          ]
        }
      ]
    }
  },
  mounted() {
    // 初始聚焦到容器
    this.$refs.wrap?.focus()
    // 初始选中第一个节点
    this.$refs.treeRef?.setCurrentKey(this.data[0].id)
  },
  methods: {
    // 同步复选框与Tree的选中状态
    handleCheckboxChange(node) {
      const tree = this.$refs.treeRef
      tree.setChecked(node.key, !node.checked, false)
    },
    // 构建当前可见的节点列表(仅展开的父节点下的节点)
    buildVisibleList() {
      const visible = []
      const traverse = (nodes, parentTreeNode) => {
        if (!nodes) return
        for (const node of nodes) {
          const currentTreeNode = this.$refs.treeRef.getNode(node.id)
          // 父节点展开或无父节点时,当前节点可见
          if (!parentTreeNode || parentTreeNode.expanded) {
            visible.push({ key: node.id, treeNode: currentTreeNode })
          }
          // 当前节点展开时,才遍历子节点
          if (node.children?.length && currentTreeNode?.expanded) {
            traverse(node.children, currentTreeNode)
          }
        }
      }
      traverse(this.data)
      return visible
    },
    // 根据索引聚焦节点
    focusNodeByIndex(list, idx) {
      const item = list[idx]
      if (!item || !item.treeNode) return
      const tree = this.$refs.treeRef
      // 设置当前节点
      tree.setCurrentKey(item.key)
      // 滚动到当前节点(可选,提升体验)
      tree.scrollToCurrentNode()
    },
    // 键盘事件处理
    onKeydown(e) {
      const tree = this.$refs.treeRef
      if (!tree) return
      const visibleNodes = this.buildVisibleList()
      const currentKey = tree.getCurrentKey()
      const currentIndex = visibleNodes.findIndex(item => item.key === currentKey)

      switch (e.key) {
        case 'ArrowDown':
          e.preventDefault()
          // 循环到第一个节点
          const nextIndex = currentIndex >= visibleNodes.length - 1 ? 0 : currentIndex + 1
          this.focusNodeByIndex(visibleNodes, nextIndex)
          break
        case 'ArrowUp':
          e.preventDefault()
          // 循环到最后一个节点
          const prevIndex = currentIndex <= 0 ? visibleNodes.length - 1 : currentIndex - 1
          this.focusNodeByIndex(visibleNodes, prevIndex)
          break
        case 'ArrowRight':
          e.preventDefault()
          const currentNode = tree.getNode(currentKey)
          // 仅当节点有子节点且未展开时,展开
          if (currentNode?.children?.length && !currentNode.expanded) {
            tree.expand(currentNode, true)
          }
          break
        case 'ArrowLeft':
          e.preventDefault()
          const currentCollapseNode = tree.getNode(currentKey)
          // 仅当节点有子节点且已展开时,折叠
          if (currentCollapseNode?.children?.length && currentCollapseNode.expanded) {
            tree.collapse(currentCollapseNode, true)
          }
          break
        case 'Enter':
        case ' ':
          e.preventDefault()
          // 切换当前节点的选中状态
          const targetNode = tree.getNode(currentKey)
          if (targetNode) {
            tree.setChecked(targetNode.key, !targetNode.checked, false)
          }
          break
      }
    }
  }
}
</script>

<style scoped>
.tree-wrap {
  outline: none; /* 移除默认聚焦边框,可选 */
}
.tree-node-label {
  display: flex;
  align-items: center;
  gap: 8px;
}
/* 可选:自定义当前选中节点的样式,提升可见性 */
.el-tree-node.is-current > .el-tree-node__content {
  background-color: #e6f7ff;
}
</style>

五、关键修改说明

  1. 修正实例方法:把错误的expandNode/collapseNode替换为Element Plus官方的expand/collapse方法,直接传入getNode(key)返回的节点实例。
  2. 阻止复选框聚焦:通过自定义节点插槽给复选框加tabindex="-1",确保键盘焦点只在节点间移动,不会跳到复选框。
  3. 可见节点逻辑优化buildVisibleList现在只收集展开父节点下的节点,上下导航完全符合用户预期。
  4. 循环导航处理:上下箭头到达边界时会循环到列表的另一端,提升用户体验。
  5. 滚动到视图:添加scrollToCurrentNode(),确保选中的节点始终在可视区域内。

最后验证效果

现在你可以测试这些键盘操作:

  • ⬇️/⬆️:在所有可见节点间循环导航
  • ➡️:展开当前选中的父节点
  • ⬅️:折叠当前选中的父节点
  • Enter/空格:切换当前节点的选中状态
  • 焦点始终保持在Tree容器或节点上,不会跳到复选框

这样应该就能完全解决你遇到的所有问题了,如果还有其他细节需要调整,比如节点样式、循环逻辑,都可以根据需求再微调~

火山引擎 最新活动