Skip to content

Commit f31c90c

Browse files
authored
Merge pull request #69 from tournantdev/feature/dropdown-test-improvement
Test Dropdown Focus Management && Bugfixes
2 parents 6d520fc + edb5f12 commit f31c90c

File tree

4 files changed

+165
-23
lines changed

4 files changed

+165
-23
lines changed

helper/isOutsidePath.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Check if the target of an event is outside of an element
33
*
44
* @param {Event} evt
5-
* @param {Node} element
5+
* @param {HTMLElement} element
66
* @returns
77
*/
88
const isOutsidePath = (evt, element) => {

packages/dropdown/src/index.vue

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,11 @@ export default {
6666
6767
this.checkForAccessibleName()
6868
69-
document.addEventListener('keydown', this.handleGlobalKeyDown)
69+
document.addEventListener('focusin', this.handleGlobalFocus)
7070
document.documentElement.addEventListener('click', this.handleGlobalClick)
7171
},
7272
beforeDestroy() {
73-
document.removeEventListener('keydown', this.handleGlobalKeyDown)
73+
document.removeEventListener('focusin', this.handleGlobalFocus)
7474
document.documentElement.removeEventListener(
7575
'click',
7676
this.handleGlobalClick
@@ -99,10 +99,10 @@ export default {
9999
this.open()
100100
}
101101
},
102-
handleGlobalKeyDown(evt) {
103-
if (evt.keyCode === 9 && isOutsidePath(evt, this.$el)) {
104-
this.close(false)
105-
}
102+
handleGlobalFocus() {
103+
if (this.$el.contains(document.activeElement)) return
104+
105+
this.close(false)
106106
},
107107
handleGlobalClick(evt) {
108108
if (isOutsidePath(evt, this.$el)) {
@@ -117,7 +117,7 @@ export default {
117117
118118
this.$nextTick().then(() => {
119119
this.items = Array.from(
120-
this.$refs.menu.querySelectorAll('[role=menuitem]:not([disabled])')
120+
this.$refs.menu.querySelectorAll('[role^="menuitem"]:not([disabled])')
121121
)
122122
123123
this.items.forEach(button => {
@@ -128,7 +128,7 @@ export default {
128128
})
129129
},
130130
close(setFocus = true) {
131-
// Method will be called from the `clickaway` directive on every component instance
131+
// Multiple instances might have added event listeners
132132
// Limit work and ensure correct handling of focus by having an additional check for visibility
133133
if (this.isVisible) {
134134
this.isVisible = false

packages/dropdown/tests/dropdown.stories.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,26 @@ export const Basic = () => ({
1818
</tournant-dropdown>`
1919
})
2020

21+
export const TabAway = () => ({
22+
components: { TournantDropdown },
23+
template: `
24+
<div>
25+
<p>Above dropdown with a <a href="#">placeholder link</a>.</p>
26+
<tournant-dropdown >
27+
<template v-slot:button-text>
28+
Toggle
29+
</template>
30+
${items}
31+
</tournant-dropdown>
32+
<p>Some more content underneath the item.</p>
33+
<p>And another paragraph with <a href="#">a link</a>.
34+
</div>
35+
`
36+
})
37+
2138
export const Positioning = () => ({
2239
components: { TournantDropdown },
23-
template: `<tournant-dropdown x-position="right" >
40+
template: `<tournant-dropdown x-position="right">
2441
<template v-slot:button-text>
2542
Toggle
2643
</template>

packages/dropdown/tests/unit/Dropdown.spec.js

Lines changed: 138 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,120 @@
1-
// global.console = { warn: jest.fn() }
2-
31
import { shallowMount, createLocalVue } from '@vue/test-utils'
42

53
import Dropdown from '@p/dropdown/src/index.vue'
64

7-
const localVue = createLocalVue()
5+
document.body.innerHTML = `
6+
<div>
7+
<button id="btn1">btn 1 </button>
8+
<button id="btn2">btn 2 </button>
9+
<button id="btn3">btn 3 </button>
10+
<a href="#" id="test-link">test</a>
11+
</div>
12+
`
813

9-
localVue.directive('clickaway', {})
14+
const localVue = createLocalVue()
1015

1116
describe('Dropdown', () => {
1217
let wrapper
1318
let button
19+
let menu
1420

1521
beforeEach(() => {
1622
wrapper = shallowMount(Dropdown, {
1723
slots: {
1824
'button-text': 'Options',
19-
items:
20-
'<button role="menuitem" tabindex="-1">Rename</button> <button role="menuitem" tabindex="-1">Delete</button>'
25+
items: `<button role="menuitem" tabindex="-1">Rename</button>
26+
<button role="menuitem" tabindex="-1">Delete</button>
27+
`
2128
},
22-
localVue
29+
localVue,
30+
attachToDocument: true
2331
})
2432

2533
button = wrapper.find('button')
2634
})
2735

2836
describe('Events', () => {
29-
it('@click - open and close menu', () => {
37+
let firstMenuItem
38+
39+
it('@click - open and close menu', async () => {
3040
button.trigger('click')
41+
await wrapper.vm.$nextTick()
3142
expect(wrapper.vm.$refs.menu).toBeDefined()
3243

3344
button.trigger('click')
34-
45+
await wrapper.vm.$nextTick()
3546
expect(wrapper.vm.isVisible).toBeFalsy()
3647
})
3748

38-
it('@keydown.down - open menu', () => {
49+
it('@keydown.down - open menu', async () => {
3950
button.trigger('keydown.down')
4051

52+
await wrapper.vm.$nextTick()
53+
4154
expect(wrapper.vm.$refs.menu).toBeDefined()
4255
})
4356

4457
it('@keydown.down > @keydown.up - opens and closes the menu', async () => {
4558
await button.trigger('keydown.down')
59+
await wrapper.vm.$nextTick()
4660
expect(wrapper.vm.$refs.menu).toBeDefined()
4761

4862
button.trigger('keydown.up')
63+
await wrapper.vm.$nextTick()
64+
expect(wrapper.vm.$refs.menu).toBeUndefined()
65+
})
66+
67+
it('@keydown.down - focuses first menu item', async () => {
68+
button.trigger('keydown.down')
69+
70+
await wrapper.vm.$nextTick()
71+
72+
firstMenuItem = wrapper.findAll('[role="menuitem"]').at(0)
73+
74+
expect(firstMenuItem.element).toBe(document.activeElement)
75+
})
76+
77+
it('@keydown.down - loops through items', async () => {
78+
// open
79+
button.trigger('keydown.down')
80+
81+
const { length } = wrapper.findAll('[role="menuitem"]')
82+
83+
for (let i = 0; i < length; i++) {
84+
button.trigger('keydown.down')
85+
}
86+
87+
firstMenuItem = wrapper.findAll('[role="menuitem"]').at(0)
88+
89+
await wrapper.vm.$nextTick()
90+
91+
expect(firstMenuItem.element).toBe(document.activeElement)
92+
})
93+
94+
it('@keydown.up - focusses last item if at the beginning', async () => {
95+
// open
96+
button.trigger('keydown.down')
97+
98+
await wrapper.vm.$nextTick()
99+
100+
menu = wrapper.find('[role="menu"]')
101+
menu.trigger('keydown.up')
102+
103+
const { length } = wrapper.findAll('[role="menuitem"]')
104+
const lastItem = wrapper.findAll('[role="menuitem"]').at(length - 1)
105+
106+
expect(lastItem.element).toBe(document.activeElement)
107+
})
108+
109+
it('closes the menu if click happens outside of it', async () => {
110+
button.trigger('keydown.down')
111+
112+
await wrapper.vm.$nextTick()
113+
114+
document.getElementById('test-link').click()
115+
116+
await wrapper.vm.$nextTick()
117+
49118
expect(wrapper.vm.$refs.menu).toBeUndefined()
50119
})
51120
})
@@ -59,13 +128,13 @@ describe('Dropdown', () => {
59128
expect(button.attributes('aria-haspopup')).toBeTruthy()
60129
})
61130

62-
it('changes its `aria-expanded` attribute', () => {
131+
it('changes its `aria-expanded` attribute', async () => {
63132
button.trigger('click')
64-
133+
await wrapper.vm.$nextTick()
65134
expect(button.attributes('aria-expanded')).toBe('true')
66135

67136
button.trigger('click')
68-
137+
await wrapper.vm.$nextTick()
69138
expect(button.attributes('aria-expanded')).toBe('false')
70139
})
71140
})
@@ -140,3 +209,59 @@ describe('Dropdown', () => {
140209
})
141210
})
142211
})
212+
213+
describe('Dropdown – menuitemcheckbox', () => {
214+
let wrapper
215+
let button
216+
217+
beforeEach(() => {
218+
wrapper = shallowMount(Dropdown, {
219+
slots: {
220+
'button-text': 'Options',
221+
items: `<button role="menuitemcheckbox" tabindex="-1">Rename</button>
222+
<button role="menuitemcheckbox" tabindex="-1">Delete</button>
223+
`
224+
},
225+
localVue,
226+
attachToDocument: true
227+
})
228+
229+
button = wrapper.find('button')
230+
})
231+
232+
it('detects `menuitemradio` button', async () => {
233+
button.trigger('click')
234+
235+
await wrapper.vm.$nextTick()
236+
237+
expect(wrapper.vm.items).toHaveLength(2)
238+
})
239+
})
240+
241+
describe('Dropdown – menuitemradio', () => {
242+
let wrapper
243+
let button
244+
245+
beforeEach(() => {
246+
wrapper = shallowMount(Dropdown, {
247+
slots: {
248+
'button-text': 'Options',
249+
items: `<button role="menuitemradio" tabindex="-1">Rename</button>
250+
<button role="menuitemradio" tabindex="-1">Delete</button>
251+
`
252+
},
253+
localVue,
254+
attachToDocument: true
255+
})
256+
257+
button = wrapper.find('button')
258+
})
259+
260+
it('detects `menuitemradio` button', async () => {
261+
button.trigger('click')
262+
263+
await wrapper.vm.$nextTick()
264+
265+
expect(wrapper.vm.items).toHaveLength(2)
266+
})
267+
})

0 commit comments

Comments
 (0)