Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/twbs/bootstrap.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/js
diff options
context:
space:
mode:
authorRyan Berliner <22206986+RyanBerliner@users.noreply.github.com>2021-07-27 08:01:04 +0300
committerGitHub <noreply@github.com>2021-07-27 08:01:04 +0300
commit7646f6bd33a03132e446fb060880bbf051a1639f (patch)
treea2addfd5e2f99b23322cd053ca0ec53c48cf6fc6 /js
parent85364745831ba5513ee7e940fe571cb4268810b8 (diff)
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>
Diffstat (limited to 'js')
-rw-r--r--js/src/dom/selector-engine.js17
-rw-r--r--js/src/modal.js36
-rw-r--r--js/src/offcanvas.js24
-rw-r--r--js/src/util/focustrap.js109
-rw-r--r--js/tests/unit/dom/selector-engine.spec.js82
-rw-r--r--js/tests/unit/modal.spec.js54
-rw-r--r--js/tests/unit/offcanvas.spec.js32
-rw-r--r--js/tests/unit/util/focustrap.spec.js210
8 files changed, 496 insertions, 68 deletions
diff --git a/js/src/dom/selector-engine.js b/js/src/dom/selector-engine.js
index 381e45fe8b..88f9240762 100644
--- a/js/src/dom/selector-engine.js
+++ b/js/src/dom/selector-engine.js
@@ -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))
}
}
diff --git a/js/src/modal.js b/js/src/modal.js
index 0e8346d6f3..53a3ccfd1c 100644
--- a/js/src/modal.js
+++ b/js/src/modal.js
@@ -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 => {
diff --git a/js/src/offcanvas.js b/js/src/offcanvas.js
index 016260437c..6c563cb4ff 100644
--- a/js/src/offcanvas.js
+++ b/js/src/offcanvas.js
@@ -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() {
diff --git a/js/src/util/focustrap.js b/js/src/util/focustrap.js
new file mode 100644
index 0000000000..ab8462e237
--- /dev/null
+++ b/js/src/util/focustrap.js
@@ -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
diff --git a/js/tests/unit/dom/selector-engine.spec.js b/js/tests/unit/dom/selector-engine.spec.js
index d108a2efbf..08c3ae8184 100644
--- a/js/tests/unit/dom/selector-engine.spec.js
+++ b/js/tests/unit/dom/selector-engine.spec.js
@@ -156,5 +156,87 @@ describe('SelectorEngine', () => {
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)
+ })
+ })
})
diff --git a/js/tests/unit/modal.spec.js b/js/tests/unit/modal.spec.js
index 86b366001f..212f98ca84 100644
--- a/js/tests/unit/modal.spec.js
+++ b/js/tests/unit/modal.spec.js
@@ -345,7 +345,7 @@ describe('Modal', () => {
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>'
const modalEl = fixtureEl.querySelector('.modal')
@@ -353,10 +353,10 @@ describe('Modal', () => {
focus: false
})
- spyOn(modal, '_enforceFocus')
+ spyOn(modal._focustrap, 'activate').and.callThrough()
modalEl.addEventListener('shown.bs.modal', () => {
- expect(modal._enforceFocus).not.toHaveBeenCalled()
+ expect(modal._focustrap.activate).not.toHaveBeenCalled()
done()
})
@@ -588,33 +588,17 @@ describe('Modal', () => {
modal.show()
})
- it('should enforce focus', done => {
+ it('should trap focus', done => {
fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>'
const modalEl = fixtureEl.querySelector('.modal')
const modal = new Modal(modalEl)
- spyOn(modal, '_enforceFocus').and.callThrough()
-
- const focusInListener = () => {
- expect(modal._element.focus).toHaveBeenCalled()
- document.removeEventListener('focusin', focusInListener)
- done()
- }
+ spyOn(modal._focustrap, 'activate').and.callThrough()
modalEl.addEventListener('shown.bs.modal', () => {
- expect(modal._enforceFocus).toHaveBeenCalled()
-
- spyOn(modal._element, 'focus')
-
- document.addEventListener('focusin', focusInListener)
-
- const focusInEvent = createEvent('focusin', { bubbles: true })
- Object.defineProperty(focusInEvent, 'target', {
- value: fixtureEl
- })
-
- document.dispatchEvent(focusInEvent)
+ expect(modal._focustrap.activate).toHaveBeenCalled()
+ done()
})
modal.show()
@@ -721,6 +705,25 @@ describe('Modal', () => {
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', () => {
@@ -729,6 +732,8 @@ describe('Modal', () => {
const modalEl = fixtureEl.querySelector('.modal')
const modal = new Modal(modalEl)
+ const focustrap = modal._focustrap
+ spyOn(focustrap, 'deactivate').and.callThrough()
expect(Modal.getInstance(modalEl)).toEqual(modal)
@@ -737,7 +742,8 @@ describe('Modal', () => {
modal.dispose()
expect(Modal.getInstance(modalEl)).toBeNull()
- expect(EventHandler.off).toHaveBeenCalledTimes(4)
+ expect(EventHandler.off).toHaveBeenCalledTimes(3)
+ expect(focustrap.deactivate).toHaveBeenCalled()
})
})
diff --git a/js/tests/unit/offcanvas.spec.js b/js/tests/unit/offcanvas.spec.js
index a13875b51a..ecbb710a59 100644
--- a/js/tests/unit/offcanvas.spec.js
+++ b/js/tests/unit/offcanvas.spec.js
@@ -219,7 +219,7 @@ describe('Offcanvas', () => {
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>'
const offCanvasEl = fixtureEl.querySelector('.offcanvas')
@@ -227,10 +227,10 @@ describe('Offcanvas', () => {
scroll: true
})
- spyOn(offCanvas, '_enforceFocusOnElement')
+ spyOn(offCanvas._focustrap, 'activate').and.callThrough()
offCanvasEl.addEventListener('shown.bs.offcanvas', () => {
- expect(offCanvas._enforceFocusOnElement).not.toHaveBeenCalled()
+ expect(offCanvas._focustrap.activate).not.toHaveBeenCalled()
done()
})
@@ -345,16 +345,16 @@ describe('Offcanvas', () => {
expect(Offcanvas.prototype.show).toHaveBeenCalled()
})
- it('should enforce focus', done => {
+ it('should trap focus', done => {
fixtureEl.innerHTML = '<div class="offcanvas"></div>'
const offCanvasEl = fixtureEl.querySelector('.offcanvas')
const offCanvas = new Offcanvas(offCanvasEl)
- spyOn(offCanvas, '_enforceFocusOnElement')
+ spyOn(offCanvas._focustrap, 'activate').and.callThrough()
offCanvasEl.addEventListener('shown.bs.offcanvas', () => {
- expect(offCanvas._enforceFocusOnElement).toHaveBeenCalled()
+ expect(offCanvas._focustrap.activate).toHaveBeenCalled()
done()
})
@@ -421,6 +421,22 @@ describe('Offcanvas', () => {
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', () => {
@@ -431,6 +447,8 @@ describe('Offcanvas', () => {
const offCanvas = new Offcanvas(offCanvasEl)
const backdrop = offCanvas._backdrop
spyOn(backdrop, 'dispose').and.callThrough()
+ const focustrap = offCanvas._focustrap
+ spyOn(focustrap, 'deactivate').and.callThrough()
expect(Offcanvas.getInstance(offCanvasEl)).toEqual(offCanvas)
@@ -440,6 +458,8 @@ describe('Offcanvas', () => {
expect(backdrop.dispose).toHaveBeenCalled()
expect(offCanvas._backdrop).toBeNull()
+ expect(focustrap.deactivate).toHaveBeenCalled()
+ expect(offCanvas._focustrap).toBeNull()
expect(Offcanvas.getInstance(offCanvasEl)).toEqual(null)
})
})
diff --git a/js/tests/unit/util/focustrap.spec.js b/js/tests/unit/util/focustrap.spec.js
new file mode 100644
index 0000000000..2457239c4f
--- /dev/null
+++ b/js/tests/unit/util/focustrap.spec.js
@@ -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()
+ })
+ })
+})