🌐 AI搜索 & 代理 主页
Skip to content

Commit c3ac992

Browse files
feat(b-modal): add ignore-enforce-focus-selector prop (closes #4537) (#4702)
* feat(b-modal): add `ignoreEnforceFocusSelector` prop * Update modal.js * Update modal.js * Update modal.spec.js * Update package.json * Update package.json * Update modal.js * Update README.md * Update package.json Co-authored-by: Troy Morehouse <troymore@nbnet.nb.ca>
1 parent 6e0b852 commit c3ac992

File tree

4 files changed

+218
-51
lines changed

4 files changed

+218
-51
lines changed

src/components/modal/README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,8 +1156,11 @@ Avoid setting `tabindex` on elements within the modal to any value other than `0
11561156
will make it difficult for people who rely on assistive technology to navigate and operate page
11571157
content and can make some of your elements unreachable via keyboard navigation.
11581158

1159-
In some circumstances, you may need to disable the enforce focus feature. You can do this by setting
1160-
the prop `no-enforce-focus`, although this is highly discouraged.
1159+
If some elements outside the modal need to be focusable (i.e. for TinyMCE), you can add them to the
1160+
`ignore-enforce-focus-selector` prop.
1161+
1162+
In some circumstances, you may need to disable the enforce focus feature completely. You can do this
1163+
by setting the prop `no-enforce-focus`, although this is highly discouraged.
11611164

11621165
### `v-b-modal` directive accessibility
11631166

src/components/modal/modal.js

Lines changed: 42 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import Vue from '../../utils/vue'
22
import BVTransition from '../../utils/bv-transition'
33
import KeyCodes from '../../utils/key-codes'
4+
import identity from '../../utils/identity'
45
import observeDom from '../../utils/observe-dom'
5-
import { arrayIncludes } from '../../utils/array'
6+
import { arrayIncludes, concat } from '../../utils/array'
67
import { getComponentConfig } from '../../utils/config'
78
import {
9+
closest,
810
contains,
911
eventOff,
1012
eventOn,
@@ -111,6 +113,10 @@ export const props = {
111113
type: Boolean,
112114
default: false
113115
},
116+
ignoreEnforceFocusSelector: {
117+
type: [Array, String],
118+
default: ''
119+
},
114120
title: {
115121
type: String,
116122
default: ''
@@ -396,6 +402,13 @@ export const BModal = /*#__PURE__*/ Vue.extend({
396402
hide: this.hide,
397403
visible: this.isVisible
398404
}
405+
},
406+
computeIgnoreEnforceFocusSelector() {
407+
// Normalize to an single selector with selectors separated by `,`
408+
return concat(this.ignoreEnforceFocusSelector)
409+
.filter(identity)
410+
.join(',')
411+
.trim()
399412
}
400413
},
401414
watch: {
@@ -701,34 +714,38 @@ export const BModal = /*#__PURE__*/ Vue.extend({
701714
focusHandler(evt) {
702715
// If focus leaves modal content, bring it back
703716
const content = this.$refs.content
704-
const target = evt.target
717+
const { target } = evt
705718
if (
706-
!this.noEnforceFocus &&
707-
this.isTop &&
708-
this.isVisible &&
709-
content &&
710-
document !== target &&
711-
!contains(content, target)
719+
this.noEnforceFocus ||
720+
!this.isTop ||
721+
!this.isVisible ||
722+
!content ||
723+
document === target ||
724+
contains(content, target) ||
725+
(this.computeIgnoreEnforceFocusSelector &&
726+
closest(this.computeIgnoreEnforceFocusSelector, target, true))
712727
) {
713-
const tabables = this.getTabables()
714-
if (this.$refs.bottomTrap && target === this.$refs.bottomTrap) {
715-
// If user pressed TAB out of modal into our bottom trab trap element
716-
// Find the first tabable element in the modal content and focus it
717-
if (attemptFocus(tabables[0])) {
718-
// Focus was successful
719-
return
720-
}
721-
} else if (this.$refs.topTrap && target === this.$refs.topTrap) {
722-
// If user pressed CTRL-TAB out of modal and into our top tab trap element
723-
// Find the last tabable element in the modal content and focus it
724-
if (attemptFocus(tabables[tabables.length - 1])) {
725-
// Focus was successful
726-
return
727-
}
728+
return
729+
}
730+
const tabables = this.getTabables()
731+
const { bottomTrap, topTrap } = this.$refs
732+
if (bottomTrap && target === bottomTrap) {
733+
// If user pressed TAB out of modal into our bottom trab trap element
734+
// Find the first tabable element in the modal content and focus it
735+
if (attemptFocus(tabables[0])) {
736+
// Focus was successful
737+
return
738+
}
739+
} else if (topTrap && target === topTrap) {
740+
// If user pressed CTRL-TAB out of modal and into our top tab trap element
741+
// Find the last tabable element in the modal content and focus it
742+
if (attemptFocus(tabables[tabables.length - 1])) {
743+
// Focus was successful
744+
return
728745
}
729-
// Otherwise focus the modal content container
730-
content.focus({ preventScroll: true })
731746
}
747+
// Otherwise focus the modal content container
748+
content.focus({ preventScroll: true })
732749
},
733750
// Turn on/off focusin listener
734751
setEnforceFocus(on) {

src/components/modal/modal.spec.js

Lines changed: 166 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ describe('modal', () => {
179179
},
180180
propsData: {
181181
static: false,
182-
id: 'testtarget',
182+
id: 'test-target',
183183
visible: true
184184
}
185185
})
@@ -190,7 +190,7 @@ describe('modal', () => {
190190
expect(wrapper.isEmpty()).toBe(true)
191191
expect(wrapper.element.nodeType).toEqual(Node.COMMENT_NODE)
192192

193-
const outer = document.getElementById('testtarget___BV_modal_outer_')
193+
const outer = document.getElementById('test-target___BV_modal_outer_')
194194
expect(outer).toBeDefined()
195195
expect(outer).not.toBe(null)
196196

@@ -205,7 +205,7 @@ describe('modal', () => {
205205
await waitNT(wrapper.vm)
206206
await waitRAF()
207207

208-
// Should no longer be in document.
208+
// Should no longer be in document
209209
expect(outer.parentElement).toEqual(null)
210210
})
211211

@@ -358,14 +358,14 @@ describe('modal', () => {
358358
const $cancel = $buttons.at(0)
359359
expect($cancel.attributes('type')).toBe('button')
360360
expect($cancel.text()).toContain('cancel')
361-
// v-html is applied to a span
361+
// `v-html` is applied to a span
362362
expect($cancel.html()).toContain('<span><em>cancel</em></span>')
363363

364364
// OK button (right-most button)
365365
const $ok = $buttons.at(1)
366366
expect($ok.attributes('type')).toBe('button')
367367
expect($ok.text()).toContain('ok')
368-
// v-html is applied to a span
368+
// `v-html` is applied to a span
369369
expect($ok.html()).toContain('<span><em>ok</em></span>')
370370

371371
wrapper.destroy()
@@ -1161,8 +1161,8 @@ describe('modal', () => {
11611161
const App = localVue.extend({
11621162
render(h) {
11631163
return h('div', {}, [
1164-
h('button', { class: 'trigger', attrs: { id: 'trigger', type: 'button' } }, 'trigger'),
1165-
h(BModal, { props: { static: true, id: 'test', visible: true } }, 'modal content')
1164+
h('button', { attrs: { id: 'button', type: 'button' } }, 'Button'),
1165+
h(BModal, { props: { static: true, id: 'test', visible: true } }, 'Modal content')
11661166
])
11671167
}
11681168
})
@@ -1185,7 +1185,7 @@ describe('modal', () => {
11851185
await waitNT(wrapper.vm)
11861186
await waitRAF()
11871187

1188-
const $button = wrapper.find('button.trigger')
1188+
const $button = wrapper.find('#button')
11891189
expect($button.exists()).toBe(true)
11901190
expect($button.is('button')).toBe(true)
11911191

@@ -1198,48 +1198,42 @@ describe('modal', () => {
11981198
expect(document.activeElement).not.toBe(document.body)
11991199
expect(document.activeElement).toBe($content.element)
12001200

1201-
// Try and set focusin on external button
1201+
// Try and focus the external button
1202+
$button.element.focus()
12021203
$button.trigger('focusin')
1203-
await waitNT(wrapper.vm)
1204-
expect(document.activeElement).not.toBe($button.element)
1205-
expect(document.activeElement).toBe($content.element)
1206-
1207-
// Try and set focusin on external button
1208-
$button.trigger('focus')
1209-
await waitNT(wrapper.vm)
12101204
expect(document.activeElement).not.toBe($button.element)
12111205
expect(document.activeElement).toBe($content.element)
12121206

1213-
// Emulate TAB by focusing the `bottomTrap` span element.
1207+
// Emulate TAB by focusing the `bottomTrap` span element
12141208
// Should focus first button in modal (in the header)
12151209
const $bottomTrap = wrapper.find(BModal).find({ ref: 'bottomTrap' })
12161210
expect($bottomTrap.exists()).toBe(true)
12171211
expect($bottomTrap.is('span')).toBe(true)
1218-
// Find the close (x) button (it is the only one with the .close class)
1212+
// Find the close (x) button (it is the only one with the `.close` class)
12191213
const $closeButton = $modal.find('button.close')
12201214
expect($closeButton.exists()).toBe(true)
12211215
expect($closeButton.is('button')).toBe(true)
1222-
// focus the tab trap
1216+
// Focus the tab trap
1217+
$bottomTrap.element.focus()
12231218
$bottomTrap.trigger('focusin')
1224-
$bottomTrap.trigger('focus')
12251219
await waitNT(wrapper.vm)
12261220
expect(document.activeElement).not.toBe($bottomTrap.element)
12271221
expect(document.activeElement).not.toBe($content.element)
12281222
// The close (x) button (first tabable in modal) should be focused
12291223
expect(document.activeElement).toBe($closeButton.element)
12301224

1231-
// Emulate CTRL-TAB by focusing the `topTrap` div element.
1225+
// Emulate CTRL-TAB by focusing the `topTrap` div element
12321226
// Should focus last button in modal (in the footer)
12331227
const $topTrap = wrapper.find(BModal).find({ ref: 'topTrap' })
12341228
expect($topTrap.exists()).toBe(true)
12351229
expect($topTrap.is('span')).toBe(true)
1236-
// Find the OK button (it is the only one with .btn-primary class)
1230+
// Find the OK button (it is the only one with `.btn-primary` class)
12371231
const $okButton = $modal.find('button.btn.btn-primary')
12381232
expect($okButton.exists()).toBe(true)
12391233
expect($okButton.is('button')).toBe(true)
1240-
// focus the tab trap
1234+
// Focus the tab trap
1235+
$topTrap.element.focus()
12411236
$topTrap.trigger('focusin')
1242-
$topTrap.trigger('focus')
12431237
await waitNT(wrapper.vm)
12441238
expect(document.activeElement).not.toBe($topTrap.element)
12451239
expect(document.activeElement).not.toBe($bottomTrap.element)
@@ -1249,5 +1243,153 @@ describe('modal', () => {
12491243

12501244
wrapper.destroy()
12511245
})
1246+
1247+
it('it allows focus for elements when "no-enforce-focus" enabled', async () => {
1248+
const App = localVue.extend({
1249+
render(h) {
1250+
return h('div', {}, [
1251+
h('button', { attrs: { id: 'button1', type: 'button' } }, 'Button 1'),
1252+
h('button', { attrs: { id: 'button2', type: 'button' } }, 'Button 2'),
1253+
h(
1254+
BModal,
1255+
{
1256+
props: {
1257+
static: true,
1258+
id: 'test',
1259+
visible: true,
1260+
noEnforceFocus: true
1261+
}
1262+
},
1263+
'Modal content'
1264+
)
1265+
])
1266+
}
1267+
})
1268+
const wrapper = mount(App, {
1269+
attachToDocument: true,
1270+
localVue: localVue,
1271+
stubs: {
1272+
transition: false
1273+
}
1274+
})
1275+
1276+
expect(wrapper.isVueInstance()).toBe(true)
1277+
1278+
await waitNT(wrapper.vm)
1279+
await waitRAF()
1280+
await waitNT(wrapper.vm)
1281+
await waitRAF()
1282+
await waitNT(wrapper.vm)
1283+
await waitRAF()
1284+
await waitNT(wrapper.vm)
1285+
await waitRAF()
1286+
1287+
const $button1 = wrapper.find('#button1')
1288+
expect($button1.exists()).toBe(true)
1289+
expect($button1.is('button')).toBe(true)
1290+
1291+
const $button2 = wrapper.find('#button2')
1292+
expect($button2.exists()).toBe(true)
1293+
expect($button2.is('button')).toBe(true)
1294+
1295+
const $modal = wrapper.find('div.modal')
1296+
expect($modal.exists()).toBe(true)
1297+
const $content = $modal.find('div.modal-content')
1298+
expect($content.exists()).toBe(true)
1299+
1300+
expect($modal.element.style.display).toEqual('block')
1301+
expect(document.activeElement).not.toBe(document.body)
1302+
expect(document.activeElement).toBe($content.element)
1303+
1304+
// Try to focus button1
1305+
$button1.element.focus()
1306+
$button1.trigger('focusin')
1307+
await waitNT(wrapper.vm)
1308+
expect(document.activeElement).toBe($button1.element)
1309+
expect(document.activeElement).not.toBe($content.element)
1310+
1311+
// Try to focus button2
1312+
$button2.element.focus()
1313+
$button2.trigger('focusin')
1314+
await waitNT(wrapper.vm)
1315+
expect(document.activeElement).toBe($button2.element)
1316+
expect(document.activeElement).not.toBe($content.element)
1317+
1318+
wrapper.destroy()
1319+
})
1320+
1321+
it('it allows focus for elements in "ignore-enforce-focus-selector" prop', async () => {
1322+
const App = localVue.extend({
1323+
render(h) {
1324+
return h('div', {}, [
1325+
h('button', { attrs: { id: 'button1', type: 'button' } }, 'Button 1'),
1326+
h('button', { attrs: { id: 'button2', type: 'button' } }, 'Button 2'),
1327+
h(
1328+
BModal,
1329+
{
1330+
props: {
1331+
static: true,
1332+
id: 'test',
1333+
visible: true,
1334+
ignoreEnforceFocusSelector: '#button1'
1335+
}
1336+
},
1337+
'Modal content'
1338+
)
1339+
])
1340+
}
1341+
})
1342+
const wrapper = mount(App, {
1343+
attachToDocument: true,
1344+
localVue: localVue,
1345+
stubs: {
1346+
transition: false
1347+
}
1348+
})
1349+
1350+
expect(wrapper.isVueInstance()).toBe(true)
1351+
1352+
await waitNT(wrapper.vm)
1353+
await waitRAF()
1354+
await waitNT(wrapper.vm)
1355+
await waitRAF()
1356+
await waitNT(wrapper.vm)
1357+
await waitRAF()
1358+
await waitNT(wrapper.vm)
1359+
await waitRAF()
1360+
1361+
const $button1 = wrapper.find('#button1')
1362+
expect($button1.exists()).toBe(true)
1363+
expect($button1.is('button')).toBe(true)
1364+
1365+
const $button2 = wrapper.find('#button2')
1366+
expect($button2.exists()).toBe(true)
1367+
expect($button2.is('button')).toBe(true)
1368+
1369+
const $modal = wrapper.find('div.modal')
1370+
expect($modal.exists()).toBe(true)
1371+
const $content = $modal.find('div.modal-content')
1372+
expect($content.exists()).toBe(true)
1373+
1374+
expect($modal.element.style.display).toEqual('block')
1375+
expect(document.activeElement).not.toBe(document.body)
1376+
expect(document.activeElement).toBe($content.element)
1377+
1378+
// Try to focus button1
1379+
$button1.element.focus()
1380+
$button1.trigger('focusin')
1381+
await waitNT(wrapper.vm)
1382+
expect(document.activeElement).toBe($button1.element)
1383+
expect(document.activeElement).not.toBe($content.element)
1384+
1385+
// Try to focus button2
1386+
$button2.element.focus()
1387+
$button2.trigger('focusin')
1388+
await waitNT(wrapper.vm)
1389+
expect(document.activeElement).not.toBe($button2.element)
1390+
expect(document.activeElement).toBe($content.element)
1391+
1392+
wrapper.destroy()
1393+
})
12521394
})
12531395
})

0 commit comments

Comments
 (0)