mirror of
https://github.com/twbs/bootstrap.git
synced 2025-08-18 03:11:19 +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:
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