Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[A11Y] Add focus traps to modals and nav drawer #3018

Merged
merged 19 commits into from
Nov 21, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"clsx": "^1.1.1",
"color-thief-browser": "^2.0.2",
"dayjs": "^1.10.7",
"focus-trap": "^6.7.1",
"jquery": "^3.6.0",
"jquery.hotkeys": "^0.1.0",
"mithril": "^2.0.4",
Expand Down
2 changes: 2 additions & 0 deletions js/src/common/compat.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import extractText from './utils/extractText';
import formatNumber from './utils/formatNumber';
import mapRoutes from './utils/mapRoutes';
import withAttr from './utils/withAttr';
import * as FocusTrap from './utils/focusTrap';
import Notification from './models/Notification';
import User from './models/User';
import Post from './models/Post';
Expand Down Expand Up @@ -116,6 +117,7 @@ export default {
'utils/withAttr': withAttr,
'utils/throttleDebounce': ThrottleDebounce,
'utils/isObject': isObject,
'utils/focusTrap': FocusTrap,
'models/Notification': Notification,
'models/User': User,
'models/Post': Post,
Expand Down
34 changes: 31 additions & 3 deletions js/src/common/components/ModalManager.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import Component from '../Component';

import type Mithril from 'mithril';
import { createFocusTrap, FocusTrap } from '../utils/focusTrap';

import type ModalManagerState from '../states/ModalManagerState';
import type Mithril from 'mithril';

interface IModalManagerAttrs {
state: ModalManagerState;
Expand All @@ -13,7 +15,14 @@ interface IModalManagerAttrs {
* overwrite the previous one.
*/
export default class ModalManager extends Component<IModalManagerAttrs> {
view() {
protected focusTrap: FocusTrap | undefined;

/**
* Whether a modal is currently shown by this modal manager.
*/
protected modalShown: boolean = false;
davwheat marked this conversation as resolved.
Show resolved Hide resolved

view(vnode: Mithril.VnodeDOM<IModalManagerAttrs, this>): Mithril.Children {
const modal = this.attrs.state.modal;

return (
Expand All @@ -29,20 +38,37 @@ export default class ModalManager extends Component<IModalManagerAttrs> {
);
}

oncreate(vnode: Mithril.VnodeDOM<IModalManagerAttrs, this>) {
oncreate(vnode: Mithril.VnodeDOM<IModalManagerAttrs, this>): void {
super.oncreate(vnode);

// Ensure the modal state is notified about a closed modal, even when the
// DOM-based Bootstrap JavaScript code triggered the closing of the modal,
// e.g. via ESC key or a click on the modal backdrop.
this.$().on('hidden.bs.modal', this.attrs.state.close.bind(this.attrs.state));

this.focusTrap = createFocusTrap(this.element as HTMLElement);
}

onupdate(vnode: Mithril.VnodeDOM<IModalManagerAttrs, this>): void {
super.onupdate(vnode);

requestAnimationFrame(() => {
try {
if (this.modalShown) this.focusTrap!.activate?.();
else this.focusTrap!.deactivate?.();
} catch {
// We can expect errors to occur here due to the nature of mithril rendering
}
});
}

animateShow(readyCallback: () => void): void {
if (!this.attrs.state.modal) return;

const dismissible = !!this.attrs.state.modal.componentClass.isDismissible;

this.modalShown = true;

// If we are opening this modal while another modal is already open,
// the shown event will not run, because the modal is already open.
// So, we need to manually trigger the readyCallback.
Expand All @@ -64,5 +90,7 @@ export default class ModalManager extends Component<IModalManagerAttrs> {
animateHide(): void {
// @ts-expect-error: No typings available for Bootstrap modals.
this.$().modal('hide');

this.modalShown = false;
}
}
74 changes: 61 additions & 13 deletions js/src/common/utils/Drawer.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,72 @@
import { createFocusTrap } from './focusTrap';

/**
* The `Drawer` class controls the page's drawer. The drawer is the area the
* slides out from the left on mobile devices; it contains the header and the
* footer.
*/
export default class Drawer {
/**
* @type {import('./focusTrap').FocusTrap}
*/
focusTrap;

/**
* @type {HTMLDivElement}
*/
appElement;

constructor() {
// Set up an event handler so that whenever the content area is tapped,
// the drawer will close.
$('#content').click((e) => {
document.getElementById('content').addEventListener('click', (e) => {
if (this.isOpen()) {
e.preventDefault();
this.hide();
}
});

this.appElement = document.getElementById('app');
this.focusTrap = createFocusTrap('#drawer', { allowOutsideClick: true });
this.drawerAvailableMediaQuery = window.matchMedia(
`(max-width: ${getComputedStyle(document.documentElement).getPropertyValue('--screen-phone-max')})`
);
}

/**
* Handler for the `resize` event on `window`.
*
* This is used to close the drawer when the viewport is widened past the `phone` size.
* At this point, the drawer turns into the standard header that we see on desktop, but
* the drawer is still registered as 'open' internally.
*
* This causes issues with the focus trap, resulting in focus becoming trapped within
* the header on desktop viewports.
*
* @internal
*/
resizeHandler = ((e) => {
console.log(this, e);
if (!e.matches && this.isOpen()) {
// Drawer is open but we've made window bigger, so hide it.
this.hide();
}
}).bind(this);

/**
* @internal
* @type {MediaQueryList}
*/
drawerAvailableMediaQuery;

/**
* Check whether or not the drawer is currently open.
*
* @return {Boolean}
* @return {boolean}
* @public
*/
isOpen() {
return $('#app').hasClass('drawerOpen');
return this.appElement.classList.contains('drawerOpen');
}

/**
Expand All @@ -39,18 +83,19 @@ export default class Drawer {
* More info: https://github.com/flarum/core/pull/2666#discussion_r595381014
*/

const $app = $('#app');
this.focusTrap.deactivate();
this.drawerAvailableMediaQuery.removeListener(this.resizeHandler);

if (!$app.hasClass('drawerOpen')) return;
if (!this.isOpen()) return;

const $drawer = $('#drawer');

// Used to prevent `visibility: hidden` from breaking the exit animation
$drawer.css('visibility', 'visible').one('transitionend', () => $drawer.css('visibility', ''));

$app.removeClass('drawerOpen');
this.appElement.classList.remove('drawerOpen');

if (this.$backdrop) this.$backdrop.remove();
this.$backdrop?.remove?.();
}

/**
Expand All @@ -59,13 +104,16 @@ export default class Drawer {
* @public
*/
show() {
$('#app').addClass('drawerOpen');
this.appElement.classList.add('drawerOpen');

this.drawerAvailableMediaQuery.addListener(this.resizeHandler);

this.$backdrop = $('<div/>')
.addClass('drawer-backdrop fade')
.appendTo('body')
.click(() => this.hide());
this.$backdrop = $('<div/>').addClass('drawer-backdrop fade').appendTo('body').on('click', this.hide.bind(this));

setTimeout(() => this.$backdrop.addClass('in'));
requestAnimationFrame(() => {
this.$backdrop.addClass('in');

this.focusTrap.activate();
});
}
}
29 changes: 29 additions & 0 deletions js/src/common/utils/focusTrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { createFocusTrap as _createFocusTrap } from 'focus-trap';

/**
* Creates a focus trap for the given element with the given options.
*
* This function applies some default options that are different to the library.
* Your own options still override these custom defaults:
*
* ```json
* {
escapeDeactivates: false,
* }
* ```
*
* @param element The element to be the focus trap, or a selector that will be used to find the element.
*
* @see https://github.com/focus-trap/focus-trap#readme - Library documentation
*/
function createFocusTrap(...args: Parameters<typeof _createFocusTrap>): ReturnType<typeof _createFocusTrap> {
args[1] = {
escapeDeactivates: false,
...args[1],
};

return _createFocusTrap(...args);
}

export * from 'focus-trap';
export { createFocusTrap };
2 changes: 1 addition & 1 deletion js/src/forum/components/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ export default class Search<T extends SearchAttrs = SearchAttrs> extends Compone
this.navigator
.onUp(() => this.setIndex(this.getCurrentNumericIndex() - 1, true))
.onDown(() => this.setIndex(this.getCurrentNumericIndex() + 1, true))
.onSelect(this.selectResult.bind(this))
.onSelect(this.selectResult.bind(this), true)
.onCancel(this.clear.bind(this))
.bindTo($input);

Expand Down
48 changes: 36 additions & 12 deletions js/src/forum/utils/KeyboardNavigatable.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
type KeyboardEventHandler = (event: KeyboardEvent) => void;
type ShouldHandle = (event: KeyboardEvent) => boolean;

enum Keys {
Enter = 13,
Escape = 27,
Space = 32,
ArrowUp = 38,
ArrowDown = 40,
ArrowLeft = 37,
ArrowRight = 39,
Tab = 9,
Backspace = 8,
}

/**
* The `KeyboardNavigatable` class manages lists that can be navigated with the
* keyboard, calling callbacks for each actions.
Expand All @@ -26,7 +38,7 @@ export default class KeyboardNavigatable {
* This will be triggered by the Up key.
*/
onUp(callback: KeyboardEventHandler): KeyboardNavigatable {
this.callbacks.set(38, (e) => {
this.callbacks.set(Keys.ArrowUp, (e) => {
e.preventDefault();
callback(e);
});
Expand All @@ -40,7 +52,7 @@ export default class KeyboardNavigatable {
* This will be triggered by the Down key.
*/
onDown(callback: KeyboardEventHandler): KeyboardNavigatable {
this.callbacks.set(40, (e) => {
this.callbacks.set(Keys.ArrowDown, (e) => {
e.preventDefault();
callback(e);
});
Expand All @@ -51,16 +63,32 @@ export default class KeyboardNavigatable {
/**
* Provide a callback to be executed when the current item is selected..
*
* This will be triggered by the Return and Tab keys..
* This will be triggered by the Return key (and Tab key, if not disabled).
*/
onSelect(callback: KeyboardEventHandler): KeyboardNavigatable {
onSelect(callback: KeyboardEventHandler, ignoreTabPress: boolean = false): KeyboardNavigatable {
const handler: KeyboardEventHandler = (e) => {
e.preventDefault();
callback(e);
};

if (!ignoreTabPress) this.callbacks.set(Keys.Tab, handler);
this.callbacks.set(Keys.Enter, handler);

return this;
}

/**
* Provide a callback to be executed when the current item is tabbed into.
*
* This will be triggered by the Tab key.
*/
onTab(callback: KeyboardEventHandler): KeyboardNavigatable {
const handler: KeyboardEventHandler = (e) => {
e.preventDefault();
callback(e);
};

this.callbacks.set(9, handler);
davwheat marked this conversation as resolved.
Show resolved Hide resolved
this.callbacks.set(13, handler);

return this;
}
Expand All @@ -71,7 +99,7 @@ export default class KeyboardNavigatable {
* This will be triggered by the Escape key.
*/
onCancel(callback: KeyboardEventHandler): KeyboardNavigatable {
this.callbacks.set(27, (e) => {
this.callbacks.set(Keys.Escape, (e) => {
e.stopPropagation();
e.preventDefault();
callback(e);
Expand All @@ -84,13 +112,9 @@ export default class KeyboardNavigatable {
* Provide a callback to be executed when previous input is removed.
*
* This will be triggered by the Backspace key.
*
* @public
* @param {KeyboardNavigatable~keyCallback} callback
* @return {KeyboardNavigatable}
*/
onRemove(callback: KeyboardEventHandler): KeyboardNavigatable {
this.callbacks.set(8, (e) => {
this.callbacks.set(Keys.Backspace, (e) => {
if (e instanceof KeyboardEvent && e.target instanceof HTMLInputElement && e.target.selectionStart === 0 && e.target.selectionEnd === 0) {
callback(e);
e.preventDefault();
Expand All @@ -112,7 +136,7 @@ export default class KeyboardNavigatable {
/**
* Set up the navigation key bindings on the given jQuery element.
*/
bindTo($element: JQuery) {
bindTo($element: JQuery<HTMLElement>) {
// Handle navigation key events on the navigatable element.
$element[0].addEventListener('keydown', this.navigate.bind(this));
}
Expand Down
Loading