mirror of
https://github.com/element-plus/element-plus.git
synced 2024-12-27 03:01:14 +08:00
fix(tree): fix keyboard navigation bug (#995)
* fix(tree): fix keyboard navigation bug * test(tree): add navigate test case
This commit is contained in:
parent
e93a728872
commit
eb5a04de6c
@ -1,7 +1,7 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { nextTick } from 'vue'
|
import { nextTick } from 'vue'
|
||||||
import Tree from '../src/tree.vue'
|
import Tree from '../src/tree.vue'
|
||||||
import { sleep } from '@element-plus/test-utils'
|
import { sleep, defineGetter } from '@element-plus/test-utils'
|
||||||
|
|
||||||
const ALL_NODE_COUNT = 9
|
const ALL_NODE_COUNT = 9
|
||||||
|
|
||||||
@ -836,4 +836,212 @@ describe('Tree.vue', () => {
|
|||||||
expect(treeWrappers[1].vm.getNode(4).data).toEqual(nodeData)
|
expect(treeWrappers[1].vm.getNode(4).data).toEqual(nodeData)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('navigate with defaultExpandAll', () => {
|
||||||
|
const { wrapper } = getTreeVm(``, {
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<el-tree default-expand-all ref="tree1" :data="data" node-key="id" :props="defaultProps"></el-tree>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
const tree = wrapper.findComponent({ name: 'ElTree' })
|
||||||
|
expect(Object.values(tree.vm.store.nodesMap).filter(item => item.canFocus).length).toBe(9)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('navigate up', async () => {
|
||||||
|
const { wrapper } = getTreeVm(``, {
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<el-tree ref="tree1" :data="data" node-key="id" :props="defaultProps"></el-tree>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
let flag = false
|
||||||
|
function handleFocus(){
|
||||||
|
return () => (flag = true)
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
const tree = wrapper.findComponent({ name: 'ElTree' })
|
||||||
|
const targetElement = wrapper.find('div[data-key="3"]').element
|
||||||
|
const fromElement = wrapper.find('div[data-key="1"]').element
|
||||||
|
defineGetter(targetElement, 'focus', handleFocus)
|
||||||
|
tree.vm.setCurrentKey(1)
|
||||||
|
expect(fromElement.classList.contains('is-focusable')).toBeTruthy()
|
||||||
|
fromElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'ArrowUp', bubbles: true, cancelable: false }))
|
||||||
|
expect(flag).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('navigate down', async () => {
|
||||||
|
const { wrapper } = getTreeVm(``, {
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<el-tree ref="tree1" :data="data" node-key="id" :props="defaultProps"></el-tree>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
let flag = false
|
||||||
|
function handleFocus(){
|
||||||
|
return () => (flag = true)
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
const tree = wrapper.findComponent({ name: 'ElTree' })
|
||||||
|
const targetElement = wrapper.find('div[data-key="2"]').element
|
||||||
|
const fromElement = wrapper.find('div[data-key="1"]').element
|
||||||
|
defineGetter(targetElement, 'focus', handleFocus)
|
||||||
|
tree.vm.setCurrentKey(1)
|
||||||
|
expect(fromElement.classList.contains('is-focusable')).toBeTruthy()
|
||||||
|
fromElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'ArrowDown', bubbles: true, cancelable: false }))
|
||||||
|
expect(flag).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('navigate with disabled', async () => {
|
||||||
|
const wrapper = mount( {
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<el-tree ref="tree1" :data="data" node-key="id" :props="defaultProps"></el-tree>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
components: {
|
||||||
|
'el-tree': Tree,
|
||||||
|
},
|
||||||
|
data(){
|
||||||
|
return {
|
||||||
|
data: [{
|
||||||
|
id: 1,
|
||||||
|
label: '一级 1',
|
||||||
|
children: [{
|
||||||
|
id: 11,
|
||||||
|
label: '二级 1-1',
|
||||||
|
children: [{
|
||||||
|
id: 111,
|
||||||
|
label: '三级 1-1',
|
||||||
|
disabled: true,
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
}, {
|
||||||
|
id: 2,
|
||||||
|
label: '一级 2',
|
||||||
|
disabled: true,
|
||||||
|
children: [{
|
||||||
|
id: 21,
|
||||||
|
label: '二级 2-1',
|
||||||
|
}, {
|
||||||
|
id: 22,
|
||||||
|
label: '二级 2-2',
|
||||||
|
}],
|
||||||
|
}, {
|
||||||
|
id: 3,
|
||||||
|
label: '一级 3',
|
||||||
|
children: [{
|
||||||
|
id: 31,
|
||||||
|
label: '二级 3-1',
|
||||||
|
}, {
|
||||||
|
id: 32,
|
||||||
|
label: '二级 3-2',
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
defaultProps: {
|
||||||
|
children: 'children',
|
||||||
|
label: 'label',
|
||||||
|
disabled: 'disabled',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
let flag = false
|
||||||
|
function handleFocus(){
|
||||||
|
return () => (flag = true)
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
const tree = wrapper.findComponent({ name: 'ElTree' })
|
||||||
|
const targetElement = wrapper.find('div[data-key="3"]').element
|
||||||
|
const fromElement = wrapper.find('div[data-key="1"]').element
|
||||||
|
defineGetter(targetElement, 'focus', handleFocus)
|
||||||
|
tree.vm.setCurrentKey(1)
|
||||||
|
expect(fromElement.classList.contains('is-focusable')).toBeTruthy()
|
||||||
|
fromElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'ArrowDown', bubbles: true, cancelable: false }))
|
||||||
|
expect(flag).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('navigate with lazy and without node-key', async () => {
|
||||||
|
const wrapper = mount( {
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<el-tree
|
||||||
|
:props="defaultProps"
|
||||||
|
:load="loadNode"
|
||||||
|
lazy
|
||||||
|
show-checkbox>
|
||||||
|
</el-tree>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
components: {
|
||||||
|
'el-tree': Tree,
|
||||||
|
},
|
||||||
|
data(){
|
||||||
|
return {
|
||||||
|
defaultProps: {
|
||||||
|
children: 'children',
|
||||||
|
label: 'label',
|
||||||
|
disabled: 'disabled',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
loadNode(node, resolve) {
|
||||||
|
if (node.level === 0) {
|
||||||
|
return resolve([{ name: 'region1' }, { name: 'region2' }])
|
||||||
|
}
|
||||||
|
if (node.level > 3) return resolve([])
|
||||||
|
|
||||||
|
let hasChild
|
||||||
|
if (node.data.name === 'region1') {
|
||||||
|
hasChild = true
|
||||||
|
} else if (node.data.name === 'region2') {
|
||||||
|
hasChild = false
|
||||||
|
} else {
|
||||||
|
hasChild = false
|
||||||
|
}
|
||||||
|
|
||||||
|
let data
|
||||||
|
if (hasChild) {
|
||||||
|
data = [{
|
||||||
|
name: 'zone' + this.count++,
|
||||||
|
}, {
|
||||||
|
name: 'zone' + this.count++,
|
||||||
|
}]
|
||||||
|
} else {
|
||||||
|
data = []
|
||||||
|
}
|
||||||
|
resolve(data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
let flag = false
|
||||||
|
function handleFocus(){
|
||||||
|
return () => (flag = !flag)
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
const tree = wrapper.findComponent({ name: 'ElTree' })
|
||||||
|
const originElements = wrapper.findAll('div[data-key]')
|
||||||
|
const region1 = originElements[0].element
|
||||||
|
const region2 = originElements[1].element
|
||||||
|
defineGetter(region2, 'focus', handleFocus)
|
||||||
|
// expand
|
||||||
|
region1.dispatchEvent(new MouseEvent('click'))
|
||||||
|
expect(region1.classList.contains('is-focusable')).toBeTruthy()
|
||||||
|
await sleep(100)
|
||||||
|
expect(Object.values(tree.vm.store.nodesMap.length === 4)).toBeTruthy()
|
||||||
|
expect(Object.values(Object.values(tree.vm.store.nodesMap).filter(item => item.canFocus).length === 4)).toBeTruthy()
|
||||||
|
// collapse
|
||||||
|
region1.dispatchEvent(new MouseEvent('click'))
|
||||||
|
expect(Object.values(Object.values(tree.vm.store.nodesMap).filter(item => item.canFocus).length === 2)).toBeTruthy()
|
||||||
|
// ArrowDown, region2 focus
|
||||||
|
region1.dispatchEvent(new KeyboardEvent('keydown', { code: 'ArrowDown', bubbles: true, cancelable: false }))
|
||||||
|
expect(flag).toBe(true)
|
||||||
|
defineGetter(region1, 'focus', handleFocus)
|
||||||
|
// ArrowDown, region1 focus
|
||||||
|
region2.dispatchEvent(new KeyboardEvent('keydown', { code: 'ArrowDown', bubbles: true, cancelable: false }))
|
||||||
|
expect(flag).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -83,6 +83,7 @@ export default class Node {
|
|||||||
store: TreeStore;
|
store: TreeStore;
|
||||||
isLeafByUser: boolean;
|
isLeafByUser: boolean;
|
||||||
isLeaf: boolean;
|
isLeaf: boolean;
|
||||||
|
canFocus: boolean;
|
||||||
|
|
||||||
level: number;
|
level: number;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
@ -99,6 +100,7 @@ export default class Node {
|
|||||||
this.parent = null
|
this.parent = null
|
||||||
this.visible = true
|
this.visible = true
|
||||||
this.isCurrent = false
|
this.isCurrent = false
|
||||||
|
this.canFocus = false
|
||||||
|
|
||||||
for (const name in options) {
|
for (const name in options) {
|
||||||
if (options.hasOwnProperty(name)) {
|
if (options.hasOwnProperty(name)) {
|
||||||
@ -135,6 +137,7 @@ export default class Node {
|
|||||||
|
|
||||||
if (store.defaultExpandAll) {
|
if (store.defaultExpandAll) {
|
||||||
this.expanded = true
|
this.expanded = true
|
||||||
|
this.canFocus = true
|
||||||
}
|
}
|
||||||
} else if (this.level > 0 && store.lazy && store.defaultExpandAll) {
|
} else if (this.level > 0 && store.lazy && store.defaultExpandAll) {
|
||||||
this.expand()
|
this.expand()
|
||||||
@ -161,6 +164,7 @@ export default class Node {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.updateLeafState()
|
this.updateLeafState()
|
||||||
|
if(this.parent && (this.level === 1 || this.parent.expanded === true)) this.canFocus = true
|
||||||
}
|
}
|
||||||
|
|
||||||
setData(data: TreeNodeData): void {
|
setData(data: TreeNodeData): void {
|
||||||
@ -323,6 +327,9 @@ export default class Node {
|
|||||||
}
|
}
|
||||||
this.expanded = true
|
this.expanded = true
|
||||||
if (callback) callback()
|
if (callback) callback()
|
||||||
|
this.childNodes.forEach(item => {
|
||||||
|
item.canFocus = true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.shouldLoadData()) {
|
if (this.shouldLoadData()) {
|
||||||
@ -349,6 +356,9 @@ export default class Node {
|
|||||||
|
|
||||||
collapse(): void {
|
collapse(): void {
|
||||||
this.expanded = false
|
this.expanded = false
|
||||||
|
this.childNodes.forEach(item => {
|
||||||
|
item.canFocus = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldLoadData(): boolean {
|
shouldLoadData(): boolean {
|
||||||
|
@ -163,10 +163,14 @@ export default class TreeStore {
|
|||||||
|
|
||||||
registerNode(node: Node): void {
|
registerNode(node: Node): void {
|
||||||
const key = this.key
|
const key = this.key
|
||||||
if (!key || !node || !node.data) return
|
if (!node || !node.data) return
|
||||||
|
|
||||||
const nodeKey = node.key
|
if(!key){
|
||||||
if (nodeKey !== undefined) this.nodesMap[node.key] = node
|
this.nodesMap[node.id] = node
|
||||||
|
}else {
|
||||||
|
const nodeKey = node.key
|
||||||
|
if (nodeKey !== undefined) this.nodesMap[node.key] = node
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deregisterNode(node: Node): void {
|
deregisterNode(node: Node): void {
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { onMounted, onUpdated, onBeforeUnmount, ref, watch, Ref } from 'vue'
|
import { onMounted, onUpdated, onBeforeUnmount, ref, watch, Ref } from 'vue'
|
||||||
import { EVENT_CODE } from '@element-plus/utils/aria'
|
import { EVENT_CODE } from '@element-plus/utils/aria'
|
||||||
import { on, off } from '@element-plus/utils/dom'
|
import { on, off } from '@element-plus/utils/dom'
|
||||||
|
import TreeStore from './tree-store'
|
||||||
|
|
||||||
interface UseKeydownOption {
|
interface UseKeydownOption {
|
||||||
el$: Ref<HTMLElement>
|
el$: Ref<HTMLElement>
|
||||||
}
|
}
|
||||||
export function useKeydown({ el$ }: UseKeydownOption) {
|
export function useKeydown({ el$ }: UseKeydownOption, store: Ref<TreeStore>) {
|
||||||
const treeItems = ref<Nullable<HTMLElement>[]>([])
|
const treeItems = ref<Nullable<HTMLElement>[]>([])
|
||||||
const checkboxItems = ref<Nullable<HTMLElement>[]>([])
|
const checkboxItems = ref<Nullable<HTMLElement>[]>([])
|
||||||
|
|
||||||
@ -39,11 +40,35 @@ export function useKeydown({ el$ }: UseKeydownOption) {
|
|||||||
if ([EVENT_CODE.up, EVENT_CODE.down].indexOf(code) > -1) {
|
if ([EVENT_CODE.up, EVENT_CODE.down].indexOf(code) > -1) {
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
if (code === EVENT_CODE.up) {
|
if (code === EVENT_CODE.up) {
|
||||||
nextIndex = currentIndex !== 0 ? currentIndex - 1 : 0
|
nextIndex = currentIndex === -1 ? 0 : currentIndex !== 0 ? currentIndex - 1 : treeItems.value.length - 1
|
||||||
|
const startIndex = nextIndex
|
||||||
|
while (true) {
|
||||||
|
if(store.value.getNode(treeItems.value[nextIndex].dataset.key).canFocus) break
|
||||||
|
nextIndex--
|
||||||
|
if(nextIndex === startIndex) {
|
||||||
|
nextIndex = -1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if(nextIndex < 0) {
|
||||||
|
nextIndex = treeItems.value.length - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
nextIndex = (currentIndex < treeItems.value.length - 1) ? currentIndex + 1 : 0
|
nextIndex = currentIndex === -1 ? 0 : (currentIndex < treeItems.value.length - 1) ? currentIndex + 1 : 0
|
||||||
|
const startIndex = nextIndex
|
||||||
|
while (true) {
|
||||||
|
if(store.value.getNode(treeItems.value[nextIndex].dataset.key).canFocus) break
|
||||||
|
nextIndex++
|
||||||
|
if(nextIndex === startIndex) {
|
||||||
|
nextIndex = -1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if(nextIndex >= treeItems.value.length) {
|
||||||
|
nextIndex = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
treeItems.value[nextIndex].focus()
|
nextIndex !== -1 && treeItems.value[nextIndex].focus()
|
||||||
}
|
}
|
||||||
if ([EVENT_CODE.left, EVENT_CODE.right].indexOf(code) > -1) {
|
if ([EVENT_CODE.left, EVENT_CODE.right].indexOf(code) > -1) {
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
|
@ -16,6 +16,7 @@
|
|||||||
:aria-disabled="node.disabled"
|
:aria-disabled="node.disabled"
|
||||||
:aria-checked="node.checked"
|
:aria-checked="node.checked"
|
||||||
:draggable="tree.props.draggable"
|
:draggable="tree.props.draggable"
|
||||||
|
:data-key="getNodeKey(node)"
|
||||||
@click.stop="handleClick"
|
@click.stop="handleClick"
|
||||||
@contextmenu="handleContextMenu"
|
@contextmenu="handleContextMenu"
|
||||||
@dragstart.stop="handleDragStart"
|
@dragstart.stop="handleDragStart"
|
||||||
|
@ -163,7 +163,7 @@ export default defineComponent({
|
|||||||
props, ctx, el$, dropIndicator$, store,
|
props, ctx, el$, dropIndicator$, store,
|
||||||
})
|
})
|
||||||
|
|
||||||
useKeydown({ el$ })
|
useKeydown({ el$ }, store)
|
||||||
|
|
||||||
const isEmpty = computed(() => {
|
const isEmpty = computed(() => {
|
||||||
const { childNodes } = root.value
|
const { childNodes } = root.value
|
||||||
|
Loading…
Reference in New Issue
Block a user