mirror of
https://github.com/twbs/bootstrap.git
synced 2025-08-06 21:56:42 +02:00
Add shift-tab keyboard support for dialogs (modal & Offcanvas components) (#33865)
* consolidate dialog focus trap logic * add shift-tab support to focustrap * remove redundant null check of trap element Co-authored-by: GeoSot <geo.sotis@gmail.com> * remove area support forom focusableChildren * fix no expectations warning in focustrap tests Co-authored-by: GeoSot <geo.sotis@gmail.com> Co-authored-by: XhmikosR <xhmikosr@gmail.com>
This commit is contained in:
@@ -11,6 +11,8 @@
|
||||
* ------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { isDisabled, isVisible } from '../util/index'
|
||||
|
||||
const NODE_TEXT = 3
|
||||
|
||||
const SelectorEngine = {
|
||||
@@ -69,6 +71,21 @@ const SelectorEngine = {
|
||||
}
|
||||
|
||||
return []
|
||||
},
|
||||
|
||||
focusableChildren(element) {
|
||||
const focusables = [
|
||||
'a',
|
||||
'button',
|
||||
'input',
|
||||
'textarea',
|
||||
'select',
|
||||
'details',
|
||||
'[tabindex]',
|
||||
'[contenteditable="true"]'
|
||||
].map(selector => `${selector}:not([tabindex^="-"])`).join(', ')
|
||||
|
||||
return this.find(focusables, element).filter(el => !isDisabled(el) && isVisible(el))
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -19,6 +19,7 @@ import SelectorEngine from './dom/selector-engine'
|
||||
import ScrollBarHelper from './util/scrollbar'
|
||||
import BaseComponent from './base-component'
|
||||
import Backdrop from './util/backdrop'
|
||||
import FocusTrap from './util/focustrap'
|
||||
|
||||
/**
|
||||
* ------------------------------------------------------------------------
|
||||
@@ -49,7 +50,6 @@ const EVENT_HIDE_PREVENTED = `hidePrevented${EVENT_KEY}`
|
||||
const EVENT_HIDDEN = `hidden${EVENT_KEY}`
|
||||
const EVENT_SHOW = `show${EVENT_KEY}`
|
||||
const EVENT_SHOWN = `shown${EVENT_KEY}`
|
||||
const EVENT_FOCUSIN = `focusin${EVENT_KEY}`
|
||||
const EVENT_RESIZE = `resize${EVENT_KEY}`
|
||||
const EVENT_CLICK_DISMISS = `click.dismiss${EVENT_KEY}`
|
||||
const EVENT_KEYDOWN_DISMISS = `keydown.dismiss${EVENT_KEY}`
|
||||
@@ -81,6 +81,7 @@ class Modal extends BaseComponent {
|
||||
this._config = this._getConfig(config)
|
||||
this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, this._element)
|
||||
this._backdrop = this._initializeBackDrop()
|
||||
this._focustrap = this._initializeFocusTrap()
|
||||
this._isShown = false
|
||||
this._ignoreBackdropClick = false
|
||||
this._isTransitioning = false
|
||||
@@ -167,7 +168,7 @@ class Modal extends BaseComponent {
|
||||
this._setEscapeEvent()
|
||||
this._setResizeEvent()
|
||||
|
||||
EventHandler.off(document, EVENT_FOCUSIN)
|
||||
this._focustrap.deactivate()
|
||||
|
||||
this._element.classList.remove(CLASS_NAME_SHOW)
|
||||
|
||||
@@ -182,14 +183,8 @@ class Modal extends BaseComponent {
|
||||
.forEach(htmlElement => EventHandler.off(htmlElement, EVENT_KEY))
|
||||
|
||||
this._backdrop.dispose()
|
||||
this._focustrap.deactivate()
|
||||
super.dispose()
|
||||
|
||||
/**
|
||||
* `document` has 2 events `EVENT_FOCUSIN` and `EVENT_CLICK_DATA_API`
|
||||
* Do not move `document` in `htmlElements` array
|
||||
* It will remove `EVENT_CLICK_DATA_API` event that should remain
|
||||
*/
|
||||
EventHandler.off(document, EVENT_FOCUSIN)
|
||||
}
|
||||
|
||||
handleUpdate() {
|
||||
@@ -205,6 +200,12 @@ class Modal extends BaseComponent {
|
||||
})
|
||||
}
|
||||
|
||||
_initializeFocusTrap() {
|
||||
return new FocusTrap({
|
||||
trapElement: this._element
|
||||
})
|
||||
}
|
||||
|
||||
_getConfig(config) {
|
||||
config = {
|
||||
...Default,
|
||||
@@ -240,13 +241,9 @@ class Modal extends BaseComponent {
|
||||
|
||||
this._element.classList.add(CLASS_NAME_SHOW)
|
||||
|
||||
if (this._config.focus) {
|
||||
this._enforceFocus()
|
||||
}
|
||||
|
||||
const transitionComplete = () => {
|
||||
if (this._config.focus) {
|
||||
this._element.focus()
|
||||
this._focustrap.activate()
|
||||
}
|
||||
|
||||
this._isTransitioning = false
|
||||
@@ -258,17 +255,6 @@ class Modal extends BaseComponent {
|
||||
this._queueCallback(transitionComplete, this._dialog, isAnimated)
|
||||
}
|
||||
|
||||
_enforceFocus() {
|
||||
EventHandler.off(document, EVENT_FOCUSIN) // guard against infinite focus loop
|
||||
EventHandler.on(document, EVENT_FOCUSIN, event => {
|
||||
if (document !== event.target &&
|
||||
this._element !== event.target &&
|
||||
!this._element.contains(event.target)) {
|
||||
this._element.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_setEscapeEvent() {
|
||||
if (this._isShown) {
|
||||
EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS, event => {
|
||||
|
@@ -18,6 +18,7 @@ import BaseComponent from './base-component'
|
||||
import SelectorEngine from './dom/selector-engine'
|
||||
import Manipulator from './dom/manipulator'
|
||||
import Backdrop from './util/backdrop'
|
||||
import FocusTrap from './util/focustrap'
|
||||
|
||||
/**
|
||||
* ------------------------------------------------------------------------
|
||||
@@ -52,7 +53,6 @@ const EVENT_SHOW = `show${EVENT_KEY}`
|
||||
const EVENT_SHOWN = `shown${EVENT_KEY}`
|
||||
const EVENT_HIDE = `hide${EVENT_KEY}`
|
||||
const EVENT_HIDDEN = `hidden${EVENT_KEY}`
|
||||
const EVENT_FOCUSIN = `focusin${EVENT_KEY}`
|
||||
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
|
||||
const EVENT_CLICK_DISMISS = `click.dismiss${EVENT_KEY}`
|
||||
const EVENT_KEYDOWN_DISMISS = `keydown.dismiss${EVENT_KEY}`
|
||||
@@ -73,6 +73,7 @@ class Offcanvas extends BaseComponent {
|
||||
this._config = this._getConfig(config)
|
||||
this._isShown = false
|
||||
this._backdrop = this._initializeBackDrop()
|
||||
this._focustrap = this._initializeFocusTrap()
|
||||
this._addEventListeners()
|
||||
}
|
||||
|
||||
@@ -110,7 +111,6 @@ class Offcanvas extends BaseComponent {
|
||||
|
||||
if (!this._config.scroll) {
|
||||
new ScrollBarHelper().hide()
|
||||
this._enforceFocusOnElement(this._element)
|
||||
}
|
||||
|
||||
this._element.removeAttribute('aria-hidden')
|
||||
@@ -119,6 +119,10 @@ class Offcanvas extends BaseComponent {
|
||||
this._element.classList.add(CLASS_NAME_SHOW)
|
||||
|
||||
const completeCallBack = () => {
|
||||
if (!this._config.scroll) {
|
||||
this._focustrap.activate()
|
||||
}
|
||||
|
||||
EventHandler.trigger(this._element, EVENT_SHOWN, { relatedTarget })
|
||||
}
|
||||
|
||||
@@ -136,7 +140,7 @@ class Offcanvas extends BaseComponent {
|
||||
return
|
||||
}
|
||||
|
||||
EventHandler.off(document, EVENT_FOCUSIN)
|
||||
this._focustrap.deactivate()
|
||||
this._element.blur()
|
||||
this._isShown = false
|
||||
this._element.classList.remove(CLASS_NAME_SHOW)
|
||||
@@ -160,8 +164,8 @@ class Offcanvas extends BaseComponent {
|
||||
|
||||
dispose() {
|
||||
this._backdrop.dispose()
|
||||
this._focustrap.deactivate()
|
||||
super.dispose()
|
||||
EventHandler.off(document, EVENT_FOCUSIN)
|
||||
}
|
||||
|
||||
// Private
|
||||
@@ -186,16 +190,10 @@ class Offcanvas extends BaseComponent {
|
||||
})
|
||||
}
|
||||
|
||||
_enforceFocusOnElement(element) {
|
||||
EventHandler.off(document, EVENT_FOCUSIN) // guard against infinite focus loop
|
||||
EventHandler.on(document, EVENT_FOCUSIN, event => {
|
||||
if (document !== event.target &&
|
||||
element !== event.target &&
|
||||
!element.contains(event.target)) {
|
||||
element.focus()
|
||||
}
|
||||
_initializeFocusTrap() {
|
||||
return new FocusTrap({
|
||||
trapElement: this._element
|
||||
})
|
||||
element.focus()
|
||||
}
|
||||
|
||||
_addEventListeners() {
|
||||
|
109
js/src/util/focustrap.js
Normal file
109
js/src/util/focustrap.js
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Bootstrap (v5.0.2): util/focustrap.js
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import EventHandler from '../dom/event-handler'
|
||||
import SelectorEngine from '../dom/selector-engine'
|
||||
import { typeCheckConfig } from './index'
|
||||
|
||||
const Default = {
|
||||
trapElement: null, // The element to trap focus inside of
|
||||
autofocus: true
|
||||
}
|
||||
|
||||
const DefaultType = {
|
||||
trapElement: 'element',
|
||||
autofocus: 'boolean'
|
||||
}
|
||||
|
||||
const NAME = 'focustrap'
|
||||
const DATA_KEY = 'bs.focustrap'
|
||||
const EVENT_KEY = `.${DATA_KEY}`
|
||||
const EVENT_FOCUSIN = `focusin${EVENT_KEY}`
|
||||
const EVENT_KEYDOWN_TAB = `keydown.tab${EVENT_KEY}`
|
||||
|
||||
const TAB_KEY = 'Tab'
|
||||
const TAB_NAV_FORWARD = 'forward'
|
||||
const TAB_NAV_BACKWARD = 'backward'
|
||||
|
||||
class FocusTrap {
|
||||
constructor(config) {
|
||||
this._config = this._getConfig(config)
|
||||
this._isActive = false
|
||||
this._lastTabNavDirection = null
|
||||
}
|
||||
|
||||
activate() {
|
||||
const { trapElement, autofocus } = this._config
|
||||
|
||||
if (this._isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
if (autofocus) {
|
||||
trapElement.focus()
|
||||
}
|
||||
|
||||
EventHandler.off(document, EVENT_KEY) // guard against infinite focus loop
|
||||
EventHandler.on(document, EVENT_FOCUSIN, event => this._handleFocusin(event))
|
||||
EventHandler.on(document, EVENT_KEYDOWN_TAB, event => this._handleKeydown(event))
|
||||
|
||||
this._isActive = true
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
if (!this._isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
this._isActive = false
|
||||
EventHandler.off(document, EVENT_KEY)
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
_handleFocusin(event) {
|
||||
const { target } = event
|
||||
const { trapElement } = this._config
|
||||
|
||||
if (
|
||||
target === document ||
|
||||
target === trapElement ||
|
||||
trapElement.contains(target)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const elements = SelectorEngine.focusableChildren(trapElement)
|
||||
|
||||
if (elements.length === 0) {
|
||||
trapElement.focus()
|
||||
} else if (this._lastTabNavDirection === TAB_NAV_BACKWARD) {
|
||||
elements[elements.length - 1].focus()
|
||||
} else {
|
||||
elements[0].focus()
|
||||
}
|
||||
}
|
||||
|
||||
_handleKeydown(event) {
|
||||
if (event.key !== TAB_KEY) {
|
||||
return
|
||||
}
|
||||
|
||||
this._lastTabNavDirection = event.shiftKey ? TAB_NAV_BACKWARD : TAB_NAV_FORWARD
|
||||
}
|
||||
|
||||
_getConfig(config) {
|
||||
config = {
|
||||
...Default,
|
||||
...(typeof config === 'object' ? config : {})
|
||||
}
|
||||
typeCheckConfig(NAME, config, DefaultType)
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
export default FocusTrap
|
Reference in New Issue
Block a user