Skip to content

feat(modal): auto return focus to trigger elements using document.activeElement #3033

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Apr 7, 2019
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