mirror of
https://github.com/twbs/bootstrap.git
synced 2025-02-20 17:54:23 +01: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:
parent
8536474583
commit
7646f6bd33
@ -34,7 +34,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./dist/js/bootstrap.bundle.js",
|
"path": "./dist/js/bootstrap.bundle.js",
|
||||||
"maxSize": "41.5 kB"
|
"maxSize": "42 kB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./dist/js/bootstrap.bundle.min.js",
|
"path": "./dist/js/bootstrap.bundle.min.js",
|
||||||
@ -42,7 +42,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./dist/js/bootstrap.esm.js",
|
"path": "./dist/js/bootstrap.esm.js",
|
||||||
"maxSize": "27 kB"
|
"maxSize": "27.5 kB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./dist/js/bootstrap.esm.min.js",
|
"path": "./dist/js/bootstrap.esm.min.js",
|
||||||
@ -50,7 +50,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./dist/js/bootstrap.js",
|
"path": "./dist/js/bootstrap.js",
|
||||||
"maxSize": "27.5 kB"
|
"maxSize": "28 kB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./dist/js/bootstrap.min.js",
|
"path": "./dist/js/bootstrap.min.js",
|
||||||
|
@ -11,6 +11,8 @@
|
|||||||
* ------------------------------------------------------------------------
|
* ------------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { isDisabled, isVisible } from '../util/index'
|
||||||
|
|
||||||
const NODE_TEXT = 3
|
const NODE_TEXT = 3
|
||||||
|
|
||||||
const SelectorEngine = {
|
const SelectorEngine = {
|
||||||
@ -69,6 +71,21 @@ const SelectorEngine = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return []
|
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 ScrollBarHelper from './util/scrollbar'
|
||||||
import BaseComponent from './base-component'
|
import BaseComponent from './base-component'
|
||||||
import Backdrop from './util/backdrop'
|
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_HIDDEN = `hidden${EVENT_KEY}`
|
||||||
const EVENT_SHOW = `show${EVENT_KEY}`
|
const EVENT_SHOW = `show${EVENT_KEY}`
|
||||||
const EVENT_SHOWN = `shown${EVENT_KEY}`
|
const EVENT_SHOWN = `shown${EVENT_KEY}`
|
||||||
const EVENT_FOCUSIN = `focusin${EVENT_KEY}`
|
|
||||||
const EVENT_RESIZE = `resize${EVENT_KEY}`
|
const EVENT_RESIZE = `resize${EVENT_KEY}`
|
||||||
const EVENT_CLICK_DISMISS = `click.dismiss${EVENT_KEY}`
|
const EVENT_CLICK_DISMISS = `click.dismiss${EVENT_KEY}`
|
||||||
const EVENT_KEYDOWN_DISMISS = `keydown.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._config = this._getConfig(config)
|
||||||
this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, this._element)
|
this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, this._element)
|
||||||
this._backdrop = this._initializeBackDrop()
|
this._backdrop = this._initializeBackDrop()
|
||||||
|
this._focustrap = this._initializeFocusTrap()
|
||||||
this._isShown = false
|
this._isShown = false
|
||||||
this._ignoreBackdropClick = false
|
this._ignoreBackdropClick = false
|
||||||
this._isTransitioning = false
|
this._isTransitioning = false
|
||||||
@ -167,7 +168,7 @@ class Modal extends BaseComponent {
|
|||||||
this._setEscapeEvent()
|
this._setEscapeEvent()
|
||||||
this._setResizeEvent()
|
this._setResizeEvent()
|
||||||
|
|
||||||
EventHandler.off(document, EVENT_FOCUSIN)
|
this._focustrap.deactivate()
|
||||||
|
|
||||||
this._element.classList.remove(CLASS_NAME_SHOW)
|
this._element.classList.remove(CLASS_NAME_SHOW)
|
||||||
|
|
||||||
@ -182,14 +183,8 @@ class Modal extends BaseComponent {
|
|||||||
.forEach(htmlElement => EventHandler.off(htmlElement, EVENT_KEY))
|
.forEach(htmlElement => EventHandler.off(htmlElement, EVENT_KEY))
|
||||||
|
|
||||||
this._backdrop.dispose()
|
this._backdrop.dispose()
|
||||||
|
this._focustrap.deactivate()
|
||||||
super.dispose()
|
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() {
|
handleUpdate() {
|
||||||
@ -205,6 +200,12 @@ class Modal extends BaseComponent {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_initializeFocusTrap() {
|
||||||
|
return new FocusTrap({
|
||||||
|
trapElement: this._element
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
_getConfig(config) {
|
_getConfig(config) {
|
||||||
config = {
|
config = {
|
||||||
...Default,
|
...Default,
|
||||||
@ -240,13 +241,9 @@ class Modal extends BaseComponent {
|
|||||||
|
|
||||||
this._element.classList.add(CLASS_NAME_SHOW)
|
this._element.classList.add(CLASS_NAME_SHOW)
|
||||||
|
|
||||||
if (this._config.focus) {
|
|
||||||
this._enforceFocus()
|
|
||||||
}
|
|
||||||
|
|
||||||
const transitionComplete = () => {
|
const transitionComplete = () => {
|
||||||
if (this._config.focus) {
|
if (this._config.focus) {
|
||||||
this._element.focus()
|
this._focustrap.activate()
|
||||||
}
|
}
|
||||||
|
|
||||||
this._isTransitioning = false
|
this._isTransitioning = false
|
||||||
@ -258,17 +255,6 @@ class Modal extends BaseComponent {
|
|||||||
this._queueCallback(transitionComplete, this._dialog, isAnimated)
|
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() {
|
_setEscapeEvent() {
|
||||||
if (this._isShown) {
|
if (this._isShown) {
|
||||||
EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS, event => {
|
EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS, event => {
|
||||||
|
@ -18,6 +18,7 @@ import BaseComponent from './base-component'
|
|||||||
import SelectorEngine from './dom/selector-engine'
|
import SelectorEngine from './dom/selector-engine'
|
||||||
import Manipulator from './dom/manipulator'
|
import Manipulator from './dom/manipulator'
|
||||||
import Backdrop from './util/backdrop'
|
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_SHOWN = `shown${EVENT_KEY}`
|
||||||
const EVENT_HIDE = `hide${EVENT_KEY}`
|
const EVENT_HIDE = `hide${EVENT_KEY}`
|
||||||
const EVENT_HIDDEN = `hidden${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_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
|
||||||
const EVENT_CLICK_DISMISS = `click.dismiss${EVENT_KEY}`
|
const EVENT_CLICK_DISMISS = `click.dismiss${EVENT_KEY}`
|
||||||
const EVENT_KEYDOWN_DISMISS = `keydown.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._config = this._getConfig(config)
|
||||||
this._isShown = false
|
this._isShown = false
|
||||||
this._backdrop = this._initializeBackDrop()
|
this._backdrop = this._initializeBackDrop()
|
||||||
|
this._focustrap = this._initializeFocusTrap()
|
||||||
this._addEventListeners()
|
this._addEventListeners()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,7 +111,6 @@ class Offcanvas extends BaseComponent {
|
|||||||
|
|
||||||
if (!this._config.scroll) {
|
if (!this._config.scroll) {
|
||||||
new ScrollBarHelper().hide()
|
new ScrollBarHelper().hide()
|
||||||
this._enforceFocusOnElement(this._element)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this._element.removeAttribute('aria-hidden')
|
this._element.removeAttribute('aria-hidden')
|
||||||
@ -119,6 +119,10 @@ class Offcanvas extends BaseComponent {
|
|||||||
this._element.classList.add(CLASS_NAME_SHOW)
|
this._element.classList.add(CLASS_NAME_SHOW)
|
||||||
|
|
||||||
const completeCallBack = () => {
|
const completeCallBack = () => {
|
||||||
|
if (!this._config.scroll) {
|
||||||
|
this._focustrap.activate()
|
||||||
|
}
|
||||||
|
|
||||||
EventHandler.trigger(this._element, EVENT_SHOWN, { relatedTarget })
|
EventHandler.trigger(this._element, EVENT_SHOWN, { relatedTarget })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,7 +140,7 @@ class Offcanvas extends BaseComponent {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
EventHandler.off(document, EVENT_FOCUSIN)
|
this._focustrap.deactivate()
|
||||||
this._element.blur()
|
this._element.blur()
|
||||||
this._isShown = false
|
this._isShown = false
|
||||||
this._element.classList.remove(CLASS_NAME_SHOW)
|
this._element.classList.remove(CLASS_NAME_SHOW)
|
||||||
@ -160,8 +164,8 @@ class Offcanvas extends BaseComponent {
|
|||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
this._backdrop.dispose()
|
this._backdrop.dispose()
|
||||||
|
this._focustrap.deactivate()
|
||||||
super.dispose()
|
super.dispose()
|
||||||
EventHandler.off(document, EVENT_FOCUSIN)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Private
|
// Private
|
||||||
@ -186,16 +190,10 @@ class Offcanvas extends BaseComponent {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
_enforceFocusOnElement(element) {
|
_initializeFocusTrap() {
|
||||||
EventHandler.off(document, EVENT_FOCUSIN) // guard against infinite focus loop
|
return new FocusTrap({
|
||||||
EventHandler.on(document, EVENT_FOCUSIN, event => {
|
trapElement: this._element
|
||||||
if (document !== event.target &&
|
|
||||||
element !== event.target &&
|
|
||||||
!element.contains(event.target)) {
|
|
||||||
element.focus()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
element.focus()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_addEventListeners() {
|
_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
|
@ -156,5 +156,87 @@ describe('SelectorEngine', () => {
|
|||||||
expect(SelectorEngine.next(divTest, '.btn')).toEqual([btn])
|
expect(SelectorEngine.next(divTest, '.btn')).toEqual([btn])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('focusableChildren', () => {
|
||||||
|
it('should return only elements with specific tag names', () => {
|
||||||
|
fixtureEl.innerHTML = [
|
||||||
|
'<div>lorem</div>',
|
||||||
|
'<span>lorem</span>',
|
||||||
|
'<a>lorem</a>',
|
||||||
|
'<button>lorem</button>',
|
||||||
|
'<input />',
|
||||||
|
'<textarea></textarea>',
|
||||||
|
'<select></select>',
|
||||||
|
'<details>lorem</details>'
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
const expectedElements = [
|
||||||
|
fixtureEl.querySelector('a'),
|
||||||
|
fixtureEl.querySelector('button'),
|
||||||
|
fixtureEl.querySelector('input'),
|
||||||
|
fixtureEl.querySelector('textarea'),
|
||||||
|
fixtureEl.querySelector('select'),
|
||||||
|
fixtureEl.querySelector('details')
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return any element with non negative tab index', () => {
|
||||||
|
fixtureEl.innerHTML = [
|
||||||
|
'<div tabindex>lorem</div>',
|
||||||
|
'<div tabindex="0">lorem</div>',
|
||||||
|
'<div tabindex="10">lorem</div>'
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
const expectedElements = [
|
||||||
|
fixtureEl.querySelector('[tabindex]'),
|
||||||
|
fixtureEl.querySelector('[tabindex="0"]'),
|
||||||
|
fixtureEl.querySelector('[tabindex="10"]')
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return not return elements with negative tab index', () => {
|
||||||
|
fixtureEl.innerHTML = [
|
||||||
|
'<button tabindex="-1">lorem</button>'
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
const expectedElements = []
|
||||||
|
|
||||||
|
expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return contenteditable elements', () => {
|
||||||
|
fixtureEl.innerHTML = [
|
||||||
|
'<div contenteditable="true">lorem</div>'
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
const expectedElements = [fixtureEl.querySelector('[contenteditable="true"]')]
|
||||||
|
|
||||||
|
expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not return disabled elements', () => {
|
||||||
|
fixtureEl.innerHTML = [
|
||||||
|
'<button disabled="true">lorem</button>'
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
const expectedElements = []
|
||||||
|
|
||||||
|
expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not return invisible elements', () => {
|
||||||
|
fixtureEl.innerHTML = [
|
||||||
|
'<button style="display:none;">lorem</button>'
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
const expectedElements = []
|
||||||
|
|
||||||
|
expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -345,7 +345,7 @@ describe('Modal', () => {
|
|||||||
modal.show()
|
modal.show()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not enforce focus if focus equal to false', done => {
|
it('should not trap focus if focus equal to false', done => {
|
||||||
fixtureEl.innerHTML = '<div class="modal fade"><div class="modal-dialog"></div></div>'
|
fixtureEl.innerHTML = '<div class="modal fade"><div class="modal-dialog"></div></div>'
|
||||||
|
|
||||||
const modalEl = fixtureEl.querySelector('.modal')
|
const modalEl = fixtureEl.querySelector('.modal')
|
||||||
@ -353,10 +353,10 @@ describe('Modal', () => {
|
|||||||
focus: false
|
focus: false
|
||||||
})
|
})
|
||||||
|
|
||||||
spyOn(modal, '_enforceFocus')
|
spyOn(modal._focustrap, 'activate').and.callThrough()
|
||||||
|
|
||||||
modalEl.addEventListener('shown.bs.modal', () => {
|
modalEl.addEventListener('shown.bs.modal', () => {
|
||||||
expect(modal._enforceFocus).not.toHaveBeenCalled()
|
expect(modal._focustrap.activate).not.toHaveBeenCalled()
|
||||||
done()
|
done()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -588,33 +588,17 @@ describe('Modal', () => {
|
|||||||
modal.show()
|
modal.show()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should enforce focus', done => {
|
it('should trap focus', done => {
|
||||||
fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
|
fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
|
||||||
|
|
||||||
const modalEl = fixtureEl.querySelector('.modal')
|
const modalEl = fixtureEl.querySelector('.modal')
|
||||||
const modal = new Modal(modalEl)
|
const modal = new Modal(modalEl)
|
||||||
|
|
||||||
spyOn(modal, '_enforceFocus').and.callThrough()
|
spyOn(modal._focustrap, 'activate').and.callThrough()
|
||||||
|
|
||||||
const focusInListener = () => {
|
|
||||||
expect(modal._element.focus).toHaveBeenCalled()
|
|
||||||
document.removeEventListener('focusin', focusInListener)
|
|
||||||
done()
|
|
||||||
}
|
|
||||||
|
|
||||||
modalEl.addEventListener('shown.bs.modal', () => {
|
modalEl.addEventListener('shown.bs.modal', () => {
|
||||||
expect(modal._enforceFocus).toHaveBeenCalled()
|
expect(modal._focustrap.activate).toHaveBeenCalled()
|
||||||
|
done()
|
||||||
spyOn(modal._element, 'focus')
|
|
||||||
|
|
||||||
document.addEventListener('focusin', focusInListener)
|
|
||||||
|
|
||||||
const focusInEvent = createEvent('focusin', { bubbles: true })
|
|
||||||
Object.defineProperty(focusInEvent, 'target', {
|
|
||||||
value: fixtureEl
|
|
||||||
})
|
|
||||||
|
|
||||||
document.dispatchEvent(focusInEvent)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
modal.show()
|
modal.show()
|
||||||
@ -721,6 +705,25 @@ describe('Modal', () => {
|
|||||||
|
|
||||||
modal.show()
|
modal.show()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should release focus trap', done => {
|
||||||
|
fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
|
||||||
|
|
||||||
|
const modalEl = fixtureEl.querySelector('.modal')
|
||||||
|
const modal = new Modal(modalEl)
|
||||||
|
spyOn(modal._focustrap, 'deactivate').and.callThrough()
|
||||||
|
|
||||||
|
modalEl.addEventListener('shown.bs.modal', () => {
|
||||||
|
modal.hide()
|
||||||
|
})
|
||||||
|
|
||||||
|
modalEl.addEventListener('hidden.bs.modal', () => {
|
||||||
|
expect(modal._focustrap.deactivate).toHaveBeenCalled()
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
|
||||||
|
modal.show()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('dispose', () => {
|
describe('dispose', () => {
|
||||||
@ -729,6 +732,8 @@ describe('Modal', () => {
|
|||||||
|
|
||||||
const modalEl = fixtureEl.querySelector('.modal')
|
const modalEl = fixtureEl.querySelector('.modal')
|
||||||
const modal = new Modal(modalEl)
|
const modal = new Modal(modalEl)
|
||||||
|
const focustrap = modal._focustrap
|
||||||
|
spyOn(focustrap, 'deactivate').and.callThrough()
|
||||||
|
|
||||||
expect(Modal.getInstance(modalEl)).toEqual(modal)
|
expect(Modal.getInstance(modalEl)).toEqual(modal)
|
||||||
|
|
||||||
@ -737,7 +742,8 @@ describe('Modal', () => {
|
|||||||
modal.dispose()
|
modal.dispose()
|
||||||
|
|
||||||
expect(Modal.getInstance(modalEl)).toBeNull()
|
expect(Modal.getInstance(modalEl)).toBeNull()
|
||||||
expect(EventHandler.off).toHaveBeenCalledTimes(4)
|
expect(EventHandler.off).toHaveBeenCalledTimes(3)
|
||||||
|
expect(focustrap.deactivate).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -219,7 +219,7 @@ describe('Offcanvas', () => {
|
|||||||
offCanvas.show()
|
offCanvas.show()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not enforce focus if focus scroll is allowed', done => {
|
it('should not trap focus if scroll is allowed', done => {
|
||||||
fixtureEl.innerHTML = '<div class="offcanvas"></div>'
|
fixtureEl.innerHTML = '<div class="offcanvas"></div>'
|
||||||
|
|
||||||
const offCanvasEl = fixtureEl.querySelector('.offcanvas')
|
const offCanvasEl = fixtureEl.querySelector('.offcanvas')
|
||||||
@ -227,10 +227,10 @@ describe('Offcanvas', () => {
|
|||||||
scroll: true
|
scroll: true
|
||||||
})
|
})
|
||||||
|
|
||||||
spyOn(offCanvas, '_enforceFocusOnElement')
|
spyOn(offCanvas._focustrap, 'activate').and.callThrough()
|
||||||
|
|
||||||
offCanvasEl.addEventListener('shown.bs.offcanvas', () => {
|
offCanvasEl.addEventListener('shown.bs.offcanvas', () => {
|
||||||
expect(offCanvas._enforceFocusOnElement).not.toHaveBeenCalled()
|
expect(offCanvas._focustrap.activate).not.toHaveBeenCalled()
|
||||||
done()
|
done()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -345,16 +345,16 @@ describe('Offcanvas', () => {
|
|||||||
expect(Offcanvas.prototype.show).toHaveBeenCalled()
|
expect(Offcanvas.prototype.show).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should enforce focus', done => {
|
it('should trap focus', done => {
|
||||||
fixtureEl.innerHTML = '<div class="offcanvas"></div>'
|
fixtureEl.innerHTML = '<div class="offcanvas"></div>'
|
||||||
|
|
||||||
const offCanvasEl = fixtureEl.querySelector('.offcanvas')
|
const offCanvasEl = fixtureEl.querySelector('.offcanvas')
|
||||||
const offCanvas = new Offcanvas(offCanvasEl)
|
const offCanvas = new Offcanvas(offCanvasEl)
|
||||||
|
|
||||||
spyOn(offCanvas, '_enforceFocusOnElement')
|
spyOn(offCanvas._focustrap, 'activate').and.callThrough()
|
||||||
|
|
||||||
offCanvasEl.addEventListener('shown.bs.offcanvas', () => {
|
offCanvasEl.addEventListener('shown.bs.offcanvas', () => {
|
||||||
expect(offCanvas._enforceFocusOnElement).toHaveBeenCalled()
|
expect(offCanvas._focustrap.activate).toHaveBeenCalled()
|
||||||
done()
|
done()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -421,6 +421,22 @@ describe('Offcanvas', () => {
|
|||||||
|
|
||||||
offCanvas.hide()
|
offCanvas.hide()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should release focus trap', done => {
|
||||||
|
fixtureEl.innerHTML = '<div class="offcanvas"></div>'
|
||||||
|
|
||||||
|
const offCanvasEl = fixtureEl.querySelector('div')
|
||||||
|
const offCanvas = new Offcanvas(offCanvasEl)
|
||||||
|
spyOn(offCanvas._focustrap, 'deactivate').and.callThrough()
|
||||||
|
offCanvas.show()
|
||||||
|
|
||||||
|
offCanvasEl.addEventListener('hidden.bs.offcanvas', () => {
|
||||||
|
expect(offCanvas._focustrap.deactivate).toHaveBeenCalled()
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
|
||||||
|
offCanvas.hide()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('dispose', () => {
|
describe('dispose', () => {
|
||||||
@ -431,6 +447,8 @@ describe('Offcanvas', () => {
|
|||||||
const offCanvas = new Offcanvas(offCanvasEl)
|
const offCanvas = new Offcanvas(offCanvasEl)
|
||||||
const backdrop = offCanvas._backdrop
|
const backdrop = offCanvas._backdrop
|
||||||
spyOn(backdrop, 'dispose').and.callThrough()
|
spyOn(backdrop, 'dispose').and.callThrough()
|
||||||
|
const focustrap = offCanvas._focustrap
|
||||||
|
spyOn(focustrap, 'deactivate').and.callThrough()
|
||||||
|
|
||||||
expect(Offcanvas.getInstance(offCanvasEl)).toEqual(offCanvas)
|
expect(Offcanvas.getInstance(offCanvasEl)).toEqual(offCanvas)
|
||||||
|
|
||||||
@ -440,6 +458,8 @@ describe('Offcanvas', () => {
|
|||||||
|
|
||||||
expect(backdrop.dispose).toHaveBeenCalled()
|
expect(backdrop.dispose).toHaveBeenCalled()
|
||||||
expect(offCanvas._backdrop).toBeNull()
|
expect(offCanvas._backdrop).toBeNull()
|
||||||
|
expect(focustrap.deactivate).toHaveBeenCalled()
|
||||||
|
expect(offCanvas._focustrap).toBeNull()
|
||||||
expect(Offcanvas.getInstance(offCanvasEl)).toEqual(null)
|
expect(Offcanvas.getInstance(offCanvasEl)).toEqual(null)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
210
js/tests/unit/util/focustrap.spec.js
Normal file
210
js/tests/unit/util/focustrap.spec.js
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import FocusTrap from '../../../src/util/focustrap'
|
||||||
|
import EventHandler from '../../../src/dom/event-handler'
|
||||||
|
import SelectorEngine from '../../../src/dom/selector-engine'
|
||||||
|
import { clearFixture, getFixture, createEvent } from '../../helpers/fixture'
|
||||||
|
|
||||||
|
describe('FocusTrap', () => {
|
||||||
|
let fixtureEl
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
fixtureEl = getFixture()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearFixture()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('activate', () => {
|
||||||
|
it('should autofocus itself by default', () => {
|
||||||
|
fixtureEl.innerHTML = '<div id="focustrap" tabindex="-1"></div>'
|
||||||
|
|
||||||
|
const trapElement = fixtureEl.querySelector('div')
|
||||||
|
|
||||||
|
spyOn(trapElement, 'focus')
|
||||||
|
|
||||||
|
const focustrap = new FocusTrap({ trapElement })
|
||||||
|
focustrap.activate()
|
||||||
|
|
||||||
|
expect(trapElement.focus).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('if configured not to autofocus, should not autofocus itself', () => {
|
||||||
|
fixtureEl.innerHTML = '<div id="focustrap" tabindex="-1"></div>'
|
||||||
|
|
||||||
|
const trapElement = fixtureEl.querySelector('div')
|
||||||
|
|
||||||
|
spyOn(trapElement, 'focus')
|
||||||
|
|
||||||
|
const focustrap = new FocusTrap({ trapElement, autofocus: false })
|
||||||
|
focustrap.activate()
|
||||||
|
|
||||||
|
expect(trapElement.focus).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should force focus inside focus trap if it can', done => {
|
||||||
|
fixtureEl.innerHTML = [
|
||||||
|
'<a href="#" id="outside">outside</a>',
|
||||||
|
'<div id="focustrap" tabindex="-1">',
|
||||||
|
' <a href="#" id="inside">inside</a>',
|
||||||
|
'</div>'
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
const trapElement = fixtureEl.querySelector('div')
|
||||||
|
const focustrap = new FocusTrap({ trapElement })
|
||||||
|
focustrap.activate()
|
||||||
|
|
||||||
|
const inside = document.getElementById('inside')
|
||||||
|
|
||||||
|
const focusInListener = () => {
|
||||||
|
expect(inside.focus).toHaveBeenCalled()
|
||||||
|
document.removeEventListener('focusin', focusInListener)
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
|
||||||
|
spyOn(inside, 'focus')
|
||||||
|
spyOn(SelectorEngine, 'focusableChildren').and.callFake(() => [inside])
|
||||||
|
|
||||||
|
document.addEventListener('focusin', focusInListener)
|
||||||
|
|
||||||
|
const focusInEvent = createEvent('focusin', { bubbles: true })
|
||||||
|
Object.defineProperty(focusInEvent, 'target', {
|
||||||
|
value: document.getElementById('outside')
|
||||||
|
})
|
||||||
|
|
||||||
|
document.dispatchEvent(focusInEvent)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should wrap focus around foward on tab', done => {
|
||||||
|
fixtureEl.innerHTML = [
|
||||||
|
'<a href="#" id="outside">outside</a>',
|
||||||
|
'<div id="focustrap" tabindex="-1">',
|
||||||
|
' <a href="#" id="first">first</a>',
|
||||||
|
' <a href="#" id="inside">inside</a>',
|
||||||
|
' <a href="#" id="last">last</a>',
|
||||||
|
'</div>'
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
const trapElement = fixtureEl.querySelector('div')
|
||||||
|
const focustrap = new FocusTrap({ trapElement })
|
||||||
|
focustrap.activate()
|
||||||
|
|
||||||
|
const first = document.getElementById('first')
|
||||||
|
const inside = document.getElementById('inside')
|
||||||
|
const last = document.getElementById('last')
|
||||||
|
const outside = document.getElementById('outside')
|
||||||
|
|
||||||
|
spyOn(SelectorEngine, 'focusableChildren').and.callFake(() => [first, inside, last])
|
||||||
|
spyOn(first, 'focus').and.callThrough()
|
||||||
|
|
||||||
|
const focusInListener = () => {
|
||||||
|
expect(first.focus).toHaveBeenCalled()
|
||||||
|
first.removeEventListener('focusin', focusInListener)
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
|
||||||
|
first.addEventListener('focusin', focusInListener)
|
||||||
|
|
||||||
|
const keydown = createEvent('keydown')
|
||||||
|
keydown.key = 'Tab'
|
||||||
|
|
||||||
|
document.dispatchEvent(keydown)
|
||||||
|
outside.focus()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should wrap focus around backwards on shift-tab', done => {
|
||||||
|
fixtureEl.innerHTML = [
|
||||||
|
'<a href="#" id="outside">outside</a>',
|
||||||
|
'<div id="focustrap" tabindex="-1">',
|
||||||
|
' <a href="#" id="first">first</a>',
|
||||||
|
' <a href="#" id="inside">inside</a>',
|
||||||
|
' <a href="#" id="last">last</a>',
|
||||||
|
'</div>'
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
const trapElement = fixtureEl.querySelector('div')
|
||||||
|
const focustrap = new FocusTrap({ trapElement })
|
||||||
|
focustrap.activate()
|
||||||
|
|
||||||
|
const first = document.getElementById('first')
|
||||||
|
const inside = document.getElementById('inside')
|
||||||
|
const last = document.getElementById('last')
|
||||||
|
const outside = document.getElementById('outside')
|
||||||
|
|
||||||
|
spyOn(SelectorEngine, 'focusableChildren').and.callFake(() => [first, inside, last])
|
||||||
|
spyOn(last, 'focus').and.callThrough()
|
||||||
|
|
||||||
|
const focusInListener = () => {
|
||||||
|
expect(last.focus).toHaveBeenCalled()
|
||||||
|
last.removeEventListener('focusin', focusInListener)
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
|
||||||
|
last.addEventListener('focusin', focusInListener)
|
||||||
|
|
||||||
|
const keydown = createEvent('keydown')
|
||||||
|
keydown.key = 'Tab'
|
||||||
|
keydown.shiftKey = true
|
||||||
|
|
||||||
|
document.dispatchEvent(keydown)
|
||||||
|
outside.focus()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should force focus on itself if there is no focusable content', done => {
|
||||||
|
fixtureEl.innerHTML = [
|
||||||
|
'<a href="#" id="outside">outside</a>',
|
||||||
|
'<div id="focustrap" tabindex="-1"></div>'
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
const trapElement = fixtureEl.querySelector('div')
|
||||||
|
const focustrap = new FocusTrap({ trapElement })
|
||||||
|
focustrap.activate()
|
||||||
|
|
||||||
|
const focusInListener = () => {
|
||||||
|
expect(focustrap._config.trapElement.focus).toHaveBeenCalled()
|
||||||
|
document.removeEventListener('focusin', focusInListener)
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
|
||||||
|
spyOn(focustrap._config.trapElement, 'focus')
|
||||||
|
|
||||||
|
document.addEventListener('focusin', focusInListener)
|
||||||
|
|
||||||
|
const focusInEvent = createEvent('focusin', { bubbles: true })
|
||||||
|
Object.defineProperty(focusInEvent, 'target', {
|
||||||
|
value: document.getElementById('outside')
|
||||||
|
})
|
||||||
|
|
||||||
|
document.dispatchEvent(focusInEvent)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deactivate', () => {
|
||||||
|
it('should flag itself as no longer active', () => {
|
||||||
|
const focustrap = new FocusTrap({ trapElement: fixtureEl })
|
||||||
|
focustrap.activate()
|
||||||
|
expect(focustrap._isActive).toBe(true)
|
||||||
|
|
||||||
|
focustrap.deactivate()
|
||||||
|
expect(focustrap._isActive).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove all event listeners', () => {
|
||||||
|
const focustrap = new FocusTrap({ trapElement: fixtureEl })
|
||||||
|
focustrap.activate()
|
||||||
|
|
||||||
|
spyOn(EventHandler, 'off')
|
||||||
|
focustrap.deactivate()
|
||||||
|
|
||||||
|
expect(EventHandler.off).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('doesn\'t try removing event listeners unless it needs to (in case it hasn\'t been activated)', () => {
|
||||||
|
const focustrap = new FocusTrap({ trapElement: fixtureEl })
|
||||||
|
|
||||||
|
spyOn(EventHandler, 'off')
|
||||||
|
focustrap.deactivate()
|
||||||
|
|
||||||
|
expect(EventHandler.off).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
Loading…
x
Reference in New Issue
Block a user