Skip to content

Commit

Permalink
feat(b-modal): add ignore-enforce-focus-selector prop (closes #4537) (
Browse files Browse the repository at this point in the history
#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 authored Jan 31, 2020
1 parent 6e0b852 commit c3ac992
Show file tree
Hide file tree
Showing 4 changed files with 218 additions and 51 deletions.
7 changes: 5 additions & 2 deletions src/components/modal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
67 changes: 42 additions & 25 deletions src/components/modal/modal.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -111,6 +113,10 @@ export const props = {
type: Boolean,
default: false
},
ignoreEnforceFocusSelector: {
type: [Array, String],
default: ''
},
title: {
type: String,
default: ''
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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) {
Expand Down
190 changes: 166 additions & 24 deletions src/components/modal/modal.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ describe('modal', () => {
},
propsData: {
static: false,
id: 'testtarget',
id: 'test-target',
visible: true
}
})
Expand All @@ -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)

Expand All @@ -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)
})

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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')
])
}
})
Expand All @@ -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)

Expand All @@ -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)
Expand All @@ -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()
})
})
})
Loading

0 comments on commit c3ac992

Please sign in to comment.