Skip to content

Commit ed99f9c

Browse files
authored
fix(b-modal): prevent page scroll when tabbing to bottom of modal + better tab containment in enforceFocus (closes #3842) (#3846)
1 parent 8e956e3 commit ed99f9c

File tree

3 files changed

+183
-45
lines changed

3 files changed

+183
-45
lines changed

src/components/modal/README.md

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,28 @@ You can also apply arbitrary classes to the modal dialog container, content (mod
599599
header, body and footer via the `modal-class`, `content-class`, `header-class`, `body-class` and
600600
`footer-class` props, respectively. The props accept either a string or array of strings.
601601

602+
### Hiding the backdrop
603+
604+
Hide the modal's backdrop via setting the `hide-backdrop` prop.
605+
606+
```html
607+
<div>
608+
<b-button v-b-modal.modal-no-backdrop>Open modal</b-button>
609+
610+
<b-modal id="modal-no-backdrop" hide-backdrop content-class="shadow" title="BootstrapVue">
611+
<p class="my-2">
612+
We've added the utility class <code>'shadow'</code>
613+
to the modal content for added effect.
614+
</p>
615+
</b-modal>
616+
</div>
617+
618+
<!-- modal-no-backdrop.vue -->
619+
```
620+
621+
Note that clicking outside of the modal will still close the modal even though the backdrop is
622+
hidden. You can disable this behaviour by setting the `no-close-on-backdrop` prop on `<b-modal>`.
623+
602624
### Disable open and close animation
603625

604626
To disable the fading transition/animation when modal opens and closes, just set the prop `no-fade`
@@ -916,8 +938,8 @@ export default {
916938
}
917939
```
918940

919-
Refer to the [Events](/docs/components/modal#component-reference) section of documentation for the
920-
full list of events emitted.
941+
Refer to the [Events](#comp-ref-b-modal) section of this documentation for the full list of events
942+
emitted.
921943

922944
## Accessibility
923945

@@ -1048,7 +1070,11 @@ event will be ignored.
10481070
When tabbing through elements within a `<b-modal>`, if focus attempts to leave the modal into the
10491071
document, it will be brought back into the modal.
10501072

1073+
Avoid setting `tabindex` on elements within the modal to any value other than `0` or `-1`. Doing so
1074+
will make it difficult for people who rely on assistive technology to navigate and operate page
1075+
content and can make some of your elements unreachable via keyboard navigation.
1076+
10511077
In some circumstances, you may need to disable the enforce focus feature. You can do this by setting
1052-
the prop `no-enforce-focus`.
1078+
the prop `no-enforce-focus`, although this is highly discouraged.
10531079

10541080
<!-- Component reference added automatically from component package.json -->

src/components/modal/modal.js

Lines changed: 92 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { isBrowser } from '../../utils/env'
1212
import { isString } from '../../utils/inspect'
1313
import { getComponentConfig } from '../../utils/config'
1414
import { stripTags } from '../../utils/html'
15-
import { contains, eventOff, eventOn, isVisible, select } from '../../utils/dom'
15+
import { contains, eventOff, eventOn, isVisible, select, selectAll } from '../../utils/dom'
1616
import { BButton } from '../button/button'
1717
import { BButtonClose } from '../button/button-close'
1818

@@ -33,6 +33,34 @@ const OBSERVER_CONFIG = {
3333
// Options for DOM event listeners
3434
const EVT_OPTIONS = { passive: true, capture: false }
3535

36+
// Query selector to find all tabbable elements
37+
// (includes tabindex="-1", which we filter out after)
38+
const TABABLE_SELECTOR = [
39+
'button',
40+
'[href]:not(.disabled)',
41+
'input',
42+
'select',
43+
'textarea',
44+
'[tabindex]',
45+
'[contenteditable]'
46+
]
47+
.map(s => `${s}:not(:disabled):not([disabled])`)
48+
.join(', ')
49+
50+
// --- Utility methods ---
51+
52+
// Attempt to focus an element, and return true if successful
53+
const attemptFocus = el => {
54+
if (el && isVisible(el) && el.focus) {
55+
try {
56+
el.focus()
57+
} catch {}
58+
}
59+
// If the element has focus, then return true
60+
return document.activeElement === el
61+
}
62+
63+
// --- Props ---
3664
export const props = {
3765
size: {
3866
type: String,
@@ -297,7 +325,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({
297325
this.headerClass
298326
]
299327
},
300-
titleClases() {
328+
titleClasses() {
301329
return [{ 'sr-only': this.titleSrOnly }, this.titleClass]
302330
},
303331
bodyClasses() {
@@ -402,7 +430,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({
402430
// Public method to show modal
403431
show() {
404432
if (this.isVisible || this.isOpening) {
405-
// If already open, on in the process of opening, do nothing
433+
// If already open, or in the process of opening, do nothing
406434
/* istanbul ignore next */
407435
return
408436
}
@@ -497,6 +525,14 @@ export const BModal = /*#__PURE__*/ Vue.extend({
497525
}
498526
return null
499527
},
528+
// Private method to get a list of all tabable elements within modal content
529+
getTabables() {
530+
// Find all tabable elements in the modal content
531+
// Assumes users have not used tabindex > 0 on elements!
532+
return selectAll(TABABLE_SELECTOR, this.$refs.content)
533+
.filter(isVisible)
534+
.filter(i => i.tabIndex > -1 && !i.disabled)
535+
},
500536
// Private method to finish showing modal
501537
doShow() {
502538
/* istanbul ignore next: commenting out for now until we can test stacking */
@@ -547,6 +583,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({
547583
onBeforeLeave() {
548584
this.isTransitioning = true
549585
this.setResizeEvent(false)
586+
this.setEnforceFocus(false)
550587
},
551588
onLeave() {
552589
// Remove the 'show' class
@@ -555,14 +592,12 @@ export const BModal = /*#__PURE__*/ Vue.extend({
555592
onAfterLeave() {
556593
this.isBlock = false
557594
this.isTransitioning = false
558-
this.setEnforceFocus(false)
559595
this.isModalOverflowing = false
560596
this.isHidden = true
561597
this.$nextTick(() => {
562-
this.returnFocusTo()
563598
this.isClosing = false
564-
this.return_focus = null
565599
modalManager.unregisterModal(this)
600+
this.returnFocusTo()
566601
// TODO: Need to find a way to pass the `trigger` property
567602
// to the `hidden` event, not just only the `hide` event
568603
this.emitEvent(this.buildEvent('hidden'))
@@ -623,17 +658,35 @@ export const BModal = /*#__PURE__*/ Vue.extend({
623658
},
624659
// Document focusin listener
625660
focusHandler(evt) {
626-
// If focus leaves modal, bring it back
627-
const modal = this.$refs.modal
661+
// If focus leaves modal content, bring it back
662+
const content = this.$refs.content
663+
const target = evt.target
628664
if (
629665
!this.noEnforceFocus &&
630666
this.isTop &&
631667
this.isVisible &&
632-
modal &&
633-
document !== evt.target &&
634-
!contains(modal, evt.target)
668+
content &&
669+
document !== target &&
670+
!contains(content, target)
635671
) {
636-
modal.focus({ preventScroll: true })
672+
const tabables = this.getTabables()
673+
if (this.$refs.bottomTrap && target === this.$refs.bottomTrap) {
674+
// If user pressed TAB out of modal into our bottom trab trap element
675+
// Find the first tabable element in the modal content and focus it
676+
if (attemptFocus(tabables[0])) {
677+
// Focus was successful
678+
return
679+
}
680+
} else if (this.$refs.topTrap && target === this.$refs.topTrap) {
681+
// If user pressed CTRL-TAB out of modal and into our top tab trap element
682+
// Find the last tabable element in the modal content and focus it
683+
if (attemptFocus(tabables[tabables.length - 1])) {
684+
// Focus was successful
685+
return
686+
}
687+
}
688+
// Otherwise focus the modal content container
689+
content.focus({ preventScroll: true })
637690
}
638691
},
639692
// Turn on/off focusin listener
@@ -677,14 +730,15 @@ export const BModal = /*#__PURE__*/ Vue.extend({
677730
// Don't try and focus if we are SSR
678731
if (isBrowser) {
679732
const modal = this.$refs.modal
733+
const content = this.$refs.content
680734
const activeElement = this.getActiveElement()
681735
// If the modal contains the activeElement, we don't do anything
682-
if (modal && !(activeElement && contains(modal, activeElement))) {
736+
if (modal && content && !(activeElement && contains(content, activeElement))) {
683737
// Make sure top of modal is showing (if longer than the viewport)
684738
// and focus the modal content wrapper
685739
this.$nextTick(() => {
686740
modal.scrollTop = 0
687-
modal.focus()
741+
content.focus()
688742
})
689743
}
690744
}
@@ -693,15 +747,16 @@ export const BModal = /*#__PURE__*/ Vue.extend({
693747
// Prefer `returnFocus` prop over event specified
694748
// `return_focus` value
695749
let el = this.returnFocus || this.return_focus || null
696-
// Is el a string CSS selector?
697-
el = isString(el) ? select(el) : el
698-
if (el) {
699-
// Possibly could be a component reference
700-
el = el.$el || el
701-
if (isVisible(el) && el.focus) {
702-
el.focus()
750+
this.return_focus = null
751+
this.$nextTick(() => {
752+
// Is el a string CSS selector?
753+
el = isString(el) ? select(el) : el
754+
if (el) {
755+
// Possibly could be a component reference
756+
el = el.$el || el
757+
attemptFocus(el)
703758
}
704-
}
759+
})
705760
},
706761
checkModalOverflow() {
707762
if (this.isVisible) {
@@ -739,7 +794,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({
739794
this.titleTag,
740795
{
741796
staticClass: 'modal-title',
742-
class: this.titleClases,
797+
class: this.titleClasses,
743798
attrs: { id: this.safeId('__BV_modal_title_') },
744799
domProps
745800
},
@@ -835,21 +890,32 @@ export const BModal = /*#__PURE__*/ Vue.extend({
835890
class: this.contentClass,
836891
attrs: {
837892
role: 'document',
838-
id: this.safeId('__BV_modal_content_')
893+
id: this.safeId('__BV_modal_content_'),
894+
tabindex: '-1'
839895
}
840896
},
841897
[header, body, footer]
842898
)
843899

900+
// Tab trap to prevent page from scrolling to next element in
901+
// tab index during enforce focus tab cycle
902+
let tabTrapTop = h()
903+
let tabTrapBottom = h()
904+
if (this.isVisible && !this.noEnforceFocus) {
905+
tabTrapTop = h('span', { ref: 'topTrap', attrs: { tabindex: '0' } })
906+
tabTrapBottom = h('span', { ref: 'bottomTrap', attrs: { tabindex: '0' } })
907+
}
908+
844909
// Modal dialog wrapper
845910
const modalDialog = h(
846911
'div',
847912
{
913+
ref: 'dialog',
848914
staticClass: 'modal-dialog',
849915
class: this.dialogClasses,
850916
on: { mousedown: this.onDialogMousedown }
851917
},
852-
[modalContent]
918+
[tabTrapTop, modalContent, tabTrapBottom]
853919
)
854920

855921
// Modal
@@ -866,7 +932,6 @@ export const BModal = /*#__PURE__*/ Vue.extend({
866932
attrs: {
867933
id: this.safeId(),
868934
role: 'dialog',
869-
tabindex: '-1',
870935
'aria-hidden': this.isVisible ? null : 'true',
871936
'aria-modal': this.isVisible ? 'true' : null,
872937
'aria-label': this.ariaLabel,
@@ -921,12 +986,6 @@ export const BModal = /*#__PURE__*/ Vue.extend({
921986
}
922987
backdrop = h(BVTransition, { props: { noFade: this.noFade } }, [backdrop])
923988

924-
// Tab trap to prevent page from scrolling to next element in
925-
// tab index during enforce focus tab cycle
926-
let tabTrap = h()
927-
if (this.isVisible && this.isTop && !this.noEnforceFocus) {
928-
tabTrap = h('div', { attrs: { tabindex: '0' } })
929-
}
930989
// Assemble modal and backdrop in an outer <div>
931990
return h(
932991
'div',
@@ -935,7 +994,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({
935994
style: this.modalOuterStyle,
936995
attrs: { id: this.safeId('__BV_modal_outer_') }
937996
},
938-
[modal, tabTrap, backdrop]
997+
[modal, backdrop]
939998
)
940999
}
9411000
},

0 commit comments

Comments
 (0)