@@ -12,7 +12,7 @@ import { isBrowser } from '../../utils/env'
1212import { isString } from '../../utils/inspect'
1313import { getComponentConfig } from '../../utils/config'
1414import { 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'
1616import { BButton } from '../button/button'
1717import { BButtonClose } from '../button/button-close'
1818
@@ -33,6 +33,34 @@ const OBSERVER_CONFIG = {
3333// Options for DOM event listeners
3434const 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 ---
3664export 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