Skip to content

Commit

Permalink
feat: modal dialog accessibility updates (#4025)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
gkatsev committed Feb 8, 2017
1 parent 1b1ba04 commit eddc1d7
Show file tree
Hide file tree
Showing 2 changed files with 191 additions and 0 deletions.
99 changes: 99 additions & 0 deletions src/js/modal-dialog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -187,6 +189,7 @@ class ModalDialog extends Component {

player.controls(false);
this.show();
this.conditionalFocus_();
this.el().setAttribute('aria-hidden', 'false');

/**
Expand Down Expand Up @@ -257,6 +260,7 @@ class ModalDialog extends Component {
* @type {EventTarget~Event}
*/
this.trigger('modalclose');
this.conditionalBlur_();

if (this.options_.temporary) {
this.dispose();
Expand Down Expand Up @@ -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_);
}
}

/**
Expand Down Expand Up @@ -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'));
});
}
}

/**
Expand Down
92 changes: 92 additions & 0 deletions test/unit/modal-dialog.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
});

0 comments on commit eddc1d7

Please sign in to comment.