Merge pull request #9380 from 370417/modal-a11y

Improve keyboard navigation of modals
pull/9419/head
Thibault Duplessis 2021-07-16 11:15:55 +02:00 committed by GitHub
commit a30b3e358f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 57 additions and 6 deletions

View File

@ -1,4 +1,5 @@
import { h, VNode } from 'snabbdom';
import { focusFirstChild } from 'common/modal';
import { MaybeVNodes } from './interfaces';
import { bind, onInsert } from './util';
@ -21,14 +22,23 @@ export function modal(d: Modal): VNode {
'div#modal-wrap.study__modal.' + d.class,
{
hook: onInsert(el => {
focusFirstChild($(el));
el.addEventListener('mousedown', e => e.stopPropagation());
d.onInsert && d.onInsert(el);
}),
},
[
h('span.close', {
attrs: { 'data-icon': '' },
hook: bind('click', d.onClose),
attrs: {
'data-icon': '',
role: 'button',
'aria-label': 'Close',
tabindex: '0',
},
hook: onInsert(el => {
el.addEventListener('click', d.onClose);
el.addEventListener('keydown', e => (e.code === 'Enter' || e.code === 'Space' ? d.onClose() : true));
}),
}),
h('div', d.content),
]

View File

@ -1,13 +1,21 @@
export default function modal(content: Cash, cls?: string, onClose?: () => void) {
modal.close();
const $wrap: any = $('<div id="modal-wrap"><span class="close" data-icon=""></span></div>');
const $wrap = $(
'<div id="modal-wrap"><span class="close" role="button" aria-label="Close" data-icon="" tabindex="0"></span></div>'
);
const $overlay = $(`<div id="modal-overlay" class="${cls}">`).on('click', modal.close);
$('<a href="#"></a>').appendTo($overlay); // guard against focus escaping to window chrome
$wrap.appendTo($overlay);
$('<a href="#"></a>').appendTo($overlay); // guard against focus escaping to window chrome
content.clone().removeClass('none').appendTo($wrap);
modal.onClose = onClose;
$wrap.find('.close').on('click', modal.close);
$wrap
.find('.close')
.on('click', modal.close)
.on('keydown', (e: KeyboardEvent) => (e.code === 'Space' || e.code === 'Enter' ? modal.close() : true));
$wrap.on('click', (e: Event) => e.stopPropagation());
$('body').addClass('overlayed').prepend($overlay);
focusFirstChild($wrap);
return $wrap;
}
modal.close = () => {
@ -19,3 +27,23 @@ modal.close = () => {
delete modal.onClose;
};
modal.onClose = undefined as (() => void) | undefined;
const focusableSelectors =
'button:not(:disabled), [href], input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]';
export function trapFocus(event: FocusEvent) {
const wrap: HTMLElement | undefined = $('#modal-wrap').get(0);
if (!wrap) return;
const position = wrap.compareDocumentPosition(event.target as HTMLElement);
if (position & Node.DOCUMENT_POSITION_CONTAINED_BY) return;
const focusableChildren = $(wrap).find(focusableSelectors);
const index = position & Node.DOCUMENT_POSITION_FOLLOWING ? 0 : focusableChildren.length - 1;
focusableChildren.get(index)?.focus();
event.preventDefault();
}
export function focusFirstChild(parent: Cash) {
const children = parent.find(focusableSelectors);
// prefer child 1 over child 0 because child 0 should be a close button
(children.get(1) ?? children.get(0))?.focus();
}

View File

@ -17,6 +17,7 @@ import watchers from './component/watchers';
import { reload } from './component/reload';
import { requestIdleCallback } from './component/functions';
import { userComplete } from './component/assets';
import { trapFocus } from 'common/modal';
exportLichessGlobals();
lichess.info = info;
@ -115,6 +116,8 @@ lichess.load.then(() => {
return false;
});
$('body').on('focusin', trapFocus);
window.Mousetrap.bind('esc', () => {
const $oc = $('#modal-wrap .close');
if ($oc.length) $oc.trigger('click');

View File

@ -2,6 +2,7 @@ import TournamentController from '../ctrl';
import { bind, onInsert, playerName } from './util';
import { h, VNode } from 'snabbdom';
import { TeamBattle, RankedTeam, MaybeVNode } from '../interfaces';
import { focusFirstChild } from 'common/modal';
export function joinWithTeamSelector(ctrl: TournamentController) {
const onClose = () => {
@ -19,13 +20,22 @@ export function joinWithTeamSelector(ctrl: TournamentController) {
'div#modal-wrap.team-battle__choice',
{
hook: onInsert(el => {
focusFirstChild($(el));
el.addEventListener('click', e => e.stopPropagation());
}),
},
[
h('span.close', {
attrs: { 'data-icon': '' },
hook: bind('click', onClose),
attrs: {
'data-icon': '',
role: 'button',
'aria-label': 'Close',
tabindex: '0',
},
hook: onInsert(el => {
el.addEventListener('click', onClose);
el.addEventListener('keydown', e => (e.code === 'Enter' || e.code === 'Space' ? onClose() : true));
}),
}),
h('div.team-picker', [
h('h2', 'Pick your team'),