Skip to content

Commit

Permalink
fix(modal): better enforce focus handler. (#2215)
Browse files Browse the repository at this point in the history
  • Loading branch information
tmorehouse committed Nov 25, 2018
1 parent 7d8662b commit 9628de2
Show file tree
Hide file tree
Showing 2 changed files with 60 additions and 48 deletions.
85 changes: 43 additions & 42 deletions src/components/modal/modal.js
Expand Up @@ -12,6 +12,7 @@ import {
isVisible,
selectAll,
select,
contains,
getBCR,
getCS,
addClass,
Expand Down Expand Up @@ -99,7 +100,7 @@ export default {
},
on: {
click: evt => {
this.hide('header-close')
this.hide('headerclose')
}
}
},
Expand Down Expand Up @@ -195,20 +196,10 @@ export default {
ref: 'content',
class: this.contentClasses,
attrs: {
tabindex: '-1',
role: 'document',
'aria-labelledby': this.hideHeader
? null
: this.safeId('__BV_modal_header_'),
id: this.safeId('__BV_modal_content_'),
'aria-labelledby': this.hideHeader ? null : this.safeId('__BV_modal_header_'),
'aria-describedby': this.safeId('__BV_modal_body_')
},
on: {
focusout: this.onFocusout,
click: evt => {
evt.stopPropagation()
// https://github.com/bootstrap-vue/bootstrap-vue/issues/1528
this.$root.$emit('bv::dropdown::shown')
}
}
},
[header, body, footer]
Expand All @@ -230,16 +221,12 @@ export default {
staticClass: 'modal',
class: this.modalClasses,
directives: [
{
name: 'show',
rawName: 'v-show',
value: this.is_visible,
expression: 'is_visible'
}
{ name: 'show', rawName: 'v-show', value: this.is_visible, expression: 'is_visible' }
],
attrs: {
id: this.safeId(),
role: 'dialog',
tabindex: '-1',
'aria-hidden': this.is_visible ? null : 'true'
},
on: {
Expand Down Expand Up @@ -281,6 +268,11 @@ export default {
attrs: { id: this.safeId('__BV_modal_backdrop_') }
})
}
// Tab trap to prevent page from scrolling to next element in tab index during enforce focus tab cycle
let tabTrap = h(false)
if (this.is_visible && this.isTop && !this.noEnforceFocus) {
tabTrap = h('div', { attrs: { tabindex: '0' } })
}
// Assemble modal and backdrop in an outer div needed for lazy modals
let outer = h(false)
if (!this.is_hidden) {
Expand All @@ -289,11 +281,9 @@ export default {
{
key: 'modal-outer',
style: this.modalOuterStyle,
attrs: {
id: this.safeId('__BV_modal_outer_')
}
attrs: { id: this.safeId('__BV_modal_outer_') }
},
[modal, backdrop]
[modal, tabTrap, backdrop]
)
}
// Wrap in DIV to maintain thi.$el reference for hide/show method aceess
Expand Down Expand Up @@ -634,7 +624,6 @@ export default {
},
onEnter () {
this.is_block = true
this.$refs.modal.scrollTop = 0
},
onAfterEnter () {
this.is_show = true
Expand All @@ -648,6 +637,7 @@ export default {
})
this.emitEvent(shownEvt)
this.focusFirst()
this.setEnforceFocus(true)
})
},
onBeforeLeave () {
Expand All @@ -667,6 +657,7 @@ export default {
this.resetScrollbar()
removeClass(document.body, 'modal-open')
}
this.setEnforceFocus(false)
this.$nextTick(() => {
this.is_hidden = this.lazy || false
this.zIndex = 0
Expand All @@ -689,7 +680,7 @@ export default {
// UI Event Handlers
onClickOut (evt) {
// If backdrop clicked, hide modal
if (this.is_visible && !this.noCloseOnBackdrop) {
if (this.is_visible && !this.noCloseOnBackdrop && !contains(this.$refs.content, evt.target)) {
this.hide('backdrop')
}
},
Expand All @@ -703,18 +694,27 @@ export default {
this.hide('esc')
}
},
onFocusout (evt) {
// Document focusin listener
focusHandler (evt) {
// If focus leaves modal, bring it back
// 'focusout' Event Listener bound on content
const content = this.$refs.content
const modal = this.$refs.modal
if (
!this.noEnforceFocus &&
this.isTop &&
this.is_visible &&
content &&
!content.contains(evt.relatedTarget)
modal &&
document !== evt.target &&
!contains(modal, evt.target)
) {
content.focus({preventScroll: true})
modal.focus({preventScroll: true})
}
},
// Turn on/off focusin listener
setEnforceFocus (on) {
if (on) {
eventOn(document, 'focusin', this.focusHandler, false)
} else {
eventOff(document, 'focusin', this.focusHandler, false)
}
},
// Resize Listener
Expand Down Expand Up @@ -755,18 +755,18 @@ export default {
if (typeof document === 'undefined') {
return
}
const content = this.$refs.content
const modal = this.$refs.modal
const activeElement = document.activeElement
if (activeElement && content && content.contains(activeElement)) {
// If activeElement is child of content, no need to change focus
} else if (content) {
if (modal) {
modal.scrollTop = 0
}
// Focus the modal content wrapper
if (activeElement && contains(modal, activeElement)) {
// If activeElement is child of modal or is modal, no need to change focus
return
}
if (modal) {
// make sure top of modal is showing (if longer than the viewport) and
// focus the modal content wrapper
this.$nextTick(() => {
content.focus()
modal.scrollTop = 0
modal.focus()
})
}
},
Expand Down Expand Up @@ -896,7 +896,7 @@ export default {
},
mounted () {
// Listen for events from others to either open or close ourselves
// And to enable/disable enforce focus
// And listen to all modals to enable/disable enforce focus
this.listenOnRoot('bv::show::modal', this.showHandler)
this.listenOnRoot('bv::modal::shown', this.shownHandler)
this.listenOnRoot('bv::hide::modal', this.hideHandler)
Expand All @@ -906,12 +906,13 @@ export default {
this.show()
}
},
beforeDestroy () {
beforeDestroy () /* instanbul ignore next */ {
// Ensure everything is back to normal
if (this._observer) {
this._observer.disconnect()
this._observer = null
}
this.setEnforceFocus(false)
this.setResizeEvent(false)
if (this.is_visible) {
this.is_visible = false
Expand Down
23 changes: 17 additions & 6 deletions src/utils/dom.js
Expand Up @@ -7,6 +7,7 @@ export const isElement = el => {

// Determine if an HTML element is visible - Faster than CSS check
export const isVisible = el => {
/* istanbul ignore next: getBoundingClientRect not avaiable in JSDOM */
return isElement(el) &&
document.body.contains(el) &&
el.getBoundingClientRect().height > 0 &&
Expand All @@ -24,6 +25,7 @@ export const isDisabled = el => {
// Cause/wait-for an element to reflow it's content (adjusting it's height/width)
export const reflow = el => {
// requsting an elements offsetHight will trigger a reflow of the element content
/* istanbul ignore next: reflow doesnt happen in JSDOM */
return isElement(el) && el.offsetHeight
}

Expand Down Expand Up @@ -52,14 +54,14 @@ export const matches = (el, selector) => {
// https://developer.mozilla.org/en-US/docs/Web/API/Element/matches#Polyfill
// Prefer native implementations over polyfill function
const proto = Element.prototype
/* istanbul ignore next */
const Matches = proto.matches ||
proto.matchesSelector ||
proto.mozMatchesSelector ||
proto.msMatchesSelector ||
proto.oMatchesSelector ||
proto.webkitMatchesSelector ||
/* istanbul ignore next */
function (sel) {
function (sel) /* istanbul ignore next */ {
const element = this
const m = selectAll(sel, element.document || element.ownerDocument)
let i = m.length
Expand All @@ -80,8 +82,8 @@ export const closest = (selector, root) => {
// https://developer.mozilla.org/en-US/docs/Web/API/Element/closest
// Since we dont support IE < 10, we can use the "Matches" version of the polyfill for speed
// Prefer native implementation over polyfill function
/* istanbul ignore next */
const Closest = Element.prototype.closest ||
/* istanbul ignore next */
function (sel) {
let element = this
if (!document.documentElement.contains(element)) {
Expand All @@ -102,6 +104,14 @@ export const closest = (selector, root) => {
return el === root ? null : el
}

// Returns true if the parent element contains the child element
export const contains = (parent, child) => {
if (!parent || typeof parent.contains !== 'function') {
return false
}
return parent.contains(child)
}

// Get an element given an ID
export const getById = id => {
return document.getElementById(/^#/.test(id) ? id.slice(1) : id) || null
Expand Down Expand Up @@ -160,21 +170,21 @@ export const hasAttr = (el, attr) => {
}

// Return the Bounding Client Rec of an element. Retruns null if not an element
/* istanbul ignore next: getBoundingClientRect() doesnt work in JSDOM */
export const getBCR = el => {
/* istanbul ignore next: getBoundingClientRect() doesnt work in JSDOM */
return isElement(el) ? el.getBoundingClientRect() : null
}

// Get computed style object for an element
/* istanbul ignore next: getComputedStyle() doesnt work in JSDOM */
export const getCS = el => {
/* istanbul ignore next: getComputedStyle() doesnt work in JSDOM */
return isElement(el) ? window.getComputedStyle(el) : {}
}

// Return an element's offset wrt document element
// https://j11y.io/jquery/#v=git&fn=jQuery.fn.offset
/* istanbul ignore next: getBoundingClientRect(), getClientRects() doesnt work in JSDOM */
export const offset = el => {
/* istanbul ignore if: getClientRects() doesnt work in JSDOM */
if (isElement(el)) {
if (!el.getClientRects().length) {
return { top: 0, left: 0 }
Expand All @@ -190,6 +200,7 @@ export const offset = el => {

// Return an element's offset wrt to it's offsetParent
// https://j11y.io/jquery/#v=git&fn=jQuery.fn.position
/* istanbul ignore next: getBoundingClientRect(), getClientRects() doesnt work in JSDOM */
export const position = el => {
if (!isElement(el)) {
return
Expand Down

0 comments on commit 9628de2

Please sign in to comment.