Skip to content
Merged
27 changes: 15 additions & 12 deletions src/components/modal/modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
}
},
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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()
}
}
Expand Down
278 changes: 277 additions & 1 deletion src/components/modal/modal.spec.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
})
})
Expand Down