From 6ec65b0c8b4e8409192f63897ef02753365f0da7 Mon Sep 17 00:00:00 2001 From: Material Web Team Date: Wed, 22 Jan 2020 14:07:48 -0500 Subject: [PATCH 01/11] Project import generated by Copybara. PiperOrigin-RevId: 290989685 --- packages/mdc-dom/README.md | 12 +- packages/mdc-dom/focus-trap.ts | 165 +++++++++++++++++++++ packages/mdc-dom/index.ts | 3 +- packages/mdc-dom/test/focus-trap.test.ts | 179 +++++++++++++++++++++++ packages/mdc-drawer/package.json | 1 + 5 files changed, 358 insertions(+), 2 deletions(-) create mode 100644 packages/mdc-dom/focus-trap.ts create mode 100644 packages/mdc-dom/test/focus-trap.test.ts diff --git a/packages/mdc-dom/README.md b/packages/mdc-dom/README.md index 6512c71fc55..1940f04eb69 100644 --- a/packages/mdc-dom/README.md +++ b/packages/mdc-dom/README.md @@ -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. @@ -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. diff --git a/packages/mdc-dom/focus-trap.ts b/packages/mdc-dom/focus-trap.ts new file mode 100644 index 00000000000..3703f024f4c --- /dev/null +++ b/packages/mdc-dom/focus-trap.ts @@ -0,0 +1,165 @@ +/** + * @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-focus-sentinel'; + +/** + * Utility to trap focus in a given element, e.g. for modal components such + * as dialogs. + * 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 el: HTMLElement, private readonly options: FocusOptions = {}) {} + + /** + * Traps focus in `el`. Also focuses on either `initialFocusEl` if set; + * otherwises sets initial focus to the first focusable child element. + */ + trapFocus() { + this.elFocusedBeforeTrapFocus = + document.activeElement instanceof HTMLElement ? + document.activeElement : null; + this.wrapTabFocus(this.el); + + if (!this.options.skipInitialFocus) { + this.focusInitialElement(this.options.initialFocusEl); + } + } + + /** + * Releases focus from `el`. Also restores focus to the previously focused + * element. + */ + releaseFocus() { + [].slice.call(this.el.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) { + const sentinelStart = this.createSentinel(); + const sentinelEnd = this.createSentinel(); + + sentinelStart.addEventListener('focus', () => { + this.focusLast(el); + }); + sentinelEnd.addEventListener('focus', () => { + this.focusFirst(el); + }); + + 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(initialFocusEl?: HTMLElement) { + const focusableElements = this.getFocusableElements(this.el); + const focusIndex = Math.max( + initialFocusEl ? focusableElements.indexOf(initialFocusEl) : 0, + 0); + focusableElements[focusIndex].focus(); + } + + /** + * Focuses first focusable child element of `el`. + */ + private focusFirst(el: HTMLElement) { + const focusableEls = this.getFocusableElements(el); + if (focusableEls.length > 0) { + focusableEls[0].focus(); + } + } + + /** + * Focuses last focusable child element of `el`. + */ + private focusLast(el: HTMLElement) { + const focusableEls = this.getFocusableElements(el); + if (focusableEls.length > 0) { + focusableEls[focusableEls.length - 1].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. + skipInitialFocus?: boolean; +} diff --git a/packages/mdc-dom/index.ts b/packages/mdc-dom/index.ts index d5caed0f472..07198f6922b 100644 --- a/packages/mdc-dom/index.ts +++ b/packages/mdc-dom/index.ts @@ -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}; diff --git a/packages/mdc-dom/test/focus-trap.test.ts b/packages/mdc-dom/test/focus-trap.test.ts new file mode 100644 index 00000000000..0843f5eb972 --- /dev/null +++ b/packages/mdc-dom/test/focus-trap.test.ts @@ -0,0 +1,179 @@ +/** + * @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. + */ + +import {FocusTrap} from '../focus-trap'; +import {emitEvent} from '../../../testing/dom/events'; + +function getFixture() { + const wrapper = document.createElement('div'); + wrapper.innerHTML = ` +
+ +
+
a
+
+
b
+
+
+
+
c
+
d
+
+
+ +
d
+
+
+ + + + + + +
+
`; + const el = wrapper.firstElementChild as HTMLElement; + wrapper.removeChild(el); + return el; +} + +function setUp() { + const root = getFixture(); + document.body.appendChild(root); + const button = root.querySelector('button') as HTMLElement; + const container1 = root.querySelector('#container1') as HTMLElement; + const container2 = root.querySelector('#container2') as HTMLElement; + const container3 = root.querySelector('#container3') as HTMLElement; + const container4 = root.querySelector('#container4') as HTMLElement; + return {button, container1, container2, container3, container4}; +} + +describe('FocusTrap', () => { + afterEach(() => { + [].slice.call(document.querySelectorAll('#root')).forEach((el) => { + document.body.removeChild(el); + }); + }); + + it('traps focus in the given container element', async () => { + const {container1, container2} = setUp(); + const focusTrap1 = new FocusTrap(container1); + focusTrap1.trapFocus(); + expectFocusTrapped(container1, 'con1a', 'con1b'); + + const focusTrap2 = new FocusTrap(container2); + focusTrap2.trapFocus(); + expectFocusTrapped(container2, 'con2a', 'con2b'); + }); + + it('releases focus from the given container element', async () => { + const {container1} = setUp(); + const focusTrap1 = new FocusTrap(container1); + focusTrap1.trapFocus(); + expectFocusTrapped(container1, 'con1a', 'con1b'); + + focusTrap1.releaseFocus(); + expect(container1.querySelectorAll('.mdc-focus-sentinel').length).toBe(0); + // Since no previously focused element, focus should remain on the first + // child of `container1`. + expect(document.activeElement!.id).toBe('con1a'); + }); + + it('restores focus to previously focused element', () => { + const {button, container2} = setUp(); + const focusTrap = new FocusTrap(container2); + + // First, set focus to button. + button.focus(); + expect(document.activeElement).toBe(button); + // Trap focus in `container2`. + focusTrap.trapFocus(); + expect(document.activeElement!.id).toBe('con2a'); + // Expect focus to be restored to button. + focusTrap.releaseFocus(); + expect(document.activeElement).toBe(button); + }); + + it('sets initial focus to first visible focusable element', () => { + const {container3} = setUp(); + const focusTrap = new FocusTrap(container3); + focusTrap.trapFocus(); + expect(document.activeElement!.id).toBe('con3b'); + }); + + it('sets initial focus to first non-hidden/non-disabled focusable element', () => { + const {container4} = setUp(); + const focusTrap = new FocusTrap(container4); + focusTrap.trapFocus(); + expect(document.activeElement!.id).toBe('con4e'); + }); + + it('sets initial focus to initialFocusEl', () => { + const {container1} = setUp(); + const initialFocusEl = container1.querySelector('#con1b') as HTMLElement; + const focusTrap = new FocusTrap(container1, { initialFocusEl }); + focusTrap.trapFocus(); + expect(document.activeElement!.id).toBe('con1b'); + }); + + it('does not set initial focus when skipInitialFocus=true', () => { + const {button, container1} = setUp(); + const focusTrap = new FocusTrap(container1, { skipInitialFocus: true }); + + // First, set focus to button. + button.focus(); + expect(document.activeElement).toBe(button); + + focusTrap.trapFocus(); + // Focus should remain on button. + expect(document.activeElement).toBe(button); + }); +}); + +function expectFocusTrapped( + el: HTMLElement, firstElementId: string, lastElementId: string) { + expect(document.activeElement!.id).toBe(firstElementId); + const focusSentinels = el.querySelectorAll('.mdc-focus-sentinel'); + const startFocusSentinel = focusSentinels[0] as HTMLElement; + const endFocusSentinel = focusSentinels[1] as HTMLElement; + // Sentinels are in the right part of the DOM tree. + expect(el.firstElementChild as HTMLElement).toBe(startFocusSentinel); + expect(el.lastElementChild as HTMLElement).toBe(endFocusSentinel); + + // Patch #addEventListener to make it synchronous for `focus` events. + const fakeFocusHandler = (eventName: string, eventHandler: any) => { + if (eventName === 'focus') { + eventHandler(); + } + }; + startFocusSentinel.addEventListener = fakeFocusHandler; + endFocusSentinel.addEventListener = fakeFocusHandler; + + // Focus on sentinels gets trapped inside the scope. + // Note that we use `emitEvent` here as calling #focus does not seem to + // execute the handler synchronously in IE11. + emitEvent(startFocusSentinel, 'focus'); + expect(document.activeElement!.id).toBe(lastElementId); + emitEvent(endFocusSentinel, 'focus'); + expect(document.activeElement!.id).toBe(firstElementId); +} diff --git a/packages/mdc-drawer/package.json b/packages/mdc-drawer/package.json index ae3d30ffcff..36d1a7fe093 100644 --- a/packages/mdc-drawer/package.json +++ b/packages/mdc-drawer/package.json @@ -21,6 +21,7 @@ "dependencies": { "@material/animation": "^4.0.0", "@material/base": "^4.0.0", + "@material/dom": "^4.0.0", "@material/elevation": "^4.0.0", "@material/feature-targeting": "^4.0.0", "@material/list": "^4.0.0", From bbf2a12c4c375b9c4a3a026cc642394c21403004 Mon Sep 17 00:00:00 2001 From: Joy Zhong Date: Wed, 22 Jan 2020 15:29:50 -0500 Subject: [PATCH 02/11] Add test changes --- test/unit/mdc-dialog/mdc-dialog.test.js | 12 ++++++------ test/unit/mdc-dialog/util.test.js | 10 +--------- test/unit/mdc-drawer/mdc-drawer.test.js | 10 +++++----- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/test/unit/mdc-dialog/mdc-dialog.test.js b/test/unit/mdc-dialog/mdc-dialog.test.js index 1a75fc8ffa0..50d0657081c 100644 --- a/test/unit/mdc-dialog/mdc-dialog.test.js +++ b/test/unit/mdc-dialog/mdc-dialog.test.js @@ -84,8 +84,8 @@ function setupTestWithMocks() { const MockFoundationCtor = td.constructor(MDCDialogFoundation); const mockFoundation = new MockFoundationCtor(); const mockFocusTrapInstance = td.object({ - activate: () => {}, - deactivate: () => {}, + trapFocus: () => {}, + releaseFocus: () => {}, }); const component = new MDCDialog(root, mockFoundation, () => mockFocusTrapInstance); @@ -412,20 +412,20 @@ test(`adapter#notifyClosed emits ${strings.CLOSED_EVENT} with action`, () => { td.verify(handler(td.matchers.contains({detail: {action}}))); }); -test('adapter#trapFocus calls activate() on a properly configured focus trap instance', () => { +test('adapter#trapFocus calls trapFocus() on a properly configured focus trap instance', () => { const {component, mockFocusTrapInstance} = setupTestWithMocks(); component.initialize(); component.getDefaultFoundation().adapter_.trapFocus(); - td.verify(mockFocusTrapInstance.activate()); + td.verify(mockFocusTrapInstance.trapFocus()); }); -test('adapter#releaseFocus calls deactivate() on a properly configured focus trap instance', () => { +test('adapter#releaseFocus calls releaseFocus() on a properly configured focus trap instance', () => { const {component, mockFocusTrapInstance} = setupTestWithMocks(); component.initialize(); component.getDefaultFoundation().adapter_.releaseFocus(); - td.verify(mockFocusTrapInstance.deactivate()); + td.verify(mockFocusTrapInstance.releaseFocus()); }); test('adapter#isContentScrollable returns false when there is no content element', () => { diff --git a/test/unit/mdc-dialog/util.test.js b/test/unit/mdc-dialog/util.test.js index 365a652bd51..72a9ec8cd23 100644 --- a/test/unit/mdc-dialog/util.test.js +++ b/test/unit/mdc-dialog/util.test.js @@ -34,21 +34,13 @@ test('createFocusTrapInstance creates a properly configured focus trap instance const focusTrapFactory = td.func('focusTrapFactory'); const properlyConfiguredFocusTrapInstance = {}; td.when(focusTrapFactory(surface, { - initialFocus: yesBtn, - escapeDeactivates: false, - clickOutsideDeactivates: true, + initialFocusEl: yesBtn, })).thenReturn(properlyConfiguredFocusTrapInstance); const instance = util.createFocusTrapInstance(surface, focusTrapFactory, yesBtn); assert.equal(instance, properlyConfiguredFocusTrapInstance); }); -test('createFocusTrapInstance creates a properly configured focus trap instance with optional args omitted', () => { - const surface = bel`
`; - const instance = util.createFocusTrapInstance(surface); - assert.sameMembers(Object.keys(instance), ['activate', 'deactivate', 'pause', 'unpause']); -}); - test('isScrollable returns false when element is null', () => { assert.isFalse(util.isScrollable(null)); }); diff --git a/test/unit/mdc-drawer/mdc-drawer.test.js b/test/unit/mdc-drawer/mdc-drawer.test.js index 19871090237..08e5c4329d8 100644 --- a/test/unit/mdc-drawer/mdc-drawer.test.js +++ b/test/unit/mdc-drawer/mdc-drawer.test.js @@ -99,7 +99,7 @@ function setupTest(options = defaultSetupOptions) { * @return {{ * component: MDCDrawer, * mockList: MDCList, - * mockFocusTrapInstance: {activate: function(), deactivate: function(), pause: function(), unpause: function()}, + * mockFocusTrapInstance: {trapFocus: function(), releaseFocus: function(), pause: function(), unpause: function()}, * root: HTMLElement, * drawer: HTMLElement, * mockFoundation: (MDCDismissibleDrawerFoundation|MDCModalDrawerFoundation), @@ -113,8 +113,8 @@ function setupTestWithMocks(options = defaultSetupOptions) { const MockFoundationCtor = td.constructor(MockFoundationClass); const mockFoundation = new MockFoundationCtor(); const mockFocusTrapInstance = td.object({ - activate: () => {}, - deactivate: () => {}, + trapFocus: () => {}, + releaseFocus: () => {}, }); const mockList = td.object({ wrapFocus: () => {}, @@ -300,14 +300,14 @@ test('adapter#trapFocus traps focus on root element', () => { const {component, mockFocusTrapInstance} = setupTestWithMocks({variantClass: cssClasses.MODAL}); component.getDefaultFoundation().adapter_.trapFocus(); - td.verify(mockFocusTrapInstance.activate()); + td.verify(mockFocusTrapInstance.trapFocus()); }); test('adapter#releaseFocus releases focus on root element after trap focus', () => { const {component, mockFocusTrapInstance} = setupTestWithMocks({variantClass: cssClasses.MODAL}); component.getDefaultFoundation().adapter_.releaseFocus(); - td.verify(mockFocusTrapInstance.deactivate()); + td.verify(mockFocusTrapInstance.releaseFocus()); }); test('adapter#notifyOpen emits drawer open event', () => { From a03133461c4a61e34c167522986ee66ea3e1e4a0 Mon Sep 17 00:00:00 2001 From: Joy Zhong Date: Wed, 22 Jan 2020 15:30:51 -0500 Subject: [PATCH 03/11] Drawer util --- packages/mdc-drawer/util.ts | 14 ++++++-------- test/unit/mdc-drawer/util.test.js | 11 +---------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/packages/mdc-drawer/util.ts b/packages/mdc-drawer/util.ts index f00d2d7d3ed..5f9e387b5e1 100644 --- a/packages/mdc-drawer/util.ts +++ b/packages/mdc-drawer/util.ts @@ -21,21 +21,19 @@ * THE SOFTWARE. */ -import {default as createFocusTrap, FocusTrap, Options} from 'focus-trap'; +import {FocusOptions, FocusTrap} from '@material/dom/focus-trap'; export type MDCDrawerFocusTrapFactory = ( - element: HTMLElement | string, - userOptions?: Options, + element: HTMLElement, + options: FocusOptions, ) => FocusTrap; export function createFocusTrapInstance( surfaceEl: HTMLElement, - focusTrapFactory: MDCDrawerFocusTrapFactory = createFocusTrap as unknown as MDCDrawerFocusTrapFactory, + focusTrapFactory: MDCDrawerFocusTrapFactory, ): FocusTrap { return focusTrapFactory(surfaceEl, { - clickOutsideDeactivates: true, // Allow handling of scrim clicks. - escapeDeactivates: false, // Foundation handles ESC key. - initialFocus: undefined, // Component handles focusing on active nav item. - returnFocusOnDeactivate: false, // Component handles restoring focus. + // Component handles focusing on active nav item. + skipInitialFocus: true, }); } diff --git a/test/unit/mdc-drawer/util.test.js b/test/unit/mdc-drawer/util.test.js index 6f8d3dc90ae..f62573b5682 100644 --- a/test/unit/mdc-drawer/util.test.js +++ b/test/unit/mdc-drawer/util.test.js @@ -33,18 +33,9 @@ test('createFocusTrapInstance creates a properly configured focus trap instance const focusTrapFactory = td.func('focusTrapFactory'); const properlyConfiguredFocusTrapInstance = {}; td.when(focusTrapFactory(rootEl, { - clickOutsideDeactivates: true, - escapeDeactivates: false, - initialFocus: undefined, - returnFocusOnDeactivate: false, + skipInitialFocus: true, })).thenReturn(properlyConfiguredFocusTrapInstance); const instance = util.createFocusTrapInstance(rootEl, focusTrapFactory); assert.equal(instance, properlyConfiguredFocusTrapInstance); }); - -test('createFocusTrapInstance creates a properly configured focus trap instance with optional args omitted', () => { - const surface = bel`
`; - const instance = util.createFocusTrapInstance(surface); - assert.sameMembers(Object.keys(instance), ['activate', 'deactivate', 'pause', 'unpause']); -}); From ac8eaf0f9f796ded45ef34b7ec7ba0d34d7db48f Mon Sep 17 00:00:00 2001 From: Joy Zhong Date: Wed, 22 Jan 2020 15:32:47 -0500 Subject: [PATCH 04/11] Dialog util --- packages/mdc-dialog/util.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/mdc-dialog/util.ts b/packages/mdc-dialog/util.ts index fea8311cf96..6387f701000 100644 --- a/packages/mdc-dialog/util.ts +++ b/packages/mdc-dialog/util.ts @@ -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 { From cc44ed4a48c39d59acec91a187344da4288de141 Mon Sep 17 00:00:00 2001 From: Joy Zhong Date: Wed, 22 Jan 2020 15:34:29 -0500 Subject: [PATCH 05/11] Dialog component --- packages/mdc-dialog/component.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/mdc-dialog/component.ts b/packages/mdc-dialog/component.ts index 1c3c77a255f..91f49f0f0a5 100644 --- a/packages/mdc-dialog/component.ts +++ b/packages/mdc-dialog/component.ts @@ -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'; @@ -74,7 +74,7 @@ export class MDCDialog extends MDCComponent { 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() @@ -84,7 +84,7 @@ export class MDCDialog extends MDCComponent { private handleClosing_!: () => void; // assigned in initialSyncWithDOM() initialize( - focusTrapFactory?: MDCDialogFocusTrapFactory, + focusTrapFactory: MDCDialogFocusTrapFactory = (el, focusOptions) => new FocusTrap(el, focusOptions), ) { const container = this.root_.querySelector(strings.CONTAINER_SELECTOR); if (!container) { @@ -173,7 +173,7 @@ export class MDCDialog extends MDCComponent { notifyClosing: (action) => this.emit(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: () => { @@ -182,7 +182,7 @@ export class MDCDialog extends MDCComponent { button.parentElement!.appendChild(button); }); }, - trapFocus: () => this.focusTrap_.activate(), + trapFocus: () => this.focusTrap_.trapFocus(), }; return new MDCDialogFoundation(adapter); } From fc353d6d80d57febfb561e3076e37419124a0871 Mon Sep 17 00:00:00 2001 From: Joy Zhong Date: Wed, 22 Jan 2020 15:35:02 -0500 Subject: [PATCH 06/11] Drawer component --- packages/mdc-drawer/component.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/mdc-drawer/component.ts b/packages/mdc-drawer/component.ts index 2dee76eb308..f210ae3bab0 100644 --- a/packages/mdc-drawer/component.ts +++ b/packages/mdc-drawer/component.ts @@ -23,9 +23,9 @@ import {MDCComponent} from '@material/base/component'; import {SpecificEventListener} from '@material/base/types'; +import {FocusTrap} from '@material/dom/focus-trap'; import {MDCList, MDCListFactory} from '@material/list/component'; import {MDCListFoundation} from '@material/list/foundation'; -import {default as createFocusTrap, FocusTrap} from 'focus-trap'; import {MDCDrawerAdapter} from './adapter'; import {MDCDismissibleDrawerFoundation} from './dismissible/foundation'; import {MDCModalDrawerFoundation} from './modal/foundation'; @@ -78,7 +78,7 @@ export class MDCDrawer extends MDCComponent { } initialize( - focusTrapFactory: MDCDrawerFocusTrapFactory = createFocusTrap as unknown as MDCDrawerFocusTrapFactory, + focusTrapFactory: MDCDrawerFocusTrapFactory = (el) => new FocusTrap(el), listFactory: MDCListFactory = (el) => new MDCList(el), ) { const listEl = this.root_.querySelector(`.${MDCListFoundation.cssClasses.ROOT}`); @@ -149,8 +149,8 @@ export class MDCDrawer extends MDCComponent { }, notifyClose: () => this.emit(strings.CLOSE_EVENT, {}, true /* shouldBubble */), notifyOpen: () => this.emit(strings.OPEN_EVENT, {}, true /* shouldBubble */), - trapFocus: () => this.focusTrap_!.activate(), - releaseFocus: () => this.focusTrap_!.deactivate(), + trapFocus: () => this.focusTrap_!.trapFocus(), + releaseFocus: () => this.focusTrap_!.releaseFocus(), }; // tslint:enable:object-literal-sort-keys From c5d951c57311fa9277e18ca7b355c371671d3310 Mon Sep 17 00:00:00 2001 From: Joy Zhong Date: Wed, 22 Jan 2020 15:46:21 -0500 Subject: [PATCH 07/11] Remove lint check --- scripts/lint-mdc.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/scripts/lint-mdc.ts b/scripts/lint-mdc.ts index 4578ee564ae..b90ced05849 100644 --- a/scripts/lint-mdc.ts +++ b/scripts/lint-mdc.ts @@ -450,15 +450,6 @@ function checkOneClassMemberAccessibility( const isPublicAccess = accessibility !== 'private' && accessibility !== 'protected'; const isPublicName = !name.endsWith('_'); - if (!isPublicAccess && isPublicName) { - logLinterViolation( - inputFilePath, - loc, - `Non-public member '${name}' is missing a trailing underscore in its name.`, - ); - return; - } - if (isPublicAccess && !isPublicName) { logLinterViolation( inputFilePath, From 9dbe3836dd072ca205d38b24c4e8223b31ea0bb7 Mon Sep 17 00:00:00 2001 From: Joy Zhong Date: Wed, 22 Jan 2020 15:48:23 -0500 Subject: [PATCH 08/11] Remove async test --- packages/mdc-dom/test/focus-trap.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mdc-dom/test/focus-trap.test.ts b/packages/mdc-dom/test/focus-trap.test.ts index 0843f5eb972..0a8b62686b0 100644 --- a/packages/mdc-dom/test/focus-trap.test.ts +++ b/packages/mdc-dom/test/focus-trap.test.ts @@ -75,7 +75,7 @@ describe('FocusTrap', () => { }); }); - it('traps focus in the given container element', async () => { + it('traps focus in the given container element', () => { const {container1, container2} = setUp(); const focusTrap1 = new FocusTrap(container1); focusTrap1.trapFocus(); @@ -86,7 +86,7 @@ describe('FocusTrap', () => { expectFocusTrapped(container2, 'con2a', 'con2b'); }); - it('releases focus from the given container element', async () => { + it('releases focus from the given container element', () => { const {container1} = setUp(); const focusTrap1 = new FocusTrap(container1); focusTrap1.trapFocus(); From e31190454c14a880efb99a0e3d1d0ea94dcd0e9c Mon Sep 17 00:00:00 2001 From: Joy Zhong Date: Wed, 22 Jan 2020 15:58:11 -0500 Subject: [PATCH 09/11] karma change --- karma.conf.js | 2 ++ packages/mdc-dom/test/focus-trap.test.ts | 3 --- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/karma.conf.js b/karma.conf.js index a42389b63f8..c2925f8df8d 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -82,6 +82,7 @@ const istanbulInstrumenterLoader = { /checkbox\/.*$/, /chips\/.*$/, /constants.[jt]s$/, + /dom\/.*$/, /data-table\/.*$/, /floating-label\/.*$/, /form-field\/.*$/, @@ -177,6 +178,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)/**/*', diff --git a/packages/mdc-dom/test/focus-trap.test.ts b/packages/mdc-dom/test/focus-trap.test.ts index 0a8b62686b0..737e9055047 100644 --- a/packages/mdc-dom/test/focus-trap.test.ts +++ b/packages/mdc-dom/test/focus-trap.test.ts @@ -94,9 +94,6 @@ describe('FocusTrap', () => { focusTrap1.releaseFocus(); expect(container1.querySelectorAll('.mdc-focus-sentinel').length).toBe(0); - // Since no previously focused element, focus should remain on the first - // child of `container1`. - expect(document.activeElement!.id).toBe('con1a'); }); it('restores focus to previously focused element', () => { From e820b375508ab008ecede1aa7b7fbbc33a894113 Mon Sep 17 00:00:00 2001 From: Joy Zhong Date: Thu, 23 Jan 2020 16:10:00 -0500 Subject: [PATCH 10/11] Sync with internal --- packages/mdc-dom/focus-trap.ts | 103 +++++++++++------------ packages/mdc-dom/test/focus-trap.test.ts | 80 +++++++++++------- 2 files changed, 99 insertions(+), 84 deletions(-) diff --git a/packages/mdc-dom/focus-trap.ts b/packages/mdc-dom/focus-trap.ts index 3703f024f4c..0e3ed4a6e00 100644 --- a/packages/mdc-dom/focus-trap.ts +++ b/packages/mdc-dom/focus-trap.ts @@ -21,44 +21,53 @@ * THE SOFTWARE. */ -const FOCUS_SENTINEL_CLASS = 'mdc-focus-sentinel'; +const FOCUS_SENTINEL_CLASS = 'mdc-dom-focus-sentinel'; /** - * Utility to trap focus in a given element, e.g. for modal components such - * as dialogs. + * 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; + private elFocusedBeforeTrapFocus: HTMLElement|null = null; - constructor(private readonly el: HTMLElement, private readonly options: FocusOptions = {}) {} + constructor( + private readonly root: HTMLElement, + private readonly options: FocusOptions = {}) {} /** - * Traps focus in `el`. Also focuses on either `initialFocusEl` if set; + * 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.el); + document.activeElement instanceof HTMLElement ? document.activeElement : + null; + this.wrapTabFocus(this.root, focusableEls); if (!this.options.skipInitialFocus) { - this.focusInitialElement(this.options.initialFocusEl); + this.focusInitialElement(focusableEls, this.options.initialFocusEl); } } /** - * Releases focus from `el`. Also restores focus to the previously focused + * Releases focus from `root`. Also restores focus to the previously focused * element. */ releaseFocus() { - [].slice.call(this.el.querySelectorAll(`.${FOCUS_SENTINEL_CLASS}`)) - .forEach((sentinelEl: HTMLElement) => { - sentinelEl.parentElement!.removeChild(sentinelEl); - }); + [].slice.call(this.root.querySelectorAll(`.${FOCUS_SENTINEL_CLASS}`)) + .forEach((sentinelEl: HTMLElement) => { + sentinelEl.parentElement!.removeChild(sentinelEl); + }); if (this.elFocusedBeforeTrapFocus) { this.elFocusedBeforeTrapFocus.focus(); @@ -72,15 +81,19 @@ export class FocusTrap { * children elements of the tabbable region, ensuring that focus is trapped * within that region. */ - private wrapTabFocus(el: HTMLElement) { + private wrapTabFocus(el: HTMLElement, focusableEls: HTMLElement[]) { const sentinelStart = this.createSentinel(); const sentinelEnd = this.createSentinel(); sentinelStart.addEventListener('focus', () => { - this.focusLast(el); + if (focusableEls.length > 0) { + focusableEls[focusableEls.length - 1].focus(); + } }); sentinelEnd.addEventListener('focus', () => { - this.focusFirst(el); + if (focusableEls.length > 0) { + focusableEls[0].focus(); + } }); el.insertBefore(sentinelStart, el.children[0]); @@ -91,53 +104,34 @@ export class FocusTrap { * 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(initialFocusEl?: HTMLElement) { - const focusableElements = this.getFocusableElements(this.el); - const focusIndex = Math.max( - initialFocusEl ? focusableElements.indexOf(initialFocusEl) : 0, - 0); - focusableElements[focusIndex].focus(); - } - - /** - * Focuses first focusable child element of `el`. - */ - private focusFirst(el: HTMLElement) { - const focusableEls = this.getFocusableElements(el); - if (focusableEls.length > 0) { - focusableEls[0].focus(); - } - } - - /** - * Focuses last focusable child element of `el`. - */ - private focusLast(el: HTMLElement) { - const focusableEls = this.getFocusableElements(el); - if (focusableEls.length > 0) { - focusableEls[focusableEls.length - 1].focus(); + 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[]; + 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 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; + 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'; + style.display === 'none' || style.visibility === 'hidden'; } return isTabbableAndVisible && !isProgrammaticallyHidden; }); @@ -161,5 +155,6 @@ export interface FocusOptions { // 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; } diff --git a/packages/mdc-dom/test/focus-trap.test.ts b/packages/mdc-dom/test/focus-trap.test.ts index 737e9055047..f18ae063d41 100644 --- a/packages/mdc-dom/test/focus-trap.test.ts +++ b/packages/mdc-dom/test/focus-trap.test.ts @@ -21,29 +21,32 @@ * THE SOFTWARE. */ -import {FocusTrap} from '../focus-trap'; import {emitEvent} from '../../../testing/dom/events'; +import {FocusTrap} from '../focus-trap'; + +const FOCUS_SENTINEL_CLASS = 'mdc-dom-focus-sentinel'; function getFixture() { const wrapper = document.createElement('div'); wrapper.innerHTML = `
-
-
a
+
+
1a
-
b
+
1b
-
-
c
-
d
+
+
2a
+
2b
-
- -
d
+
+ + +
3c
-
+
@@ -51,6 +54,9 @@ function getFixture() {
+
+
5a
+
`; const el = wrapper.firstElementChild as HTMLElement; wrapper.removeChild(el); @@ -61,11 +67,15 @@ function setUp() { const root = getFixture(); document.body.appendChild(root); const button = root.querySelector('button') as HTMLElement; - const container1 = root.querySelector('#container1') as HTMLElement; - const container2 = root.querySelector('#container2') as HTMLElement; - const container3 = root.querySelector('#container3') as HTMLElement; - const container4 = root.querySelector('#container4') as HTMLElement; - return {button, container1, container2, container3, container4}; + const container1 = root.querySelector('#container1_innerDiv') as HTMLElement; + const container2 = root.querySelector('#container2_standard') as HTMLElement; + const container3 = + root.querySelector('#container3_notVisibleElements') as HTMLElement; + const container4 = + root.querySelector('#container4_disabledOrHiddenElements') as HTMLElement; + const container5 = + root.querySelector('#container5_noFocusableChild') as HTMLElement; + return {button, container1, container2, container3, container4, container5}; } describe('FocusTrap', () => { @@ -93,7 +103,8 @@ describe('FocusTrap', () => { expectFocusTrapped(container1, 'con1a', 'con1b'); focusTrap1.releaseFocus(); - expect(container1.querySelectorAll('.mdc-focus-sentinel').length).toBe(0); + expect(container1.querySelectorAll(`.${FOCUS_SENTINEL_CLASS}`).length) + .toBe(0); }); it('restores focus to previously focused element', () => { @@ -115,27 +126,28 @@ describe('FocusTrap', () => { const {container3} = setUp(); const focusTrap = new FocusTrap(container3); focusTrap.trapFocus(); - expect(document.activeElement!.id).toBe('con3b'); + expect(document.activeElement!.id).toBe('con3c'); }); - it('sets initial focus to first non-hidden/non-disabled focusable element', () => { - const {container4} = setUp(); - const focusTrap = new FocusTrap(container4); - focusTrap.trapFocus(); - expect(document.activeElement!.id).toBe('con4e'); - }); + it('sets initial focus to first non-hidden/non-disabled focusable element', + () => { + const {container4} = setUp(); + const focusTrap = new FocusTrap(container4); + focusTrap.trapFocus(); + expect(document.activeElement!.id).toBe('con4e'); + }); it('sets initial focus to initialFocusEl', () => { const {container1} = setUp(); const initialFocusEl = container1.querySelector('#con1b') as HTMLElement; - const focusTrap = new FocusTrap(container1, { initialFocusEl }); + const focusTrap = new FocusTrap(container1, {initialFocusEl}); focusTrap.trapFocus(); expect(document.activeElement!.id).toBe('con1b'); }); it('does not set initial focus when skipInitialFocus=true', () => { const {button, container1} = setUp(); - const focusTrap = new FocusTrap(container1, { skipInitialFocus: true }); + const focusTrap = new FocusTrap(container1, {skipInitialFocus: true}); // First, set focus to button. button.focus(); @@ -145,17 +157,25 @@ describe('FocusTrap', () => { // Focus should remain on button. expect(document.activeElement).toBe(button); }); + + it('throws an error when trapping focus in an element with 0 focusable elements', + () => { + const {container5} = setUp(); + const focusTrap = new FocusTrap(container5); + expect(() => { + focusTrap.trapFocus(); + }) + .toThrow(jasmine.stringMatching( + /Element must have at least one focusable child/)); + }); }); function expectFocusTrapped( el: HTMLElement, firstElementId: string, lastElementId: string) { expect(document.activeElement!.id).toBe(firstElementId); - const focusSentinels = el.querySelectorAll('.mdc-focus-sentinel'); + const focusSentinels = el.querySelectorAll(`.${FOCUS_SENTINEL_CLASS}`); const startFocusSentinel = focusSentinels[0] as HTMLElement; const endFocusSentinel = focusSentinels[1] as HTMLElement; - // Sentinels are in the right part of the DOM tree. - expect(el.firstElementChild as HTMLElement).toBe(startFocusSentinel); - expect(el.lastElementChild as HTMLElement).toBe(endFocusSentinel); // Patch #addEventListener to make it synchronous for `focus` events. const fakeFocusHandler = (eventName: string, eventHandler: any) => { From c414f530747db5629ffbb6cda2bf6d58dbef9c8d Mon Sep 17 00:00:00 2001 From: Joy Zhong Date: Thu, 23 Jan 2020 16:36:31 -0500 Subject: [PATCH 11/11] Remove focus-trap dep --- packages/mdc-dialog/package.json | 1 - packages/mdc-drawer/package.json | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/mdc-dialog/package.json b/packages/mdc-dialog/package.json index 8d502e6808e..d21d0f54d72 100644 --- a/packages/mdc-dialog/package.json +++ b/packages/mdc-dialog/package.json @@ -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": { diff --git a/packages/mdc-drawer/package.json b/packages/mdc-drawer/package.json index 36d1a7fe093..c91ae28482a 100644 --- a/packages/mdc-drawer/package.json +++ b/packages/mdc-drawer/package.json @@ -30,7 +30,6 @@ "@material/shape": "^4.0.0", "@material/theme": "^4.0.0", "@material/typography": "^4.0.0", - "focus-trap": "^5.0.0", "tslib": "^1.9.3" } }