mirror of
https://github.com/videojs/video.js.git
synced 2026-06-19 22:15:04 +02:00
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:
@@ -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'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user