@@ -12,7 +12,7 @@ import { isBrowser } from '../../utils/env'
12
12
import { isString } from '../../utils/inspect'
13
13
import { getComponentConfig } from '../../utils/config'
14
14
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'
16
16
import { BButton } from '../button/button'
17
17
import { BButtonClose } from '../button/button-close'
18
18
@@ -33,6 +33,34 @@ const OBSERVER_CONFIG = {
33
33
// Options for DOM event listeners
34
34
const EVT_OPTIONS = { passive : true , capture : false }
35
35
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 ---
36
64
export const props = {
37
65
size : {
38
66
type : String ,
@@ -297,7 +325,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({
297
325
this . headerClass
298
326
]
299
327
} ,
300
- titleClases ( ) {
328
+ titleClasses ( ) {
301
329
return [ { 'sr-only' : this . titleSrOnly } , this . titleClass ]
302
330
} ,
303
331
bodyClasses ( ) {
@@ -402,7 +430,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({
402
430
// Public method to show modal
403
431
show ( ) {
404
432
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
406
434
/* istanbul ignore next */
407
435
return
408
436
}
@@ -497,6 +525,14 @@ export const BModal = /*#__PURE__*/ Vue.extend({
497
525
}
498
526
return null
499
527
} ,
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
+ } ,
500
536
// Private method to finish showing modal
501
537
doShow ( ) {
502
538
/* istanbul ignore next: commenting out for now until we can test stacking */
@@ -547,6 +583,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({
547
583
onBeforeLeave ( ) {
548
584
this . isTransitioning = true
549
585
this . setResizeEvent ( false )
586
+ this . setEnforceFocus ( false )
550
587
} ,
551
588
onLeave ( ) {
552
589
// Remove the 'show' class
@@ -555,14 +592,12 @@ export const BModal = /*#__PURE__*/ Vue.extend({
555
592
onAfterLeave ( ) {
556
593
this . isBlock = false
557
594
this . isTransitioning = false
558
- this . setEnforceFocus ( false )
559
595
this . isModalOverflowing = false
560
596
this . isHidden = true
561
597
this . $nextTick ( ( ) => {
562
- this . returnFocusTo ( )
563
598
this . isClosing = false
564
- this . return_focus = null
565
599
modalManager . unregisterModal ( this )
600
+ this . returnFocusTo ( )
566
601
// TODO: Need to find a way to pass the `trigger` property
567
602
// to the `hidden` event, not just only the `hide` event
568
603
this . emitEvent ( this . buildEvent ( 'hidden' ) )
@@ -623,17 +658,35 @@ export const BModal = /*#__PURE__*/ Vue.extend({
623
658
} ,
624
659
// Document focusin listener
625
660
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
628
664
if (
629
665
! this . noEnforceFocus &&
630
666
this . isTop &&
631
667
this . isVisible &&
632
- modal &&
633
- document !== evt . target &&
634
- ! contains ( modal , evt . target )
668
+ content &&
669
+ document !== target &&
670
+ ! contains ( content , target )
635
671
) {
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 } )
637
690
}
638
691
} ,
639
692
// Turn on/off focusin listener
@@ -677,14 +730,15 @@ export const BModal = /*#__PURE__*/ Vue.extend({
677
730
// Don't try and focus if we are SSR
678
731
if ( isBrowser ) {
679
732
const modal = this . $refs . modal
733
+ const content = this . $refs . content
680
734
const activeElement = this . getActiveElement ( )
681
735
// 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 ) ) ) {
683
737
// Make sure top of modal is showing (if longer than the viewport)
684
738
// and focus the modal content wrapper
685
739
this . $nextTick ( ( ) => {
686
740
modal . scrollTop = 0
687
- modal . focus ( )
741
+ content . focus ( )
688
742
} )
689
743
}
690
744
}
@@ -693,15 +747,16 @@ export const BModal = /*#__PURE__*/ Vue.extend({
693
747
// Prefer `returnFocus` prop over event specified
694
748
// `return_focus` value
695
749
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 )
703
758
}
704
- }
759
+ } )
705
760
} ,
706
761
checkModalOverflow ( ) {
707
762
if ( this . isVisible ) {
@@ -739,7 +794,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({
739
794
this . titleTag ,
740
795
{
741
796
staticClass : 'modal-title' ,
742
- class : this . titleClases ,
797
+ class : this . titleClasses ,
743
798
attrs : { id : this . safeId ( '__BV_modal_title_' ) } ,
744
799
domProps
745
800
} ,
@@ -835,21 +890,32 @@ export const BModal = /*#__PURE__*/ Vue.extend({
835
890
class : this . contentClass ,
836
891
attrs : {
837
892
role : 'document' ,
838
- id : this . safeId ( '__BV_modal_content_' )
893
+ id : this . safeId ( '__BV_modal_content_' ) ,
894
+ tabindex : '-1'
839
895
}
840
896
} ,
841
897
[ header , body , footer ]
842
898
)
843
899
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
+
844
909
// Modal dialog wrapper
845
910
const modalDialog = h (
846
911
'div' ,
847
912
{
913
+ ref : 'dialog' ,
848
914
staticClass : 'modal-dialog' ,
849
915
class : this . dialogClasses ,
850
916
on : { mousedown : this . onDialogMousedown }
851
917
} ,
852
- [ modalContent ]
918
+ [ tabTrapTop , modalContent , tabTrapBottom ]
853
919
)
854
920
855
921
// Modal
@@ -866,7 +932,6 @@ export const BModal = /*#__PURE__*/ Vue.extend({
866
932
attrs : {
867
933
id : this . safeId ( ) ,
868
934
role : 'dialog' ,
869
- tabindex : '-1' ,
870
935
'aria-hidden' : this . isVisible ? null : 'true' ,
871
936
'aria-modal' : this . isVisible ? 'true' : null ,
872
937
'aria-label' : this . ariaLabel ,
@@ -921,12 +986,6 @@ export const BModal = /*#__PURE__*/ Vue.extend({
921
986
}
922
987
backdrop = h ( BVTransition , { props : { noFade : this . noFade } } , [ backdrop ] )
923
988
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
- }
930
989
// Assemble modal and backdrop in an outer <div>
931
990
return h (
932
991
'div' ,
@@ -935,7 +994,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({
935
994
style : this . modalOuterStyle ,
936
995
attrs : { id : this . safeId ( '__BV_modal_outer_' ) }
937
996
} ,
938
- [ modal , tabTrap , backdrop ]
997
+ [ modal , backdrop ]
939
998
)
940
999
}
941
1000
} ,
0 commit comments