diff --git a/src/js/modal-dialog.js b/src/js/modal-dialog.js index 41adb3af3..bb1fb5b7b 100644 --- a/src/js/modal-dialog.js +++ b/src/js/modal-dialog.js @@ -4,6 +4,8 @@ import * as Dom from './utils/dom'; import * as Fn from './utils/fn'; import Component from './component'; +import window from 'global/window'; +import document from 'global/document'; const MODAL_CLASS_NAME = 'vjs-modal-dialog'; const ESC = 27; @@ -187,6 +189,7 @@ class ModalDialog extends Component { player.controls(false); this.show(); + this.conditionalFocus_(); this.el().setAttribute('aria-hidden', 'false'); /** @@ -257,6 +260,7 @@ class ModalDialog extends Component { * @type {EventTarget~Event} */ this.trigger('modalclose'); + this.conditionalBlur_(); if (this.options_.temporary) { this.dispose(); @@ -351,6 +355,13 @@ class ModalDialog extends Component { } else { parentEl.appendChild(contentEl); } + + // make sure that the close button is last in the dialog DOM + const closeButton = this.getChild('closeButton'); + + if (closeButton) { + parentEl.appendChild(closeButton.el_); + } } /** @@ -399,6 +410,94 @@ class ModalDialog extends Component { } return this.content_; } + + /** + * conditionally focus the modal dialog if focus was previously on the player. + * + * @private + */ + conditionalFocus_() { + const activeEl = document.activeElement; + const playerEl = this.player_.el_; + + this.previouslyActiveEl_ = null; + + if (playerEl.contains(activeEl) || playerEl === activeEl) { + this.previouslyActiveEl_ = activeEl; + + this.focus(); + + this.on(document, 'keydown', this.handleKeyDown); + } + } + + /** + * conditionally blur the element and refocus the last focused element + * + * @private + */ + conditionalBlur_() { + if (this.previouslyActiveEl_) { + this.previouslyActiveEl_.focus(); + this.previouslyActiveEl_ = null; + } + + this.off(document, 'keydown', this.handleKeyDown); + } + + /** + * Keydown handler. Attached when modal is focused. + * + * @listens keydown + */ + handleKeyDown(event) { + // exit early if it isn't a tab key + if (event.which !== 9) { + return; + } + + const focusableEls = this.focusableEls_(); + const activeEl = this.el_.querySelector(':focus'); + let focusIndex; + + for (let i = 0; i < focusableEls.length; i++) { + if (activeEl === focusableEls[i]) { + focusIndex = i; + break; + } + } + + if (event.shiftKey && focusIndex === 0) { + focusableEls[focusableEls.length - 1].focus(); + event.preventDefault(); + } else if (!event.shiftKey && focusIndex === focusableEls.length - 1) { + focusableEls[0].focus(); + event.preventDefault(); + } + } + + /** + * get all focusable elements + * + * @private + */ + focusableEls_() { + const allChildren = this.el_.querySelectorAll('*'); + + return Array.prototype.filter.call(allChildren, (child) => { + return ((child instanceof window.HTMLAnchorElement || + child instanceof window.HTMLAreaElement) && child.hasAttribute('href')) || + ((child instanceof window.HTMLInputElement || + child instanceof window.HTMLSelectElement || + child instanceof window.HTMLTextAreaElement || + child instanceof window.HTMLButtonElement) && !child.hasAttribute('disabled')) || + (child instanceof window.HTMLIFrameElement || + child instanceof window.HTMLObjectElement || + child instanceof window.HTMLEmbedElement) || + (child.hasAttribute('tabindex') && child.getAttribute('tabindex') !== -1) || + (child.hasAttribute('contenteditable')); + }); + } } /** diff --git a/test/unit/modal-dialog.test.js b/test/unit/modal-dialog.test.js index 5ef9cebc9..aa1dc3d4d 100644 --- a/test/unit/modal-dialog.test.js +++ b/test/unit/modal-dialog.test.js @@ -22,6 +22,82 @@ QUnit.module('ModalDialog', { } }); +const mockFocusableEls = function(Modal, focuscallback) { + Modal.prototype.oldFocusableEls = Modal.prototype.focusableEls_; + + const focus = function() { + return focuscallback(this.i); + }; + const els = [ { + i: 0, + focus + }, { + i: 1, + focus + }, { + i: 2, + focus + }, { + i: 3, + focus + }]; + + Modal.prototype.focusableEls_ = () => els; +}; + +const restoreFocusableEls = function(Modal) { + Modal.prototype.focusableEls_ = Modal.prototype.oldFocusableEls; +}; + +const mockActiveEl = function(modal, index) { + modal.oldEl = modal.el_; + modal.el_ = { + querySelector() { + const focusableEls = modal.focusableEls_(); + + return focusableEls[index]; + } + }; +}; + +const restoreActiveEl = function(modal) { + modal.el_ = modal.oldEl; +}; + +const tabTestHelper = function(assert, player) { + return function(from, to, shift = false) { + mockFocusableEls(ModalDialog, (focusIndex) => { + assert.equal(focusIndex, to, `we should focus back on the ${to} element, we got ${focusIndex}.`); + }); + const modal = new ModalDialog(player, {}); + + mockActiveEl(modal, from); + + let prevented = false; + + modal.handleKeyDown({ + which: 9, + shiftKey: shift, + preventDefault() { + prevented = true; + } + }); + + if (!prevented) { + const newIndex = shift ? from - 1 : from + 1; + const newEl = modal.focusableEls_()[newIndex]; + + if (newEl) { + newEl.focus(newEl.i); + } + } + + restoreActiveEl(modal); + modal.dispose(); + restoreFocusableEls(ModalDialog); + }; +}; + QUnit.test('should create the expected element', function(assert) { const elAssertions = TestHelpers.assertEl(assert, this.el, { tagName: 'div', @@ -399,3 +475,19 @@ QUnit.test('"uncloseable" option', function(assert) { modal.handleKeyPress({which: ESC}); assert.strictEqual(spy.callCount, 0, 'ESC did not close the modal'); }); + +QUnit.test('handleKeyDown traps tab focus', function(assert) { + const tabTester = tabTestHelper(assert, this.player); + + // tabbing forward from first element to last and cycling back to first + tabTester(0, 1, false); + tabTester(1, 2, false); + tabTester(2, 3, false); + tabTester(3, 0, false); + + // tabbing backwards from last element to first and cycling back to last + tabTester(3, 2, true); + tabTester(2, 1, true); + tabTester(1, 0, true); + tabTester(0, 3, true); +});