feat: modal dialog accessibility updates (#4025)

If the modal dialog was opened and the focus was preset inside the
player, move the focus to the modal dialog.
When the modal dialog is closed, move the focus back to the previously
active element.
When focus is inside the dialog, trap tab focus. This was inspired by https://github.com/gdkraus/accessible-modal-dialog and ally.js.
This commit is contained in:
Gary Katsevman
2017-02-08 17:29:05 -05:00
committed by GitHub
parent 1b1ba04271
commit eddc1d71a3
2 changed files with 191 additions and 0 deletions
+99
View File
@@ -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'));
});
}
}
/**
+92
View File
@@ -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);
});