diff options
| author | Ryan Berliner <[email protected]> | 2021-07-27 01:01:04 -0400 |
|---|---|---|
| committer | GitHub <[email protected]> | 2021-07-27 08:01:04 +0300 |
| commit | 7646f6bd33a03132e446fb060880bbf051a1639f (patch) | |
| tree | a2addfd5e2f99b23322cd053ca0ec53c48cf6fc6 /js/tests | |
| parent | 85364745831ba5513ee7e940fe571cb4268810b8 (diff) | |
| download | bootstrap-7646f6bd33a03132e446fb060880bbf051a1639f.tar.xz bootstrap-7646f6bd33a03132e446fb060880bbf051a1639f.zip | |
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 <[email protected]>
* remove area support forom focusableChildren
* fix no expectations warning in focustrap tests
Co-authored-by: GeoSot <[email protected]>
Co-authored-by: XhmikosR <[email protected]>
Diffstat (limited to 'js/tests')
| -rw-r--r-- | js/tests/unit/dom/selector-engine.spec.js | 82 | ||||
| -rw-r--r-- | js/tests/unit/modal.spec.js | 54 | ||||
| -rw-r--r-- | js/tests/unit/offcanvas.spec.js | 32 | ||||
| -rw-r--r-- | js/tests/unit/util/focustrap.spec.js | 210 |
4 files changed, 348 insertions, 30 deletions
diff --git a/js/tests/unit/dom/selector-engine.spec.js b/js/tests/unit/dom/selector-engine.spec.js index d108a2efb..08c3ae818 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 86b366001..212f98ca8 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 a13875b51..ecbb710a5 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 000000000..2457239c4 --- /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() + }) + }) +}) |
