diff --git a/helper/isOutsidePath.js b/helper/isOutsidePath.js index 88bbf48..98757f3 100644 --- a/helper/isOutsidePath.js +++ b/helper/isOutsidePath.js @@ -2,7 +2,7 @@ * Check if the target of an event is outside of an element * * @param {Event} evt - * @param {Node} element + * @param {HTMLElement} element * @returns */ const isOutsidePath = (evt, element) => { diff --git a/packages/dropdown/src/index.vue b/packages/dropdown/src/index.vue index 4d528af..5bf461b 100644 --- a/packages/dropdown/src/index.vue +++ b/packages/dropdown/src/index.vue @@ -66,11 +66,11 @@ export default { this.checkForAccessibleName() - document.addEventListener('keydown', this.handleGlobalKeyDown) + document.addEventListener('focusin', this.handleGlobalFocus) document.documentElement.addEventListener('click', this.handleGlobalClick) }, beforeDestroy() { - document.removeEventListener('keydown', this.handleGlobalKeyDown) + document.removeEventListener('focusin', this.handleGlobalFocus) document.documentElement.removeEventListener( 'click', this.handleGlobalClick @@ -99,10 +99,10 @@ export default { this.open() } }, - handleGlobalKeyDown(evt) { - if (evt.keyCode === 9 && isOutsidePath(evt, this.$el)) { - this.close(false) - } + handleGlobalFocus() { + if (this.$el.contains(document.activeElement)) return + + this.close(false) }, handleGlobalClick(evt) { if (isOutsidePath(evt, this.$el)) { @@ -117,7 +117,7 @@ export default { this.$nextTick().then(() => { this.items = Array.from( - this.$refs.menu.querySelectorAll('[role=menuitem]:not([disabled])') + this.$refs.menu.querySelectorAll('[role^="menuitem"]:not([disabled])') ) this.items.forEach(button => { @@ -128,7 +128,7 @@ export default { }) }, close(setFocus = true) { - // Method will be called from the `clickaway` directive on every component instance + // Multiple instances might have added event listeners // Limit work and ensure correct handling of focus by having an additional check for visibility if (this.isVisible) { this.isVisible = false diff --git a/packages/dropdown/tests/dropdown.stories.js b/packages/dropdown/tests/dropdown.stories.js index 61b446a..b6167b3 100644 --- a/packages/dropdown/tests/dropdown.stories.js +++ b/packages/dropdown/tests/dropdown.stories.js @@ -18,9 +18,26 @@ export const Basic = () => ({ ` }) +export const TabAway = () => ({ + components: { TournantDropdown }, + template: ` +
+

Above dropdown with a placeholder link.

+ + + ${items} + +

Some more content underneath the item.

+

And another paragraph with a link. +

+ ` +}) + export const Positioning = () => ({ components: { TournantDropdown }, - template: ` + template: ` diff --git a/packages/dropdown/tests/unit/Dropdown.spec.js b/packages/dropdown/tests/unit/Dropdown.spec.js index 5b204ca..a8cb8d3 100644 --- a/packages/dropdown/tests/unit/Dropdown.spec.js +++ b/packages/dropdown/tests/unit/Dropdown.spec.js @@ -1,51 +1,120 @@ -// global.console = { warn: jest.fn() } - import { shallowMount, createLocalVue } from '@vue/test-utils' import Dropdown from '@p/dropdown/src/index.vue' -const localVue = createLocalVue() +document.body.innerHTML = ` +
+ + + + test +
+` -localVue.directive('clickaway', {}) +const localVue = createLocalVue() describe('Dropdown', () => { let wrapper let button + let menu beforeEach(() => { wrapper = shallowMount(Dropdown, { slots: { 'button-text': 'Options', - items: - ' ' + items: ` + + ` }, - localVue + localVue, + attachToDocument: true }) button = wrapper.find('button') }) describe('Events', () => { - it('@click - open and close menu', () => { + let firstMenuItem + + it('@click - open and close menu', async () => { button.trigger('click') + await wrapper.vm.$nextTick() expect(wrapper.vm.$refs.menu).toBeDefined() button.trigger('click') - + await wrapper.vm.$nextTick() expect(wrapper.vm.isVisible).toBeFalsy() }) - it('@keydown.down - open menu', () => { + it('@keydown.down - open menu', async () => { button.trigger('keydown.down') + await wrapper.vm.$nextTick() + expect(wrapper.vm.$refs.menu).toBeDefined() }) it('@keydown.down > @keydown.up - opens and closes the menu', async () => { await button.trigger('keydown.down') + await wrapper.vm.$nextTick() expect(wrapper.vm.$refs.menu).toBeDefined() button.trigger('keydown.up') + await wrapper.vm.$nextTick() + expect(wrapper.vm.$refs.menu).toBeUndefined() + }) + + it('@keydown.down - focuses first menu item', async () => { + button.trigger('keydown.down') + + await wrapper.vm.$nextTick() + + firstMenuItem = wrapper.findAll('[role="menuitem"]').at(0) + + expect(firstMenuItem.element).toBe(document.activeElement) + }) + + it('@keydown.down - loops through items', async () => { + // open + button.trigger('keydown.down') + + const { length } = wrapper.findAll('[role="menuitem"]') + + for (let i = 0; i < length; i++) { + button.trigger('keydown.down') + } + + firstMenuItem = wrapper.findAll('[role="menuitem"]').at(0) + + await wrapper.vm.$nextTick() + + expect(firstMenuItem.element).toBe(document.activeElement) + }) + + it('@keydown.up - focusses last item if at the beginning', async () => { + // open + button.trigger('keydown.down') + + await wrapper.vm.$nextTick() + + menu = wrapper.find('[role="menu"]') + menu.trigger('keydown.up') + + const { length } = wrapper.findAll('[role="menuitem"]') + const lastItem = wrapper.findAll('[role="menuitem"]').at(length - 1) + + expect(lastItem.element).toBe(document.activeElement) + }) + + it('closes the menu if click happens outside of it', async () => { + button.trigger('keydown.down') + + await wrapper.vm.$nextTick() + + document.getElementById('test-link').click() + + await wrapper.vm.$nextTick() + expect(wrapper.vm.$refs.menu).toBeUndefined() }) }) @@ -59,13 +128,13 @@ describe('Dropdown', () => { expect(button.attributes('aria-haspopup')).toBeTruthy() }) - it('changes its `aria-expanded` attribute', () => { + it('changes its `aria-expanded` attribute', async () => { button.trigger('click') - + await wrapper.vm.$nextTick() expect(button.attributes('aria-expanded')).toBe('true') button.trigger('click') - + await wrapper.vm.$nextTick() expect(button.attributes('aria-expanded')).toBe('false') }) }) @@ -140,3 +209,59 @@ describe('Dropdown', () => { }) }) }) + +describe('Dropdown – menuitemcheckbox', () => { + let wrapper + let button + + beforeEach(() => { + wrapper = shallowMount(Dropdown, { + slots: { + 'button-text': 'Options', + items: ` + + ` + }, + localVue, + attachToDocument: true + }) + + button = wrapper.find('button') + }) + + it('detects `menuitemradio` button', async () => { + button.trigger('click') + + await wrapper.vm.$nextTick() + + expect(wrapper.vm.items).toHaveLength(2) + }) +}) + +describe('Dropdown – menuitemradio', () => { + let wrapper + let button + + beforeEach(() => { + wrapper = shallowMount(Dropdown, { + slots: { + 'button-text': 'Options', + items: ` + + ` + }, + localVue, + attachToDocument: true + }) + + button = wrapper.find('button') + }) + + it('detects `menuitemradio` button', async () => { + button.trigger('click') + + await wrapper.vm.$nextTick() + + expect(wrapper.vm.items).toHaveLength(2) + }) +})