mirror of
https://github.com/twbs/bootstrap.git
synced 2025-08-12 00:24:03 +02:00
Add shift-tab keyboard support for dialogs (modal & Offcanvas components) (#33865)
* consolidate dialog focus trap logic * add shift-tab support to focustrap * remove redundant null check of trap element Co-authored-by: GeoSot <geo.sotis@gmail.com> * remove area support forom focusableChildren * fix no expectations warning in focustrap tests Co-authored-by: GeoSot <geo.sotis@gmail.com> Co-authored-by: XhmikosR <xhmikosr@gmail.com>
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
@@ -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)
|
||||
})
|
||||
})
|
||||
|
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()
|
||||
})
|
||||
})
|
||||
})
|
Reference in New Issue
Block a user