Skip to content
Permalink
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>
  • Loading branch information
jacobmllr95 and tmorehouse committed Jan 31, 2020
1 parent 6e0b852 commit c3ac99283927b5261d1df05d3c479c534011d7c5
Showing with 218 additions and 51 deletions.
  1. +5 −2 src/components/modal/README.md
  2. +42 −25 src/components/modal/modal.js
  3. +166 −24 src/components/modal/modal.spec.js
  4. +5 −0 src/components/modal/package.json
@@ -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

@@ -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) {
@@ -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('<span><em>cancel</em></span>')

// 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('<span><em>ok</em></span>')

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()
})
})
})

0 comments on commit c3ac992

Please sign in to comment.