Skip to content

Commit

Permalink
feat(dom): Add focus trap utility. (#5505)
Browse files Browse the repository at this point in the history
  • Loading branch information
joyzhong committed Jan 23, 2020
1 parent b7facc6 commit 63f357d
Show file tree
Hide file tree
Showing 16 changed files with 406 additions and 70 deletions.
2 changes: 2 additions & 0 deletions karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ const istanbulInstrumenterLoader = {
/checkbox\/.*$/,
/chips\/.*$/,
/constants.[jt]s$/,
/dom\/.*$/,
/data-table\/.*$/,
/floating-label\/.*$/,
/form-field\/.*$/,
Expand Down Expand Up @@ -178,6 +179,7 @@ const jasmineConfig = {
'packages/!(mdc-base)/**/*',
'packages/!(mdc-checkbox)/**/*',
'packages/!(mdc-chips)/**/*',
'packages/!(mdc-dom)/**/*',
'packages/!(mdc-data-table)/**/*',
'packages/!(mdc-floating-label)/**/*',
'packages/!(mdc-form-field)/**/*',
Expand Down
10 changes: 5 additions & 5 deletions packages/mdc-dialog/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@

import {MDCComponent} from '@material/base/component';
import {SpecificEventListener} from '@material/base/types';
import {FocusTrap} from '@material/dom/focus-trap';
import {closest, matches} from '@material/dom/ponyfill';
import {MDCRipple} from '@material/ripple/component';
import {FocusTrap} from 'focus-trap';
import {MDCDialogAdapter} from './adapter';
import {MDCDialogFoundation} from './foundation';
import {MDCDialogCloseEventDetail} from './types';
Expand Down Expand Up @@ -74,7 +74,7 @@ export class MDCDialog extends MDCComponent<MDCDialogFoundation> {
private defaultButton_!: HTMLElement | null; // assigned in initialize()

private focusTrap_!: FocusTrap; // assigned in initialSyncWithDOM()
private focusTrapFactory_?: MDCDialogFocusTrapFactory; // assigned in initialize()
private focusTrapFactory_!: MDCDialogFocusTrapFactory; // assigned in initialize()

private handleClick_!: SpecificEventListener<'click'>; // assigned in initialSyncWithDOM()
private handleKeydown_!: SpecificEventListener<'keydown'>; // assigned in initialSyncWithDOM()
Expand All @@ -84,7 +84,7 @@ export class MDCDialog extends MDCComponent<MDCDialogFoundation> {
private handleClosing_!: () => void; // assigned in initialSyncWithDOM()

initialize(
focusTrapFactory?: MDCDialogFocusTrapFactory,
focusTrapFactory: MDCDialogFocusTrapFactory = (el, focusOptions) => new FocusTrap(el, focusOptions),
) {
const container = this.root_.querySelector<HTMLElement>(strings.CONTAINER_SELECTOR);
if (!container) {
Expand Down Expand Up @@ -173,7 +173,7 @@ export class MDCDialog extends MDCComponent<MDCDialogFoundation> {
notifyClosing: (action) => this.emit<MDCDialogCloseEventDetail>(strings.CLOSING_EVENT, action ? {action} : {}),
notifyOpened: () => this.emit(strings.OPENED_EVENT, {}),
notifyOpening: () => this.emit(strings.OPENING_EVENT, {}),
releaseFocus: () => this.focusTrap_.deactivate(),
releaseFocus: () => this.focusTrap_.releaseFocus(),
removeBodyClass: (className) => document.body.classList.remove(className),
removeClass: (className) => this.root_.classList.remove(className),
reverseButtons: () => {
Expand All @@ -182,7 +182,7 @@ export class MDCDialog extends MDCComponent<MDCDialogFoundation> {
button.parentElement!.appendChild(button);
});
},
trapFocus: () => this.focusTrap_.activate(),
trapFocus: () => this.focusTrap_.trapFocus(),
};
return new MDCDialogFoundation(adapter);
}
Expand Down
1 change: 0 additions & 1 deletion packages/mdc-dialog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
"@material/theme": "^4.0.0",
"@material/touch-target": "^4.0.0",
"@material/typography": "^4.0.0",
"focus-trap": "^5.0.0",
"tslib": "^1.9.3"
},
"publishConfig": {
Expand Down
16 changes: 6 additions & 10 deletions packages/mdc-dialog/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,19 @@
* THE SOFTWARE.
*/

import {default as createFocusTrap, FocusTarget, FocusTrap, Options} from 'focus-trap';
import {FocusOptions, FocusTrap} from '@material/dom/focus-trap';

export type MDCDialogFocusTrapFactory = (
element: HTMLElement | string,
userOptions?: Options,
element: HTMLElement,
options: FocusOptions,
) => FocusTrap;

export function createFocusTrapInstance(
surfaceEl: HTMLElement,
focusTrapFactory: MDCDialogFocusTrapFactory = createFocusTrap as unknown as MDCDialogFocusTrapFactory,
initialFocusEl?: FocusTarget,
focusTrapFactory: MDCDialogFocusTrapFactory,
initialFocusEl?: HTMLElement,
): FocusTrap {
return focusTrapFactory(surfaceEl, {
clickOutsideDeactivates: true, // Allow handling of scrim clicks.
escapeDeactivates: false, // Foundation handles ESC key.
initialFocus: initialFocusEl,
});
return focusTrapFactory(surfaceEl, {initialFocusEl});
}

export function isScrollable(el: HTMLElement | null): boolean {
Expand Down
12 changes: 11 additions & 1 deletion packages/mdc-dom/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Function Signature | Description
`matches(element: Element, selector: string) => boolean` | Returns true if the given element matches the given CSS selector.
`estimateScrollWidth(element: Element) => number` | Returns the true optical width of the element if visible or an estimation if hidden by a parent element with `display: none;`.

### Event Functions
## Event Functions

External frameworks and libraries can use the following event utility methods.

Expand All @@ -45,3 +45,13 @@ Method Signature | Description
`util.applyPassive(globalObj = window, forceRefresh = false) => object` | Determine whether the current browser supports passive event listeners

> _NOTE_: The function `util.applyPassive` cache its results; `forceRefresh` will force recomputation, but is used mainly for testing and should not be necessary in normal use.
## Focus Trap

The `FocusTrap` utility traps focus within a given element. It is intended for usage from MDC-internal
components like dialog and modal drawer.

Method Signature | Description
--- | ---
`trapFocus() => void` | Traps focus in the root element. Also focuses on `initialFocusEl` if set; otherwise, sets initial focus to the first focusable child element.
`releaseFocus() => void` | Releases focus from the root element. Also restores focus to the previously focused element.
160 changes: 160 additions & 0 deletions packages/mdc-dom/focus-trap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* @license
* Copyright 2020 Google Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/

const FOCUS_SENTINEL_CLASS = 'mdc-dom-focus-sentinel';

/**
* Utility to trap focus in a given root element, e.g. for modal components such
* as dialogs. The root should have at least one focusable child element,
* for setting initial focus when trapping focus.
* Also tracks the previously focused element, and restores focus to that
* element when releasing focus.
*/
export class FocusTrap {
// Previously focused element before trapping focus.
private elFocusedBeforeTrapFocus: HTMLElement|null = null;

constructor(
private readonly root: HTMLElement,
private readonly options: FocusOptions = {}) {}

/**
* Traps focus in `root`. Also focuses on either `initialFocusEl` if set;
* otherwises sets initial focus to the first focusable child element.
*/
trapFocus() {
const focusableEls = this.getFocusableElements(this.root);
if (focusableEls.length === 0) {
throw new Error(
'FocusTrap: Element must have at least one focusable child.');
}

this.elFocusedBeforeTrapFocus =
document.activeElement instanceof HTMLElement ? document.activeElement :
null;
this.wrapTabFocus(this.root, focusableEls);

if (!this.options.skipInitialFocus) {
this.focusInitialElement(focusableEls, this.options.initialFocusEl);
}
}

/**
* Releases focus from `root`. Also restores focus to the previously focused
* element.
*/
releaseFocus() {
[].slice.call(this.root.querySelectorAll(`.${FOCUS_SENTINEL_CLASS}`))
.forEach((sentinelEl: HTMLElement) => {
sentinelEl.parentElement!.removeChild(sentinelEl);
});

if (this.elFocusedBeforeTrapFocus) {
this.elFocusedBeforeTrapFocus.focus();
}
}

/**
* Wraps tab focus within `el` by adding two hidden sentinel divs which are
* used to mark the beginning and the end of the tabbable region. When
* focused, these sentinel elements redirect focus to the first/last
* children elements of the tabbable region, ensuring that focus is trapped
* within that region.
*/
private wrapTabFocus(el: HTMLElement, focusableEls: HTMLElement[]) {
const sentinelStart = this.createSentinel();
const sentinelEnd = this.createSentinel();

sentinelStart.addEventListener('focus', () => {
if (focusableEls.length > 0) {
focusableEls[focusableEls.length - 1].focus();
}
});
sentinelEnd.addEventListener('focus', () => {
if (focusableEls.length > 0) {
focusableEls[0].focus();
}
});

el.insertBefore(sentinelStart, el.children[0]);
el.appendChild(sentinelEnd);
}

/**
* Focuses on `initialFocusEl` if defined and a child of the root element.
* Otherwise, focuses on the first focusable child element of the root.
*/
private focusInitialElement(
focusableEls: HTMLElement[], initialFocusEl?: HTMLElement) {
let focusIndex = 0;
if (initialFocusEl) {
focusIndex = Math.max(focusableEls.indexOf(initialFocusEl), 0);
}
focusableEls[focusIndex].focus();
}

private getFocusableElements(root: HTMLElement): HTMLElement[] {
const focusableEls =
[].slice.call(root.querySelectorAll(
'[autofocus], [tabindex], a, input, textarea, select, button')) as
HTMLElement[];
return focusableEls.filter((el) => {
const isDisabledOrHidden = el.getAttribute('aria-disabled') === 'true' ||
el.getAttribute('disabled') != null ||
el.getAttribute('hidden') != null ||
el.getAttribute('aria-hidden') === 'true';
const isTabbableAndVisible = el.tabIndex >= 0 &&
el.getBoundingClientRect().width > 0 &&
!el.classList.contains(FOCUS_SENTINEL_CLASS) && !isDisabledOrHidden;

let isProgrammaticallyHidden = false;
if (isTabbableAndVisible) {
const style = getComputedStyle(el);
isProgrammaticallyHidden =
style.display === 'none' || style.visibility === 'hidden';
}
return isTabbableAndVisible && !isProgrammaticallyHidden;
});
}

private createSentinel() {
const sentinel = document.createElement('div');
sentinel.setAttribute('tabindex', '0');
// Don't announce in screen readers.
sentinel.setAttribute('aria-hidden', 'true');
sentinel.classList.add(FOCUS_SENTINEL_CLASS);
return sentinel;
}
}

/** Customization options. */
export interface FocusOptions {
// The element to focus initially when trapping focus.
// Must be a child of the root element.
initialFocusEl?: HTMLElement;

// Whether to skip initially focusing on any element when trapping focus.
// By default, focus is set on the first focusable child element of the root.
// This is useful if the caller wants to handle setting initial focus.
skipInitialFocus?: boolean;
}
3 changes: 2 additions & 1 deletion packages/mdc-dom/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
*/

import * as events from './events';
import * as focusTrap from './focus-trap';
import * as ponyfill from './ponyfill';

export {events, ponyfill};
export {events, focusTrap, ponyfill};
Loading

0 comments on commit 63f357d

Please sign in to comment.