]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
Add shift-tab keyboard support for dialogs (modal & Offcanvas components) (#33865)
authorRyan Berliner <22206986+RyanBerliner@users.noreply.github.com>
Tue, 27 Jul 2021 05:01:04 +0000 (01:01 -0400)
committerGitHub <noreply@github.com>
Tue, 27 Jul 2021 05:01:04 +0000 (08:01 +0300)
* 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>
.bundlewatch.config.json
js/src/dom/selector-engine.js
js/src/modal.js
js/src/offcanvas.js
js/src/util/focustrap.js [new file with mode: 0644]
js/tests/unit/dom/selector-engine.spec.js
js/tests/unit/modal.spec.js
js/tests/unit/offcanvas.spec.js
js/tests/unit/util/focustrap.spec.js [new file with mode: 0644]

index fc70cd2cfec72a9f6de9d53b4cb8327a24be0c60..4806d7277827fde908e34aeace09b06d4dd96bde 100644 (file)
@@ -34,7 +34,7 @@
     },
     {
       "path": "./dist/js/bootstrap.bundle.js",
-      "maxSize": "41.5 kB"
+      "maxSize": "42 kB"
     },
     {
       "path": "./dist/js/bootstrap.bundle.min.js",
@@ -42,7 +42,7 @@
     },
     {
       "path": "./dist/js/bootstrap.esm.js",
-      "maxSize": "27 kB"
+      "maxSize": "27.5 kB"
     },
     {
       "path": "./dist/js/bootstrap.esm.min.js",
@@ -50,7 +50,7 @@
     },
     {
       "path": "./dist/js/bootstrap.js",
-      "maxSize": "27.5 kB"
+      "maxSize": "28 kB"
     },
     {
       "path": "./dist/js/bootstrap.min.js",
index 381e45fe8b840a3a10c57d70873d4224f3884423..88f9240762f934027c536485c2387d2f24882f2b 100644 (file)
@@ -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))
   }
 }
 
index 0e8346d6f3f0844ebdd771c68092f6c46ed7d90b..53a3ccfd1cdd583f9a089b3b145d157d5f62706c 100644 (file)
@@ -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 => {
index 016260437cd46c805f6b706337237f2f4baf39a8..6c563cb4ff70a231ff237c7199b86667370a217b 100644 (file)
@@ -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 (file)
index 0000000..ab8462e
--- /dev/null
@@ -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
index d108a2efbf7e57ed479b4d6e0d5cb58e8f25f9ca..08c3ae81844c7efbb3e31c3054c6a7b1f696ca5e 100644 (file)
@@ -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)
+    })
+  })
 })
 
index 86b366001f6c29094a96a92d07a78708a76457d6..212f98ca84fd7d7f39ee47fa9d5fc33c7aca60a3 100644 (file)
@@ -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()
     })
   })
 
index a13875b51a97a68299f85832b6608bea70dc43e3..ecbb710a59423456ddf20821da48063199734a5b 100644 (file)
@@ -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 (file)
index 0000000..2457239
--- /dev/null
@@ -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()
+    })
+  })
+})