diff --git a/src/components/modal/modal.js b/src/components/modal/modal.js index b56853f0195..650d6d7e6ca 100644 --- a/src/components/modal/modal.js +++ b/src/components/modal/modal.js @@ -363,6 +363,13 @@ export default Vue.extend({ return } this.is_opening = true + if (inBrowser && document.activeElement.focus) { + // Preset the fallback return focus value if it is not set. + // document.activeElement should be the trigger element that was clicked or + // in the case of using the v-model, which ever element has current focus. + // Will be overridden by some commands such as toggle, etc. + this.return_focus = this.return_focus || document.activeElement + } const showEvt = new BvModalEvent('show', { cancelable: true, vueTarget: this, @@ -577,7 +584,7 @@ export default Vue.extend({ // Root listener handlers showHandler(id, triggerEl) { if (id === this.id) { - this.return_focus = triggerEl || null + this.return_focus = triggerEl || document.activeElement || null this.show() } }, @@ -606,11 +613,8 @@ export default Vue.extend({ if (inBrowser) { const modal = this.$refs.modal const activeElement = document.activeElement - if (activeElement && contains(modal, activeElement)) { - // If `activeElement` is child of modal or is modal, no need to change focus - return - } - if (modal) { + // If the modal contains the activeElement, we don't do anything + if (modal && !(activeElement && contains(modal, activeElement))) { // Make sure top of modal is showing (if longer than the viewport) // and focus the modal content wrapper this.$nextTick(() => { @@ -622,14 +626,13 @@ export default Vue.extend({ }, returnFocusTo() { // Prefer `returnFocus` prop over event specified `return_focus` value - let el = this.returnFocus || this.return_focus || null - if (typeof el === 'string') { - // CSS Selector - el = select(el) - } + let el = this.returnFocus || this.return_focus || document.activeElement || null + // Is el a string CSS Selector? + el = typeof el === 'string' ? select(el) : el if (el) { + // Possibly could be a component reference el = el.$el || el - if (isVisible(el)) { + if (isVisible(el) && el.focus) { el.focus() } } diff --git a/src/components/modal/modal.spec.js b/src/components/modal/modal.spec.js index f025408f2c9..d6318069086 100644 --- a/src/components/modal/modal.spec.js +++ b/src/components/modal/modal.spec.js @@ -1,7 +1,7 @@ import BModal from './modal' import BvModalEvent from './helpers/bv-modal-event.class' -import { mount, createWrapper } from '@vue/test-utils' +import { mount, createWrapper, createLocalVue as CreateLocalVue } from '@vue/test-utils' // The defautl Z-INDEX for modal backdrop const DEFAULT_ZINDEX = 1040 @@ -896,6 +896,282 @@ describe('modal', () => { // Modal should now be closed expect($modal.element.style.display).toEqual('none') + + wrapper.destroy() + }) + }) + + describe('focus management', () => { + const localVue = new CreateLocalVue() + + it('returns focus to document.body when no return focus set and not using v-b-toggle', async () => { + // JSDOM won't focus the document unless it has a tab index + document.body.tabIndex = 0 + + const wrapper = mount(BModal, { + attachToDocument: true, + localVue: localVue, + stubs: { + transition: false + }, + propsData: { + id: 'test', + visible: false + } + }) + + expect(wrapper.isVueInstance()).toBe(true) + + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await waitAF() + + const $modal = wrapper.find('div.modal') + expect($modal.exists()).toBe(true) + + expect($modal.element.style.display).toEqual('none') + expect(document.activeElement).toBe(document.body) + + // Try and open modal via .toggle() method + wrapper.vm.toggle() + + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick() + + // Modal should now be open + expect($modal.element.style.display).toEqual('') + expect(document.activeElement).not.toBe(document.body) + expect(wrapper.element.contains(document.activeElement)).toBe(true) + + // Try and close modal via .toggle() + wrapper.vm.toggle() + + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick() + + // Modal should now be closed + expect($modal.element.style.display).toEqual('none') + expect(document.activeElement).toBe(document.body) + + wrapper.destroy() + }) + + it('returns focus to previous active element when return focus not set and not using v-b-toggle', async () => { + const App = localVue.extend({ + render(h) { + return h('div', {}, [ + h('button', { class: 'trigger', attrs: { id: 'trigger', type: 'button' } }, 'trigger'), + h(BModal, { props: { id: 'test', visible: false } }, 'modal content') + ]) + } + }) + const wrapper = mount(App, { + attachToDocument: true, + localVue: localVue, + stubs: { + transition: false + } + }) + + expect(wrapper.isVueInstance()).toBe(true) + + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick() + + const $button = wrapper.find('button.trigger') + expect($button.exists()).toBe(true) + expect($button.is('button')).toBe(true) + + const $modal = wrapper.find('div.modal') + expect($modal.exists()).toBe(true) + + expect($modal.element.style.display).toEqual('none') + expect(document.activeElement).toBe(document.body) + + // Set the active element to the button + $button.element.focus() + expect(document.activeElement).toBe($button.element) + + // Try and open modal via .toggle() method + wrapper.find(BModal).vm.toggle() + + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick() + + // Modal should now be open + expect($modal.element.style.display).toEqual('') + expect(document.activeElement).not.toBe(document.body) + expect(document.activeElement).not.toBe($button.element) + expect($modal.element.contains(document.activeElement)).toBe(true) + + // Try and close modal via .toggle() + wrapper.find(BModal).vm.toggle() + + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick() + + // Modal should now be closed + expect($modal.element.style.display).toEqual('none') + expect(document.activeElement).toBe($button.element) + + wrapper.destroy() + }) + + it('returns focus to element specified in toggle() method', async () => { + const App = localVue.extend({ + render(h) { + return h('div', {}, [ + h('button', { class: 'trigger', attrs: { id: 'trigger', type: 'button' } }, 'trigger'), + h( + 'button', + { class: 'return-to', attrs: { id: 'return-to', type: 'button' } }, + 'trigger' + ), + h(BModal, { props: { id: 'test', visible: false } }, 'modal content') + ]) + } + }) + const wrapper = mount(App, { + attachToDocument: true, + localVue: localVue, + stubs: { + transition: false + } + }) + + expect(wrapper.isVueInstance()).toBe(true) + + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick() + + const $button = wrapper.find('button.trigger') + expect($button.exists()).toBe(true) + expect($button.is('button')).toBe(true) + + const $button2 = wrapper.find('button.return-to') + expect($button2.exists()).toBe(true) + expect($button2.is('button')).toBe(true) + + const $modal = wrapper.find('div.modal') + expect($modal.exists()).toBe(true) + + expect($modal.element.style.display).toEqual('none') + expect(document.activeElement).toBe(document.body) + + // Set the active element to the button + $button.element.focus() + expect(document.activeElement).toBe($button.element) + + // Try and open modal via .toggle() method + wrapper.find(BModal).vm.toggle('button.return-to') + + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick() + + // Modal should now be open + expect($modal.element.style.display).toEqual('') + expect(document.activeElement).not.toBe(document.body) + expect(document.activeElement).not.toBe($button.element) + expect(document.activeElement).not.toBe($button2.element) + expect($modal.element.contains(document.activeElement)).toBe(true) + + // Try and close modal via .toggle() + wrapper.find(BModal).vm.toggle() + + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick() + + // Modal should now be closed + expect($modal.element.style.display).toEqual('none') + expect(document.activeElement).toBe($button2.element) + + wrapper.destroy() + }) + + it('if focus leave modal it reutrns to modal', async () => { + const App = localVue.extend({ + render(h) { + return h('div', {}, [ + h('button', { class: 'trigger', attrs: { id: 'trigger', type: 'button' } }, 'trigger'), + h(BModal, { props: { id: 'test', visible: true } }, 'modal content') + ]) + } + }) + const wrapper = mount(App, { + attachToDocument: true, + localVue: localVue, + stubs: { + transition: false + } + }) + + expect(wrapper.isVueInstance()).toBe(true) + + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick() + + const $button = wrapper.find('button.trigger') + expect($button.exists()).toBe(true) + expect($button.is('button')).toBe(true) + + const $modal = wrapper.find('div.modal') + expect($modal.exists()).toBe(true) + + expect($modal.element.style.display).toEqual('') + expect(document.activeElement).not.toBe(document.body) + expect(document.activeElement).toBe($modal.element) + + // Try anf set focusin on external button + $button.trigger('focusin') + await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick() + expect(document.activeElement).not.toBe($button.element) + expect(document.activeElement).toBe($modal.element) + + // Try anf set focusin on external button + $button.trigger('focus') + await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick() + expect(document.activeElement).not.toBe($button.element) + expect(document.activeElement).toBe($modal.element) + wrapper.destroy() }) })