diff --git a/src/components/modal/README.md b/src/components/modal/README.md
index 12f00e32166..d0fbe4ad214 100644
--- a/src/components/modal/README.md
+++ b/src/components/modal/README.md
@@ -1156,8 +1156,11 @@ Avoid setting `tabindex` on elements within the modal to any value other than `0
will make it difficult for people who rely on assistive technology to navigate and operate page
content and can make some of your elements unreachable via keyboard navigation.
-In some circumstances, you may need to disable the enforce focus feature. You can do this by setting
-the prop `no-enforce-focus`, although this is highly discouraged.
+If some elements outside the modal need to be focusable (i.e. for TinyMCE), you can add them to the
+`ignore-enforce-focus-selector` prop.
+
+In some circumstances, you may need to disable the enforce focus feature completely. You can do this
+by setting the prop `no-enforce-focus`, although this is highly discouraged.
### `v-b-modal` directive accessibility
diff --git a/src/components/modal/modal.js b/src/components/modal/modal.js
index b1c625513c2..80372b80dcf 100644
--- a/src/components/modal/modal.js
+++ b/src/components/modal/modal.js
@@ -1,10 +1,12 @@
import Vue from '../../utils/vue'
import BVTransition from '../../utils/bv-transition'
import KeyCodes from '../../utils/key-codes'
+import identity from '../../utils/identity'
import observeDom from '../../utils/observe-dom'
-import { arrayIncludes } from '../../utils/array'
+import { arrayIncludes, concat } from '../../utils/array'
import { getComponentConfig } from '../../utils/config'
import {
+ closest,
contains,
eventOff,
eventOn,
@@ -111,6 +113,10 @@ export const props = {
type: Boolean,
default: false
},
+ ignoreEnforceFocusSelector: {
+ type: [Array, String],
+ default: ''
+ },
title: {
type: String,
default: ''
@@ -396,6 +402,13 @@ export const BModal = /*#__PURE__*/ Vue.extend({
hide: this.hide,
visible: this.isVisible
}
+ },
+ computeIgnoreEnforceFocusSelector() {
+ // Normalize to an single selector with selectors separated by `,`
+ return concat(this.ignoreEnforceFocusSelector)
+ .filter(identity)
+ .join(',')
+ .trim()
}
},
watch: {
@@ -701,34 +714,38 @@ export const BModal = /*#__PURE__*/ Vue.extend({
focusHandler(evt) {
// If focus leaves modal content, bring it back
const content = this.$refs.content
- const target = evt.target
+ const { target } = evt
if (
- !this.noEnforceFocus &&
- this.isTop &&
- this.isVisible &&
- content &&
- document !== target &&
- !contains(content, target)
+ this.noEnforceFocus ||
+ !this.isTop ||
+ !this.isVisible ||
+ !content ||
+ document === target ||
+ contains(content, target) ||
+ (this.computeIgnoreEnforceFocusSelector &&
+ closest(this.computeIgnoreEnforceFocusSelector, target, true))
) {
- const tabables = this.getTabables()
- if (this.$refs.bottomTrap && target === this.$refs.bottomTrap) {
- // If user pressed TAB out of modal into our bottom trab trap element
- // Find the first tabable element in the modal content and focus it
- if (attemptFocus(tabables[0])) {
- // Focus was successful
- return
- }
- } else if (this.$refs.topTrap && target === this.$refs.topTrap) {
- // If user pressed CTRL-TAB out of modal and into our top tab trap element
- // Find the last tabable element in the modal content and focus it
- if (attemptFocus(tabables[tabables.length - 1])) {
- // Focus was successful
- return
- }
+ return
+ }
+ const tabables = this.getTabables()
+ const { bottomTrap, topTrap } = this.$refs
+ if (bottomTrap && target === bottomTrap) {
+ // If user pressed TAB out of modal into our bottom trab trap element
+ // Find the first tabable element in the modal content and focus it
+ if (attemptFocus(tabables[0])) {
+ // Focus was successful
+ return
+ }
+ } else if (topTrap && target === topTrap) {
+ // If user pressed CTRL-TAB out of modal and into our top tab trap element
+ // Find the last tabable element in the modal content and focus it
+ if (attemptFocus(tabables[tabables.length - 1])) {
+ // Focus was successful
+ return
}
- // Otherwise focus the modal content container
- content.focus({ preventScroll: true })
}
+ // Otherwise focus the modal content container
+ content.focus({ preventScroll: true })
},
// Turn on/off focusin listener
setEnforceFocus(on) {
diff --git a/src/components/modal/modal.spec.js b/src/components/modal/modal.spec.js
index 723a11c6037..677abde0e81 100644
--- a/src/components/modal/modal.spec.js
+++ b/src/components/modal/modal.spec.js
@@ -179,7 +179,7 @@ describe('modal', () => {
},
propsData: {
static: false,
- id: 'testtarget',
+ id: 'test-target',
visible: true
}
})
@@ -190,7 +190,7 @@ describe('modal', () => {
expect(wrapper.isEmpty()).toBe(true)
expect(wrapper.element.nodeType).toEqual(Node.COMMENT_NODE)
- const outer = document.getElementById('testtarget___BV_modal_outer_')
+ const outer = document.getElementById('test-target___BV_modal_outer_')
expect(outer).toBeDefined()
expect(outer).not.toBe(null)
@@ -205,7 +205,7 @@ describe('modal', () => {
await waitNT(wrapper.vm)
await waitRAF()
- // Should no longer be in document.
+ // Should no longer be in document
expect(outer.parentElement).toEqual(null)
})
@@ -358,14 +358,14 @@ describe('modal', () => {
const $cancel = $buttons.at(0)
expect($cancel.attributes('type')).toBe('button')
expect($cancel.text()).toContain('cancel')
- // v-html is applied to a span
+ // `v-html` is applied to a span
expect($cancel.html()).toContain('cancel')
// OK button (right-most button)
const $ok = $buttons.at(1)
expect($ok.attributes('type')).toBe('button')
expect($ok.text()).toContain('ok')
- // v-html is applied to a span
+ // `v-html` is applied to a span
expect($ok.html()).toContain('ok')
wrapper.destroy()
@@ -1161,8 +1161,8 @@ describe('modal', () => {
const App = localVue.extend({
render(h) {
return h('div', {}, [
- h('button', { class: 'trigger', attrs: { id: 'trigger', type: 'button' } }, 'trigger'),
- h(BModal, { props: { static: true, id: 'test', visible: true } }, 'modal content')
+ h('button', { attrs: { id: 'button', type: 'button' } }, 'Button'),
+ h(BModal, { props: { static: true, id: 'test', visible: true } }, 'Modal content')
])
}
})
@@ -1185,7 +1185,7 @@ describe('modal', () => {
await waitNT(wrapper.vm)
await waitRAF()
- const $button = wrapper.find('button.trigger')
+ const $button = wrapper.find('#button')
expect($button.exists()).toBe(true)
expect($button.is('button')).toBe(true)
@@ -1198,48 +1198,42 @@ describe('modal', () => {
expect(document.activeElement).not.toBe(document.body)
expect(document.activeElement).toBe($content.element)
- // Try and set focusin on external button
+ // Try and focus the external button
+ $button.element.focus()
$button.trigger('focusin')
- await waitNT(wrapper.vm)
- expect(document.activeElement).not.toBe($button.element)
- expect(document.activeElement).toBe($content.element)
-
- // Try and set focusin on external button
- $button.trigger('focus')
- await waitNT(wrapper.vm)
expect(document.activeElement).not.toBe($button.element)
expect(document.activeElement).toBe($content.element)
- // Emulate TAB by focusing the `bottomTrap` span element.
+ // Emulate TAB by focusing the `bottomTrap` span element
// Should focus first button in modal (in the header)
const $bottomTrap = wrapper.find(BModal).find({ ref: 'bottomTrap' })
expect($bottomTrap.exists()).toBe(true)
expect($bottomTrap.is('span')).toBe(true)
- // Find the close (x) button (it is the only one with the .close class)
+ // Find the close (x) button (it is the only one with the `.close` class)
const $closeButton = $modal.find('button.close')
expect($closeButton.exists()).toBe(true)
expect($closeButton.is('button')).toBe(true)
- // focus the tab trap
+ // Focus the tab trap
+ $bottomTrap.element.focus()
$bottomTrap.trigger('focusin')
- $bottomTrap.trigger('focus')
await waitNT(wrapper.vm)
expect(document.activeElement).not.toBe($bottomTrap.element)
expect(document.activeElement).not.toBe($content.element)
// The close (x) button (first tabable in modal) should be focused
expect(document.activeElement).toBe($closeButton.element)
- // Emulate CTRL-TAB by focusing the `topTrap` div element.
+ // Emulate CTRL-TAB by focusing the `topTrap` div element
// Should focus last button in modal (in the footer)
const $topTrap = wrapper.find(BModal).find({ ref: 'topTrap' })
expect($topTrap.exists()).toBe(true)
expect($topTrap.is('span')).toBe(true)
- // Find the OK button (it is the only one with .btn-primary class)
+ // Find the OK button (it is the only one with `.btn-primary` class)
const $okButton = $modal.find('button.btn.btn-primary')
expect($okButton.exists()).toBe(true)
expect($okButton.is('button')).toBe(true)
- // focus the tab trap
+ // Focus the tab trap
+ $topTrap.element.focus()
$topTrap.trigger('focusin')
- $topTrap.trigger('focus')
await waitNT(wrapper.vm)
expect(document.activeElement).not.toBe($topTrap.element)
expect(document.activeElement).not.toBe($bottomTrap.element)
@@ -1249,5 +1243,153 @@ describe('modal', () => {
wrapper.destroy()
})
+
+ it('it allows focus for elements when "no-enforce-focus" enabled', async () => {
+ const App = localVue.extend({
+ render(h) {
+ return h('div', {}, [
+ h('button', { attrs: { id: 'button1', type: 'button' } }, 'Button 1'),
+ h('button', { attrs: { id: 'button2', type: 'button' } }, 'Button 2'),
+ h(
+ BModal,
+ {
+ props: {
+ static: true,
+ id: 'test',
+ visible: true,
+ noEnforceFocus: true
+ }
+ },
+ 'Modal content'
+ )
+ ])
+ }
+ })
+ const wrapper = mount(App, {
+ attachToDocument: true,
+ localVue: localVue,
+ stubs: {
+ transition: false
+ }
+ })
+
+ expect(wrapper.isVueInstance()).toBe(true)
+
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+
+ const $button1 = wrapper.find('#button1')
+ expect($button1.exists()).toBe(true)
+ expect($button1.is('button')).toBe(true)
+
+ const $button2 = wrapper.find('#button2')
+ expect($button2.exists()).toBe(true)
+ expect($button2.is('button')).toBe(true)
+
+ const $modal = wrapper.find('div.modal')
+ expect($modal.exists()).toBe(true)
+ const $content = $modal.find('div.modal-content')
+ expect($content.exists()).toBe(true)
+
+ expect($modal.element.style.display).toEqual('block')
+ expect(document.activeElement).not.toBe(document.body)
+ expect(document.activeElement).toBe($content.element)
+
+ // Try to focus button1
+ $button1.element.focus()
+ $button1.trigger('focusin')
+ await waitNT(wrapper.vm)
+ expect(document.activeElement).toBe($button1.element)
+ expect(document.activeElement).not.toBe($content.element)
+
+ // Try to focus button2
+ $button2.element.focus()
+ $button2.trigger('focusin')
+ await waitNT(wrapper.vm)
+ expect(document.activeElement).toBe($button2.element)
+ expect(document.activeElement).not.toBe($content.element)
+
+ wrapper.destroy()
+ })
+
+ it('it allows focus for elements in "ignore-enforce-focus-selector" prop', async () => {
+ const App = localVue.extend({
+ render(h) {
+ return h('div', {}, [
+ h('button', { attrs: { id: 'button1', type: 'button' } }, 'Button 1'),
+ h('button', { attrs: { id: 'button2', type: 'button' } }, 'Button 2'),
+ h(
+ BModal,
+ {
+ props: {
+ static: true,
+ id: 'test',
+ visible: true,
+ ignoreEnforceFocusSelector: '#button1'
+ }
+ },
+ 'Modal content'
+ )
+ ])
+ }
+ })
+ const wrapper = mount(App, {
+ attachToDocument: true,
+ localVue: localVue,
+ stubs: {
+ transition: false
+ }
+ })
+
+ expect(wrapper.isVueInstance()).toBe(true)
+
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+
+ const $button1 = wrapper.find('#button1')
+ expect($button1.exists()).toBe(true)
+ expect($button1.is('button')).toBe(true)
+
+ const $button2 = wrapper.find('#button2')
+ expect($button2.exists()).toBe(true)
+ expect($button2.is('button')).toBe(true)
+
+ const $modal = wrapper.find('div.modal')
+ expect($modal.exists()).toBe(true)
+ const $content = $modal.find('div.modal-content')
+ expect($content.exists()).toBe(true)
+
+ expect($modal.element.style.display).toEqual('block')
+ expect(document.activeElement).not.toBe(document.body)
+ expect(document.activeElement).toBe($content.element)
+
+ // Try to focus button1
+ $button1.element.focus()
+ $button1.trigger('focusin')
+ await waitNT(wrapper.vm)
+ expect(document.activeElement).toBe($button1.element)
+ expect(document.activeElement).not.toBe($content.element)
+
+ // Try to focus button2
+ $button2.element.focus()
+ $button2.trigger('focusin')
+ await waitNT(wrapper.vm)
+ expect(document.activeElement).not.toBe($button2.element)
+ expect(document.activeElement).toBe($content.element)
+
+ wrapper.destroy()
+ })
})
})
diff --git a/src/components/modal/package.json b/src/components/modal/package.json
index d0720f6e8b2..483d8fd8dd4 100644
--- a/src/components/modal/package.json
+++ b/src/components/modal/package.json
@@ -93,6 +93,11 @@
"prop": "noEnforceFocus",
"description": "Disables the enforce focus routine which maintains focus inside the modal"
},
+ {
+ "prop": "ignoreEnforceFocusSelector",
+ "version": "2.4.0",
+ "description": "Ignore certain elements from the enforce focus routine, specified by css selector(s)"
+ },
{
"prop": "titleSrOnly",
"description": "Wraps the title in an '.sr-only' wrapper"