fix(tree): fix keyboard navigation bug (#995)

* fix(tree): fix keyboard navigation bug

* test(tree): add navigate test case
This commit is contained in:
Ryan2128 2020-12-15 20:50:17 +08:00 committed by GitHub
parent e93a728872
commit eb5a04de6c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 257 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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