Skip to content

Commit e5c0aa5

Browse files
authored
feat(modal): auto return focus to trigger elements using document.activeElement if no return focus provided (#3033)
1 parent 771424c commit e5c0aa5

File tree

2 files changed

+292
-13
lines changed

2 files changed

+292
-13
lines changed

src/components/modal/modal.js

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,13 @@ export default Vue.extend({
363363
return
364364
}
365365
this.is_opening = true
366+
if (inBrowser && document.activeElement.focus) {
367+
// Preset the fallback return focus value if it is not set.
368+
// document.activeElement should be the trigger element that was clicked or
369+
// in the case of using the v-model, which ever element has current focus.
370+
// Will be overridden by some commands such as toggle, etc.
371+
this.return_focus = this.return_focus || document.activeElement
372+
}
366373
const showEvt = new BvModalEvent('show', {
367374
cancelable: true,
368375
vueTarget: this,
@@ -577,7 +584,7 @@ export default Vue.extend({
577584
// Root listener handlers
578585
showHandler(id, triggerEl) {
579586
if (id === this.id) {
580-
this.return_focus = triggerEl || null
587+
this.return_focus = triggerEl || document.activeElement || null
581588
this.show()
582589
}
583590
},
@@ -606,11 +613,8 @@ export default Vue.extend({
606613
if (inBrowser) {
607614
const modal = this.$refs.modal
608615
const activeElement = document.activeElement
609-
if (activeElement && contains(modal, activeElement)) {
610-
// If `activeElement` is child of modal or is modal, no need to change focus
611-
return
612-
}
613-
if (modal) {
616+
// If the modal contains the activeElement, we don't do anything
617+
if (modal && !(activeElement && contains(modal, activeElement))) {
614618
// Make sure top of modal is showing (if longer than the viewport)
615619
// and focus the modal content wrapper
616620
this.$nextTick(() => {
@@ -622,14 +626,13 @@ export default Vue.extend({
622626
},
623627
returnFocusTo() {
624628
// Prefer `returnFocus` prop over event specified `return_focus` value
625-
let el = this.returnFocus || this.return_focus || null
626-
if (typeof el === 'string') {
627-
// CSS Selector
628-
el = select(el)
629-
}
629+
let el = this.returnFocus || this.return_focus || document.activeElement || null
630+
// Is el a string CSS Selector?
631+
el = typeof el === 'string' ? select(el) : el
630632
if (el) {
633+
// Possibly could be a component reference
631634
el = el.$el || el
632-
if (isVisible(el)) {
635+
if (isVisible(el) && el.focus) {
633636
el.focus()
634637
}
635638
}

src/components/modal/modal.spec.js

Lines changed: 277 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import BModal from './modal'
22
import BvModalEvent from './helpers/bv-modal-event.class'
33

4-
import { mount, createWrapper } from '@vue/test-utils'
4+
import { mount, createWrapper, createLocalVue as CreateLocalVue } from '@vue/test-utils'
55

66
// The defautl Z-INDEX for modal backdrop
77
const DEFAULT_ZINDEX = 1040
@@ -896,6 +896,282 @@ describe('modal', () => {
896896

897897
// Modal should now be closed
898898
expect($modal.element.style.display).toEqual('none')
899+
900+
wrapper.destroy()
901+
})
902+
})
903+
904+
describe('focus management', () => {
905+
const localVue = new CreateLocalVue()
906+
907+
it('returns focus to document.body when no return focus set and not using v-b-toggle', async () => {
908+
// JSDOM won't focus the document unless it has a tab index
909+
document.body.tabIndex = 0
910+
911+
const wrapper = mount(BModal, {
912+
attachToDocument: true,
913+
localVue: localVue,
914+
stubs: {
915+
transition: false
916+
},
917+
propsData: {
918+
id: 'test',
919+
visible: false
920+
}
921+
})
922+
923+
expect(wrapper.isVueInstance()).toBe(true)
924+
925+
await wrapper.vm.$nextTick()
926+
await waitAF()
927+
await wrapper.vm.$nextTick()
928+
await waitAF()
929+
930+
const $modal = wrapper.find('div.modal')
931+
expect($modal.exists()).toBe(true)
932+
933+
expect($modal.element.style.display).toEqual('none')
934+
expect(document.activeElement).toBe(document.body)
935+
936+
// Try and open modal via .toggle() method
937+
wrapper.vm.toggle()
938+
939+
await wrapper.vm.$nextTick()
940+
await waitAF()
941+
await wrapper.vm.$nextTick()
942+
await waitAF()
943+
await wrapper.vm.$nextTick()
944+
await wrapper.vm.$nextTick()
945+
946+
// Modal should now be open
947+
expect($modal.element.style.display).toEqual('')
948+
expect(document.activeElement).not.toBe(document.body)
949+
expect(wrapper.element.contains(document.activeElement)).toBe(true)
950+
951+
// Try and close modal via .toggle()
952+
wrapper.vm.toggle()
953+
954+
await wrapper.vm.$nextTick()
955+
await waitAF()
956+
await wrapper.vm.$nextTick()
957+
await waitAF()
958+
await wrapper.vm.$nextTick()
959+
await wrapper.vm.$nextTick()
960+
961+
// Modal should now be closed
962+
expect($modal.element.style.display).toEqual('none')
963+
expect(document.activeElement).toBe(document.body)
964+
965+
wrapper.destroy()
966+
})
967+
968+
it('returns focus to previous active element when return focus not set and not using v-b-toggle', async () => {
969+
const App = localVue.extend({
970+
render(h) {
971+
return h('div', {}, [
972+
h('button', { class: 'trigger', attrs: { id: 'trigger', type: 'button' } }, 'trigger'),
973+
h(BModal, { props: { id: 'test', visible: false } }, 'modal content')
974+
])
975+
}
976+
})
977+
const wrapper = mount(App, {
978+
attachToDocument: true,
979+
localVue: localVue,
980+
stubs: {
981+
transition: false
982+
}
983+
})
984+
985+
expect(wrapper.isVueInstance()).toBe(true)
986+
987+
await wrapper.vm.$nextTick()
988+
await waitAF()
989+
await wrapper.vm.$nextTick()
990+
await waitAF()
991+
await wrapper.vm.$nextTick()
992+
await wrapper.vm.$nextTick()
993+
994+
const $button = wrapper.find('button.trigger')
995+
expect($button.exists()).toBe(true)
996+
expect($button.is('button')).toBe(true)
997+
998+
const $modal = wrapper.find('div.modal')
999+
expect($modal.exists()).toBe(true)
1000+
1001+
expect($modal.element.style.display).toEqual('none')
1002+
expect(document.activeElement).toBe(document.body)
1003+
1004+
// Set the active element to the button
1005+
$button.element.focus()
1006+
expect(document.activeElement).toBe($button.element)
1007+
1008+
// Try and open modal via .toggle() method
1009+
wrapper.find(BModal).vm.toggle()
1010+
1011+
await wrapper.vm.$nextTick()
1012+
await waitAF()
1013+
await wrapper.vm.$nextTick()
1014+
await waitAF()
1015+
await wrapper.vm.$nextTick()
1016+
await wrapper.vm.$nextTick()
1017+
1018+
// Modal should now be open
1019+
expect($modal.element.style.display).toEqual('')
1020+
expect(document.activeElement).not.toBe(document.body)
1021+
expect(document.activeElement).not.toBe($button.element)
1022+
expect($modal.element.contains(document.activeElement)).toBe(true)
1023+
1024+
// Try and close modal via .toggle()
1025+
wrapper.find(BModal).vm.toggle()
1026+
1027+
await wrapper.vm.$nextTick()
1028+
await waitAF()
1029+
await wrapper.vm.$nextTick()
1030+
await waitAF()
1031+
await wrapper.vm.$nextTick()
1032+
await wrapper.vm.$nextTick()
1033+
1034+
// Modal should now be closed
1035+
expect($modal.element.style.display).toEqual('none')
1036+
expect(document.activeElement).toBe($button.element)
1037+
1038+
wrapper.destroy()
1039+
})
1040+
1041+
it('returns focus to element specified in toggle() method', async () => {
1042+
const App = localVue.extend({
1043+
render(h) {
1044+
return h('div', {}, [
1045+
h('button', { class: 'trigger', attrs: { id: 'trigger', type: 'button' } }, 'trigger'),
1046+
h(
1047+
'button',
1048+
{ class: 'return-to', attrs: { id: 'return-to', type: 'button' } },
1049+
'trigger'
1050+
),
1051+
h(BModal, { props: { id: 'test', visible: false } }, 'modal content')
1052+
])
1053+
}
1054+
})
1055+
const wrapper = mount(App, {
1056+
attachToDocument: true,
1057+
localVue: localVue,
1058+
stubs: {
1059+
transition: false
1060+
}
1061+
})
1062+
1063+
expect(wrapper.isVueInstance()).toBe(true)
1064+
1065+
await wrapper.vm.$nextTick()
1066+
await waitAF()
1067+
await wrapper.vm.$nextTick()
1068+
await waitAF()
1069+
await wrapper.vm.$nextTick()
1070+
await wrapper.vm.$nextTick()
1071+
1072+
const $button = wrapper.find('button.trigger')
1073+
expect($button.exists()).toBe(true)
1074+
expect($button.is('button')).toBe(true)
1075+
1076+
const $button2 = wrapper.find('button.return-to')
1077+
expect($button2.exists()).toBe(true)
1078+
expect($button2.is('button')).toBe(true)
1079+
1080+
const $modal = wrapper.find('div.modal')
1081+
expect($modal.exists()).toBe(true)
1082+
1083+
expect($modal.element.style.display).toEqual('none')
1084+
expect(document.activeElement).toBe(document.body)
1085+
1086+
// Set the active element to the button
1087+
$button.element.focus()
1088+
expect(document.activeElement).toBe($button.element)
1089+
1090+
// Try and open modal via .toggle() method
1091+
wrapper.find(BModal).vm.toggle('button.return-to')
1092+
1093+
await wrapper.vm.$nextTick()
1094+
await waitAF()
1095+
await wrapper.vm.$nextTick()
1096+
await waitAF()
1097+
await wrapper.vm.$nextTick()
1098+
await wrapper.vm.$nextTick()
1099+
1100+
// Modal should now be open
1101+
expect($modal.element.style.display).toEqual('')
1102+
expect(document.activeElement).not.toBe(document.body)
1103+
expect(document.activeElement).not.toBe($button.element)
1104+
expect(document.activeElement).not.toBe($button2.element)
1105+
expect($modal.element.contains(document.activeElement)).toBe(true)
1106+
1107+
// Try and close modal via .toggle()
1108+
wrapper.find(BModal).vm.toggle()
1109+
1110+
await wrapper.vm.$nextTick()
1111+
await waitAF()
1112+
await wrapper.vm.$nextTick()
1113+
await waitAF()
1114+
await wrapper.vm.$nextTick()
1115+
await wrapper.vm.$nextTick()
1116+
1117+
// Modal should now be closed
1118+
expect($modal.element.style.display).toEqual('none')
1119+
expect(document.activeElement).toBe($button2.element)
1120+
1121+
wrapper.destroy()
1122+
})
1123+
1124+
it('if focus leave modal it reutrns to modal', async () => {
1125+
const App = localVue.extend({
1126+
render(h) {
1127+
return h('div', {}, [
1128+
h('button', { class: 'trigger', attrs: { id: 'trigger', type: 'button' } }, 'trigger'),
1129+
h(BModal, { props: { id: 'test', visible: true } }, 'modal content')
1130+
])
1131+
}
1132+
})
1133+
const wrapper = mount(App, {
1134+
attachToDocument: true,
1135+
localVue: localVue,
1136+
stubs: {
1137+
transition: false
1138+
}
1139+
})
1140+
1141+
expect(wrapper.isVueInstance()).toBe(true)
1142+
1143+
await wrapper.vm.$nextTick()
1144+
await waitAF()
1145+
await wrapper.vm.$nextTick()
1146+
await waitAF()
1147+
await wrapper.vm.$nextTick()
1148+
await wrapper.vm.$nextTick()
1149+
1150+
const $button = wrapper.find('button.trigger')
1151+
expect($button.exists()).toBe(true)
1152+
expect($button.is('button')).toBe(true)
1153+
1154+
const $modal = wrapper.find('div.modal')
1155+
expect($modal.exists()).toBe(true)
1156+
1157+
expect($modal.element.style.display).toEqual('')
1158+
expect(document.activeElement).not.toBe(document.body)
1159+
expect(document.activeElement).toBe($modal.element)
1160+
1161+
// Try anf set focusin on external button
1162+
$button.trigger('focusin')
1163+
await wrapper.vm.$nextTick()
1164+
await wrapper.vm.$nextTick()
1165+
expect(document.activeElement).not.toBe($button.element)
1166+
expect(document.activeElement).toBe($modal.element)
1167+
1168+
// Try anf set focusin on external button
1169+
$button.trigger('focus')
1170+
await wrapper.vm.$nextTick()
1171+
await wrapper.vm.$nextTick()
1172+
expect(document.activeElement).not.toBe($button.element)
1173+
expect(document.activeElement).toBe($modal.element)
1174+
8991175
wrapper.destroy()
9001176
})
9011177
})

0 commit comments

Comments
 (0)