From f8ba48fd44131e6bd83b2c0e204b2633fbf42ae0 Mon Sep 17 00:00:00 2001 From: "Andrew C. Dvorak" Date: Wed, 13 Feb 2019 19:16:32 -0800 Subject: [PATCH] feat(drawer): Convert JS to TypeScript (#4390) Refs #4225 --- packages/mdc-dialog/index.ts | 6 +- packages/mdc-dialog/util.ts | 18 ++- .../mdc-drawer/{adapter.js => adapter.ts} | 49 +++---- .../mdc-drawer/{constants.js => constants.ts} | 11 +- .../{foundation.js => foundation.ts} | 115 +++++++-------- packages/mdc-drawer/{index.js => index.ts} | 134 ++++++++---------- .../modal/{foundation.js => foundation.ts} | 10 +- packages/mdc-drawer/types.ts | 32 +++++ packages/mdc-drawer/{util.js => util.ts} | 23 ++- scripts/webpack/js-bundle-factory.js | 2 +- test/unit/mdc-dialog/util.test.js | 18 +-- .../mdc-drawer/dismissible.foundation.test.js | 8 +- test/unit/mdc-drawer/mdc-drawer.test.js | 91 ++++++++++-- test/unit/mdc-drawer/modal.foundation.test.js | 3 +- test/unit/mdc-drawer/util.test.js | 11 +- 15 files changed, 287 insertions(+), 244 deletions(-) rename packages/mdc-drawer/{adapter.js => adapter.ts} (70%) rename packages/mdc-drawer/{constants.js => constants.ts} (97%) rename packages/mdc-drawer/dismissible/{foundation.js => foundation.ts} (68%) rename packages/mdc-drawer/{index.js => index.ts} (52%) rename packages/mdc-drawer/modal/{foundation.js => foundation.ts} (87%) create mode 100644 packages/mdc-drawer/types.ts rename packages/mdc-drawer/{util.js => util.ts} (67%) diff --git a/packages/mdc-dialog/index.ts b/packages/mdc-dialog/index.ts index 41456697a32..dce700fb22f 100644 --- a/packages/mdc-dialog/index.ts +++ b/packages/mdc-dialog/index.ts @@ -70,7 +70,7 @@ class MDCDialog extends MDCComponent { private container_!: HTMLElement; // assigned in initialize() private content_!: HTMLElement | null; // assigned in initialize() private defaultButton_!: HTMLElement | null; // assigned in initialize() - private initialFocusEl_!: HTMLElement | null; // assigned in initialize() + private initialFocusEl_?: HTMLElement; // assigned in initialize() private focusTrap_!: createFocusTrap.FocusTrap; // assigned in initialSyncWithDOM() private focusTrapFactory_!: FocusTrapFactory; // assigned in initialize() @@ -83,7 +83,7 @@ class MDCDialog extends MDCComponent { initialize( focusTrapFactory: FocusTrapFactory = createFocusTrap as unknown as FocusTrapFactory, - initialFocusEl: Element | null = null) { + initialFocusEl?: HTMLElement) { const container = this.root_.querySelector(strings.CONTAINER_SELECTOR); if (!container) { throw new Error(`Dialog component requires a ${strings.CONTAINER_SELECTOR} container element`); @@ -93,7 +93,7 @@ class MDCDialog extends MDCComponent { this.buttons_ = [].slice.call(this.root_.querySelectorAll(strings.BUTTON_SELECTOR)); this.defaultButton_ = this.root_.querySelector(strings.DEFAULT_BUTTON_SELECTOR); this.focusTrapFactory_ = focusTrapFactory; - this.initialFocusEl_ = initialFocusEl as HTMLElement; + this.initialFocusEl_ = initialFocusEl; this.buttonRipples_ = []; for (const buttonEl of this.buttons_) { diff --git a/packages/mdc-dialog/util.ts b/packages/mdc-dialog/util.ts index 1de641bcde7..1f332a28216 100644 --- a/packages/mdc-dialog/util.ts +++ b/packages/mdc-dialog/util.ts @@ -24,26 +24,24 @@ import * as createFocusTrap from 'focus-trap'; import {FocusTrapFactory} from './types'; -function createFocusTrapInstance( +export function createFocusTrapInstance( surfaceEl: HTMLElement, focusTrapFactory: FocusTrapFactory = createFocusTrap as unknown as FocusTrapFactory, - initialFocusEl: createFocusTrap.FocusTarget | null, + initialFocusEl?: createFocusTrap.FocusTarget, ): createFocusTrap.FocusTrap { - return focusTrapFactory(surfaceEl, ({ - clickOutsideDeactivates: true, // Allow handling of scrim clicks - escapeDeactivates: false, // Dialog foundation handles escape key + return focusTrapFactory(surfaceEl, { + clickOutsideDeactivates: true, // Allow handling of scrim clicks. + escapeDeactivates: false, // Foundation handles ESC key. initialFocus: initialFocusEl, - } as createFocusTrap.Options)); + }); } -function isScrollable(el: HTMLElement | null): boolean { +export function isScrollable(el: HTMLElement | null): boolean { return el ? el.scrollHeight > el.offsetHeight : false; } -function areTopsMisaligned(els: HTMLElement[]): boolean { +export function areTopsMisaligned(els: HTMLElement[]): boolean { const tops = new Set(); [].forEach.call(els, (el: HTMLElement) => tops.add(el.offsetTop)); return tops.size > 1; } - -export {createFocusTrapInstance, isScrollable, areTopsMisaligned}; diff --git a/packages/mdc-drawer/adapter.js b/packages/mdc-drawer/adapter.ts similarity index 70% rename from packages/mdc-drawer/adapter.js rename to packages/mdc-drawer/adapter.ts index 5a927c64bbb..8448135b34c 100644 --- a/packages/mdc-drawer/adapter.js +++ b/packages/mdc-drawer/adapter.ts @@ -21,79 +21,70 @@ * THE SOFTWARE. */ -/* eslint no-unused-vars: [2, {"args": "none"}] */ - /** - * Adapter for MDC Drawer - * - * Defines the shape of the adapter expected by the foundation. Implement this - * adapter to integrate the Drawer into your framework. See - * https://github.com/material-components/material-components-web/blob/master/docs/authoring-components.md - * for more information. - * - * @record + * Defines the shape of the adapter expected by the foundation. + * Implement this adapter for your framework of choice to delegate updates to + * the component in your framework of choice. See architecture documentation + * for more details. + * https://github.com/material-components/material-components-web/blob/master/docs/code/architecture.md */ -class MDCDrawerAdapter { +interface MDCDrawerAdapter { /** * Adds a class to the root Element. - * @param {string} className */ - addClass(className) {} + addClass(className: string): void; /** * Removes a class from the root Element. - * @param {string} className */ - removeClass(className) {} + removeClass(className: string): void; /** * Returns true if the root Element contains the given class. - * @param {string} className - * @return {boolean} */ - hasClass(className) {} + hasClass(className: string): boolean; /** - * @param {!Element} element target element to verify class name - * @param {string} className class name + * @param element target element to verify class name + * @param className class name */ - elementHasClass(element, className) {} + elementHasClass(element: Element, className: string): boolean; /** * Saves the focus of currently active element. */ - saveFocus() {} + saveFocus(): void; /** * Restores focus to element previously saved with 'saveFocus'. */ - restoreFocus() {} + restoreFocus(): void; /** * Focuses the active / selected navigation item. */ - focusActiveNavigationItem() {} + focusActiveNavigationItem(): void; /** * Emits a custom event "MDCDrawer:closed" denoting the drawer has closed. */ - notifyClose() {} + notifyClose(): void; /** * Emits a custom event "MDCDrawer:opened" denoting the drawer has opened. */ - notifyOpen() {} + notifyOpen(): void; /** * Traps focus on root element and focuses the active navigation element. */ - trapFocus() {} + trapFocus(): void; /** * Releases focus trap from root element which was set by `trapFocus` * and restores focus to where it was prior to calling `trapFocus`. */ - releaseFocus() {} + releaseFocus(): void; } -export default MDCDrawerAdapter; +export {MDCDrawerAdapter as default, MDCDrawerAdapter}; diff --git a/packages/mdc-drawer/constants.js b/packages/mdc-drawer/constants.ts similarity index 97% rename from packages/mdc-drawer/constants.js rename to packages/mdc-drawer/constants.ts index b6592611496..d6d9fa2755f 100644 --- a/packages/mdc-drawer/constants.js +++ b/packages/mdc-drawer/constants.ts @@ -20,23 +20,22 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ -/** @enum {string} */ + const cssClasses = { - ROOT: 'mdc-drawer', + ANIMATE: 'mdc-drawer--animate', + CLOSING: 'mdc-drawer--closing', DISMISSIBLE: 'mdc-drawer--dismissible', MODAL: 'mdc-drawer--modal', OPEN: 'mdc-drawer--open', - ANIMATE: 'mdc-drawer--animate', OPENING: 'mdc-drawer--opening', - CLOSING: 'mdc-drawer--closing', + ROOT: 'mdc-drawer', }; -/** @enum {string} */ const strings = { APP_CONTENT_SELECTOR: '.mdc-drawer-app-content', - SCRIM_SELECTOR: '.mdc-drawer-scrim', CLOSE_EVENT: 'MDCDrawer:closed', OPEN_EVENT: 'MDCDrawer:opened', + SCRIM_SELECTOR: '.mdc-drawer-scrim', }; export {cssClasses, strings}; diff --git a/packages/mdc-drawer/dismissible/foundation.js b/packages/mdc-drawer/dismissible/foundation.ts similarity index 68% rename from packages/mdc-drawer/dismissible/foundation.js rename to packages/mdc-drawer/dismissible/foundation.ts index 50a0f8dc4f4..3432039e3ad 100644 --- a/packages/mdc-drawer/dismissible/foundation.js +++ b/packages/mdc-drawer/dismissible/foundation.ts @@ -21,48 +21,42 @@ * THE SOFTWARE. */ -import MDCDrawerAdapter from '../adapter'; import {MDCFoundation} from '@material/base/foundation'; +import {MDCDrawerAdapter} from '../adapter'; import {cssClasses, strings} from '../constants'; -/** - * @extends {MDCFoundation} - */ -class MDCDismissibleDrawerFoundation extends MDCFoundation { - /** @return enum {string} */ +class MDCDismissibleDrawerFoundation extends MDCFoundation { static get strings() { return strings; } - /** @return enum {string} */ static get cssClasses() { return cssClasses; } - static get defaultAdapter() { - return /** @type {!MDCDrawerAdapter} */ ({ - addClass: (/* className: string */) => {}, - removeClass: (/* className: string */) => {}, - hasClass: (/* className: string */) => {}, - elementHasClass: (/* element: !Element, className: string */) => {}, - notifyClose: () => {}, - notifyOpen: () => {}, - saveFocus: () => {}, - restoreFocus: () => {}, - focusActiveNavigationItem: () => {}, - trapFocus: () => {}, - releaseFocus: () => {}, - }); + static get defaultAdapter(): MDCDrawerAdapter { + // tslint:disable:object-literal-sort-keys + return { + addClass: () => undefined, + removeClass: () => undefined, + hasClass: () => false, + elementHasClass: () => false, + notifyClose: () => undefined, + notifyOpen: () => undefined, + saveFocus: () => undefined, + restoreFocus: () => undefined, + focusActiveNavigationItem: () => undefined, + trapFocus: () => undefined, + releaseFocus: () => undefined, + }; + // tslint:enable:object-literal-sort-keys } - constructor(adapter) { - super(Object.assign(MDCDismissibleDrawerFoundation.defaultAdapter, adapter)); - - /** @private {number} */ - this.animationFrame_ = 0; + private animationFrame_ = 0; + private animationTimer_ = 0; - /** @private {number} */ - this.animationTimer_ = 0; + constructor(adapter?: Partial) { + super({...MDCDismissibleDrawerFoundation.defaultAdapter, ...adapter}); } destroy() { @@ -74,9 +68,6 @@ class MDCDismissibleDrawerFoundation extends MDCFoundation { } } - /** - * Function to open the drawer. - */ open() { if (this.isOpen() || this.isOpening() || this.isClosing()) { return; @@ -93,9 +84,6 @@ class MDCDismissibleDrawerFoundation extends MDCFoundation { this.adapter_.saveFocus(); } - /** - * Function to close the drawer. - */ close() { if (!this.isOpen() || this.isOpening() || this.isClosing()) { return; @@ -105,48 +93,31 @@ class MDCDismissibleDrawerFoundation extends MDCFoundation { } /** - * Extension point for when drawer finishes open animation. - * @protected + * @return true if drawer is in open state. */ - opened() {} - - /** - * Extension point for when drawer finishes close animation. - * @protected - */ - closed() {} - - /** - * Returns true if drawer is in open state. - * @return {boolean} - */ - isOpen() { + isOpen(): boolean { return this.adapter_.hasClass(cssClasses.OPEN); } /** - * Returns true if drawer is animating open. - * @return {boolean} + * @return true if drawer is animating open. */ - isOpening() { + isOpening(): boolean { return this.adapter_.hasClass(cssClasses.OPENING) || this.adapter_.hasClass(cssClasses.ANIMATE); } /** - * Returns true if drawer is animating closed. - * @return {boolean} + * @return true if drawer is animating closed. */ - isClosing() { + isClosing(): boolean { return this.adapter_.hasClass(cssClasses.CLOSING); } /** * Keydown handler to close drawer when key is escape. - * @param evt */ - handleKeydown(evt) { + handleKeydown(evt: KeyboardEvent) { const {keyCode, key} = evt; - const isEscape = key === 'Escape' || keyCode === 27; if (isEscape) { this.close(); @@ -155,14 +126,13 @@ class MDCDismissibleDrawerFoundation extends MDCFoundation { /** * Handles a transition end event on the root element. - * @param {!Event} evt */ - handleTransitionEnd(evt) { + handleTransitionEnd(evt: TransitionEvent) { const {OPENING, CLOSING, OPEN, ANIMATE, ROOT} = cssClasses; // In Edge, transitionend on ripple pseudo-elements yields a target without classList, so check for Element first. - const isElement = evt.target instanceof Element; - if (!isElement || !this.adapter_.elementHasClass(/** @type {!Element} */ (evt.target), ROOT)) { + const isRootElement = this.isElement_(evt.target) && this.adapter_.elementHasClass(evt.target, ROOT); + if (!isRootElement) { return; } @@ -182,12 +152,20 @@ class MDCDismissibleDrawerFoundation extends MDCFoundation { this.adapter_.removeClass(CLOSING); } + /** + * Extension point for when drawer finishes open animation. + */ + protected opened() {} // tslint:disable-line:no-empty + + /** + * Extension point for when drawer finishes close animation. + */ + protected closed() {} // tslint:disable-line:no-empty + /** * Runs the given logic on the next animation frame, using setTimeout to factor in Firefox reflow behavior. - * @param {Function} callback - * @private */ - runNextAnimationFrame_(callback) { + private runNextAnimationFrame_(callback: () => void) { cancelAnimationFrame(this.animationFrame_); this.animationFrame_ = requestAnimationFrame(() => { this.animationFrame_ = 0; @@ -195,6 +173,11 @@ class MDCDismissibleDrawerFoundation extends MDCFoundation { this.animationTimer_ = setTimeout(callback, 0); }); } + + private isElement_(element: unknown): element is Element { + // In Edge, transitionend on ripple pseudo-elements yields a target without classList. + return Boolean((element as Element).classList); + } } -export default MDCDismissibleDrawerFoundation; +export {MDCDismissibleDrawerFoundation as default, MDCDismissibleDrawerFoundation}; diff --git a/packages/mdc-drawer/index.js b/packages/mdc-drawer/index.ts similarity index 52% rename from packages/mdc-drawer/index.js rename to packages/mdc-drawer/index.ts index a08f1f27b98..5f57eaeb2f3 100644 --- a/packages/mdc-drawer/index.js +++ b/packages/mdc-drawer/index.ts @@ -20,73 +20,35 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ + import {MDCComponent} from '@material/base/component'; -import MDCDismissibleDrawerFoundation from './dismissible/foundation'; -import MDCModalDrawerFoundation from './modal/foundation'; -import MDCDrawerAdapter from './adapter'; -import {MDCList} from '@material/list/index'; +import {SpecificEventListener} from '@material/base/index'; import {MDCListFoundation} from '@material/list/foundation'; +import {MDCList} from '@material/list/index'; +import * as createFocusTrap from 'focus-trap'; +import {MDCDrawerAdapter} from './adapter'; import {strings} from './constants'; +import {MDCDismissibleDrawerFoundation} from './dismissible/foundation'; +import {MDCModalDrawerFoundation} from './modal/foundation'; +import {FocusTrapFactory, ListFactory} from './types'; import * as util from './util'; -import createFocusTrap from 'focus-trap'; - -/** - * @extends {MDCComponent} - * @final - */ -class MDCDrawer extends MDCComponent { - /** - * @param {...?} args - */ - constructor(...args) { - super(...args); - - /** @private {!Element} */ - this.previousFocus_; - - /** @private {!Function} */ - this.handleKeydown_; - - /** @private {!Function} */ - this.handleTransitionEnd_; - - /** @private {!Function} */ - this.focusTrapFactory_; - /** @private {!FocusTrapInstance} */ - this.focusTrap_; - - /** @private {?Element} */ - this.scrim_; - - /** @private {?Function} */ - this.handleScrimClick_; - - /** @private {?MDCList} */ - this.list_; - } - - /** - * @param {!Element} root - * @return {!MDCDrawer} - */ - static attachTo(root) { +class MDCDrawer extends MDCComponent { + static attachTo(root: Element): MDCDrawer { return new MDCDrawer(root); } /** * Returns true if drawer is in the open position. - * @return {boolean} */ - get open() { + get open(): boolean { return this.foundation_.isOpen(); } /** * Toggles the drawer open and closed. - * @param {boolean} isOpen */ - set open(isOpen) { + set open(isOpen: boolean) { if (isOpen) { this.foundation_.open(); } else { @@ -94,10 +56,21 @@ class MDCDrawer extends MDCComponent { } } + private previousFocus_?: Element | null; + private scrim_!: HTMLElement | null; // assigned in initialSyncWithDOM() + private list_?: MDCList; // assigned in initialize() + + private focusTrap_?: createFocusTrap.FocusTrap; // assigned in initialSyncWithDOM() + private focusTrapFactory_!: FocusTrapFactory; // assigned in initialize() + + private handleScrimClick_?: SpecificEventListener<'click'>; // initialized in initialSyncWithDOM() + private handleKeydown_!: SpecificEventListener<'keydown'>; // initialized in initialSyncWithDOM() + private handleTransitionEnd_!: SpecificEventListener<'transitionend'>; // initialized in initialSyncWithDOM() + initialize( - focusTrapFactory = createFocusTrap, - listFactory = (el) => new MDCList(el)) { - const listEl = /** @type {!Element} */ (this.root_.querySelector(`.${MDCListFoundation.cssClasses.ROOT}`)); + focusTrapFactory: FocusTrapFactory = createFocusTrap as unknown as FocusTrapFactory, + listFactory: ListFactory = (el) => new MDCList(el)) { + const listEl = this.root_.querySelector(`.${MDCListFoundation.cssClasses.ROOT}`); if (listEl) { this.list_ = listFactory(listEl); this.list_.wrapFocus = true; @@ -107,65 +80,66 @@ class MDCDrawer extends MDCComponent { initialSyncWithDOM() { const {MODAL} = MDCDismissibleDrawerFoundation.cssClasses; + const {SCRIM_SELECTOR} = MDCDismissibleDrawerFoundation.strings; + + this.scrim_ = (this.root_.parentNode as Element).querySelector(SCRIM_SELECTOR); - if (this.root_.classList.contains(MODAL)) { - const {SCRIM_SELECTOR} = MDCDismissibleDrawerFoundation.strings; - this.scrim_ = /** @type {!Element} */ (this.root_.parentNode.querySelector(SCRIM_SELECTOR)); - this.handleScrimClick_ = () => /** @type {!MDCModalDrawerFoundation} */ (this.foundation_).handleScrimClick(); + if (this.scrim_ && this.root_.classList.contains(MODAL)) { + this.handleScrimClick_ = () => (this.foundation_ as MDCModalDrawerFoundation).handleScrimClick(); this.scrim_.addEventListener('click', this.handleScrimClick_); - this.focusTrap_ = util.createFocusTrapInstance(this.root_, this.focusTrapFactory_); + this.focusTrap_ = util.createFocusTrapInstance(this.root_ as HTMLElement, this.focusTrapFactory_); } this.handleKeydown_ = (evt) => this.foundation_.handleKeydown(evt); this.handleTransitionEnd_ = (evt) => this.foundation_.handleTransitionEnd(evt); - this.root_.addEventListener('keydown', this.handleKeydown_); - this.root_.addEventListener('transitionend', this.handleTransitionEnd_); + this.listen('keydown', this.handleKeydown_); + this.listen('transitionend', this.handleTransitionEnd_); } destroy() { - this.root_.removeEventListener('keydown', this.handleKeydown_); - this.root_.removeEventListener('transitionend', this.handleTransitionEnd_); + this.unlisten('keydown', this.handleKeydown_); + this.unlisten('transitionend', this.handleTransitionEnd_); if (this.list_) { this.list_.destroy(); } const {MODAL} = MDCDismissibleDrawerFoundation.cssClasses; - if (this.root_.classList.contains(MODAL)) { - this.scrim_.removeEventListener('click', /** @type {!Function} */ (this.handleScrimClick_)); + if (this.scrim_ && this.handleScrimClick_ && this.root_.classList.contains(MODAL)) { + this.scrim_.removeEventListener('click', this.handleScrimClick_); // Ensure drawer is closed to hide scrim and release focus this.open = false; } } - getDefaultFoundation() { - /** @type {!MDCDrawerAdapter} */ - const adapter = /** @type {!MDCDrawerAdapter} */ (Object.assign({ + getDefaultFoundation(): MDCDismissibleDrawerFoundation { + // tslint:disable:object-literal-sort-keys + const adapter: MDCDrawerAdapter = { addClass: (className) => this.root_.classList.add(className), removeClass: (className) => this.root_.classList.remove(className), hasClass: (className) => this.root_.classList.contains(className), elementHasClass: (element, className) => element.classList.contains(className), - saveFocus: () => { - this.previousFocus_ = document.activeElement; - }, + saveFocus: () => this.previousFocus_ = document.activeElement, restoreFocus: () => { - const previousFocus = this.previousFocus_ && this.previousFocus_.focus; - if (this.root_.contains(document.activeElement) && previousFocus) { - this.previousFocus_.focus(); + const previousFocus = this.previousFocus_ as HTMLOrSVGElement | null; + if (previousFocus && previousFocus.focus && this.root_.contains(document.activeElement)) { + previousFocus.focus(); } }, focusActiveNavigationItem: () => { - const activeNavItemEl = this.root_.querySelector(`.${MDCListFoundation.cssClasses.LIST_ITEM_ACTIVATED_CLASS}`); + const activeNavItemEl = + this.root_.querySelector(`.${MDCListFoundation.cssClasses.LIST_ITEM_ACTIVATED_CLASS}`); if (activeNavItemEl) { activeNavItemEl.focus(); } }, 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_!.activate(), + releaseFocus: () => this.focusTrap_!.deactivate(), + }; + // tslint:enable:object-literal-sort-keys const {DISMISSIBLE, MODAL} = MDCDismissibleDrawerFoundation.cssClasses; if (this.root_.classList.contains(DISMISSIBLE)) { @@ -179,4 +153,8 @@ class MDCDrawer extends MDCComponent { } } -export {MDCDrawer, MDCDismissibleDrawerFoundation, MDCModalDrawerFoundation, util}; +export {MDCDrawer as default, MDCDrawer, util}; +export * from './dismissible/foundation'; +export * from './modal/foundation'; +export * from './adapter'; +export * from './types'; diff --git a/packages/mdc-drawer/modal/foundation.js b/packages/mdc-drawer/modal/foundation.ts similarity index 87% rename from packages/mdc-drawer/modal/foundation.js rename to packages/mdc-drawer/modal/foundation.ts index 99166faa33b..3f683d550ff 100644 --- a/packages/mdc-drawer/modal/foundation.js +++ b/packages/mdc-drawer/modal/foundation.ts @@ -21,15 +21,12 @@ * THE SOFTWARE. */ -import MDCDismissibleDrawerFoundation from '../dismissible/foundation'; +import {MDCDismissibleDrawerFoundation} from '../dismissible/foundation'; -/** - * @extends {MDCDismissibleDrawerFoundation} - */ +/* istanbul ignore next: subclass is not a branch statement */ class MDCModalDrawerFoundation extends MDCDismissibleDrawerFoundation { /** * Called when drawer finishes open animation. - * @override */ opened() { this.adapter_.trapFocus(); @@ -37,7 +34,6 @@ class MDCModalDrawerFoundation extends MDCDismissibleDrawerFoundation { /** * Called when drawer finishes close animation. - * @override */ closed() { this.adapter_.releaseFocus(); @@ -51,4 +47,4 @@ class MDCModalDrawerFoundation extends MDCDismissibleDrawerFoundation { } } -export default MDCModalDrawerFoundation; +export {MDCModalDrawerFoundation as default, MDCModalDrawerFoundation}; diff --git a/packages/mdc-drawer/types.ts b/packages/mdc-drawer/types.ts new file mode 100644 index 00000000000..ea4926e68b3 --- /dev/null +++ b/packages/mdc-drawer/types.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2019 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 {MDCList} from '@material/list/index'; +import * as FocusTrapLib from 'focus-trap'; + +export type FocusTrapFactory = ( + element: HTMLElement | string, + userOptions?: FocusTrapLib.Options, +) => FocusTrapLib.FocusTrap; + +export type ListFactory = (el: Element) => MDCList; diff --git a/packages/mdc-drawer/util.js b/packages/mdc-drawer/util.ts similarity index 67% rename from packages/mdc-drawer/util.js rename to packages/mdc-drawer/util.ts index 0b364d64c94..0b66f546d07 100644 --- a/packages/mdc-drawer/util.js +++ b/packages/mdc-drawer/util.ts @@ -21,20 +21,17 @@ * THE SOFTWARE. */ -import createFocusTrap from 'focus-trap'; +import * as createFocusTrap from 'focus-trap'; +import {FocusTrapFactory} from './types'; -/** - * @param {!Element} surfaceEl - * @param {!Function} focusTrapFactory - * @return {!FocusTrapInstance} - */ -function createFocusTrapInstance(surfaceEl, focusTrapFactory = createFocusTrap) { +export function createFocusTrapInstance( + surfaceEl: HTMLElement, + focusTrapFactory: FocusTrapFactory = createFocusTrap as unknown as FocusTrapFactory, +): createFocusTrap.FocusTrap { return focusTrapFactory(surfaceEl, { - clickOutsideDeactivates: true, - initialFocus: false, // Navigation drawer handles focusing on active nav item. - escapeDeactivates: false, // Navigation drawer handles ESC. - returnFocusOnDeactivate: false, // Navigation drawer handles restore focus. + 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. }); } - -export {createFocusTrapInstance}; diff --git a/scripts/webpack/js-bundle-factory.js b/scripts/webpack/js-bundle-factory.js index 412ace43456..f5992945d81 100644 --- a/scripts/webpack/js-bundle-factory.js +++ b/scripts/webpack/js-bundle-factory.js @@ -159,7 +159,7 @@ class JsBundleFactory { chips: getAbsolutePath('/packages/mdc-chips/index.ts'), dialog: getAbsolutePath('/packages/mdc-dialog/index.ts'), dom: getAbsolutePath('/packages/mdc-dom/index.ts'), - drawer: getAbsolutePath('/packages/mdc-drawer/index.js'), + drawer: getAbsolutePath('/packages/mdc-drawer/index.ts'), floatingLabel: getAbsolutePath('/packages/mdc-floating-label/index.ts'), formField: getAbsolutePath('/packages/mdc-form-field/index.ts'), gridList: getAbsolutePath('/packages/mdc-grid-list/index.ts'), diff --git a/test/unit/mdc-dialog/util.test.js b/test/unit/mdc-dialog/util.test.js index 2d7b57c47b4..365a652bd51 100644 --- a/test/unit/mdc-dialog/util.test.js +++ b/test/unit/mdc-dialog/util.test.js @@ -24,21 +24,11 @@ import bel from 'bel'; import td from 'testdouble'; import {assert} from 'chai'; - import * as util from '../../../packages/mdc-dialog/util'; suite('MDCDialog - util'); -// Babel transpiles optional function arguments into `if` statements. Istanbul (our code coverage tool) then reports the -// transpiled `else` branch as lacking coverage, but the coverage report UI doesn't tell you where the missing branches -// are. See https://github.com/gotwarlost/istanbul/issues/582#issuecomment-334683612. -// createFocusTrapInstance() has two optional arguments, so code coverage reports two missed branches. -test('createFocusTrapInstance covers `if` branches added by Babel transpilation of optional arguments', () => { - const surface = bel`
`; - util.createFocusTrapInstance(surface); -}); - -test('createFocusTrapInstance creates a properly configured focus trap instance', () => { +test('createFocusTrapInstance creates a properly configured focus trap instance with all args specified', () => { const surface = bel`
`; const yesBtn = bel``; const focusTrapFactory = td.func('focusTrapFactory'); @@ -53,6 +43,12 @@ test('createFocusTrapInstance creates a properly configured focus trap instance' 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/dismissible.foundation.test.js b/test/unit/mdc-drawer/dismissible.foundation.test.js index aaaf71401b2..0ebde22043f 100644 --- a/test/unit/mdc-drawer/dismissible.foundation.test.js +++ b/test/unit/mdc-drawer/dismissible.foundation.test.js @@ -25,13 +25,19 @@ import {assert} from 'chai'; import bel from 'bel'; import td from 'testdouble'; -import MDCDismissibleDrawerFoundation from '../../../packages/mdc-drawer/dismissible/foundation'; +import {MDCDismissibleDrawerFoundation} from '../../../packages/mdc-drawer/dismissible/foundation'; import {strings, cssClasses} from '../../../packages/mdc-drawer/constants'; import {verifyDefaultAdapter} from '../helpers/foundation'; import {install as installClock} from '../helpers/clock'; suite('MDCDismissibleDrawerFoundation'); +/** + * @return {{ + * mockAdapter: MDCDrawerAdapter, + * foundation: MDCDismissibleDrawerFoundation, + * }} + */ const setupTest = () => { const mockAdapter = td.object(MDCDismissibleDrawerFoundation.defaultAdapter); const foundation = new MDCDismissibleDrawerFoundation(mockAdapter); diff --git a/test/unit/mdc-drawer/mdc-drawer.test.js b/test/unit/mdc-drawer/mdc-drawer.test.js index a0779cdbd52..bc42b5d8235 100644 --- a/test/unit/mdc-drawer/mdc-drawer.test.js +++ b/test/unit/mdc-drawer/mdc-drawer.test.js @@ -29,19 +29,38 @@ import td from 'testdouble'; import {MDCDrawer} from '../../../packages/mdc-drawer/index'; import {strings, cssClasses} from '../../../packages/mdc-drawer/constants'; import {MDCListFoundation} from '../../../packages/mdc-list/index'; -import MDCDismissibleDrawerFoundation from '../../../packages/mdc-drawer/dismissible/foundation'; +import {MDCDismissibleDrawerFoundation} from '../../../packages/mdc-drawer/dismissible/foundation'; +import {MDCModalDrawerFoundation} from '../../../packages/mdc-drawer/modal/foundation'; +/** + * @typedef {{ + * variantClass: (string|undefined), + * shadowRoot: (boolean|undefined), + * hasList: (boolean|undefined), + * }} + */ +let DrawerSetupOptions; // eslint-disable-line no-unused-vars + +const defaultSetupOptions = {variantClass: cssClasses.DISMISSIBLE, shadowRoot: false, hasList: true}; + +/** + * @param {DrawerSetupOptions} options + * @return {HTMLElement|DocumentFragment} + */ function getFixture(options) { + const listEl = bel` + + `; const drawerEl = bel`
- + ${options.hasList ? listEl : ''}
`; @@ -64,8 +83,10 @@ function getFixture(options) { } } -const defaultSetupOptions = {variantClass: cssClasses.DISMISSIBLE, shadowRoot: false}; - +/** + * @param {DrawerSetupOptions} options + * @return {{component: MDCDrawer, root: (HTMLElement|DocumentFragment), drawer: HTMLElement}} + */ function setupTest(options = defaultSetupOptions) { const root = getFixture(options); const drawer = root.querySelector('.mdc-drawer'); @@ -73,10 +94,23 @@ function setupTest(options = defaultSetupOptions) { return {root, drawer, component}; } +/** + * @param {DrawerSetupOptions} options + * @return {{ + * component: MDCDrawer, + * mockList: MDCList, + * mockFocusTrapInstance: {activate: function(), deactivate: function(), pause: function(), unpause: function()}, + * root: HTMLElement, + * drawer: HTMLElement, + * mockFoundation: (MDCDismissibleDrawerFoundation|MDCModalDrawerFoundation), + * }} + */ function setupTestWithMocks(options = defaultSetupOptions) { const root = getFixture(options); const drawer = root.querySelector('.mdc-drawer'); - const MockFoundationCtor = td.constructor(MDCDismissibleDrawerFoundation); + const isModal = options.variantClass === cssClasses.MODAL; + const MockFoundationClass = isModal ? MDCModalDrawerFoundation : MDCDismissibleDrawerFoundation; + const MockFoundationCtor = td.constructor(MockFoundationClass); const mockFoundation = new MockFoundationCtor(); const mockFocusTrapInstance = td.object({ activate: () => {}, @@ -94,8 +128,8 @@ function setupTestWithMocks(options = defaultSetupOptions) { suite('MDCDrawer'); test('attachTo initializes and returns a MDCDrawer instance', () => { - const drawer = getFixture({variantClass: cssClasses.DISMISSIBLE}).querySelector('.mdc-drawer'); - assert.isTrue(MDCDrawer.attachTo(drawer) instanceof MDCDrawer); + const root = getFixture({variantClass: cssClasses.DISMISSIBLE, hasList: false}).querySelector('.mdc-drawer'); + assert.instanceOf(MDCDrawer.attachTo(root), MDCDrawer); }); test('#get open calls foundation.isOpen', () => { @@ -116,6 +150,13 @@ test('#set open false calls foundation.close', () => { td.verify(mockFoundation.close(), {times: 1}); }); +test('click event calls foundation.handleScrimClick method', () => { + const {root, mockFoundation} = setupTestWithMocks({variantClass: cssClasses.MODAL}); + const scrimEl = root.querySelector('.mdc-drawer-scrim'); + domEvents.emit(scrimEl, 'click'); + td.verify(mockFoundation.handleScrimClick(), {times: 1}); +}); + test('keydown event calls foundation.handleKeydown method', () => { const {drawer, mockFoundation} = setupTestWithMocks(); drawer.querySelector('.mdc-list-item').focus(); @@ -157,6 +198,14 @@ test('#destroy calls destroy on list', () => { td.verify(mockList.destroy(), {times: 1}); }); +test('#destroy does not throw an error when list is not present', () => { + const {component, mockList} = + setupTestWithMocks(Object.assign({}, defaultSetupOptions, {variantClass: cssClasses.MODAL, hasList: false})); + component.destroy(); + + td.verify(mockList.destroy(), {times: 0}); +}); + test('adapter#addClass adds class to drawer', () => { const {component, drawer} = setupTest(); component.getDefaultFoundation().adapter_.addClass('test-class'); @@ -283,8 +332,20 @@ test('adapter#focusActiveNavigationItem focuses on active navigation item', () = document.body.appendChild(root); component.getDefaultFoundation().adapter_.focusActiveNavigationItem(); - const activedNavigationItemEl = root.querySelector(`.${MDCListFoundation.cssClasses.LIST_ITEM_ACTIVATED_CLASS}`); - assert.equal(activedNavigationItemEl, document.activeElement); + const activatedNavigationItemEl = root.querySelector(`.${MDCListFoundation.cssClasses.LIST_ITEM_ACTIVATED_CLASS}`); + assert.equal(document.activeElement, activatedNavigationItemEl); + document.body.removeChild(root); +}); + +test('adapter#focusActiveNavigationItem does nothing if no active navigation item exists', () => { + const {component, root} = setupTest(); + const prevActiveElement = document.activeElement; + document.body.appendChild(root); + const activatedNavigationItemEl = root.querySelector(`.${MDCListFoundation.cssClasses.LIST_ITEM_ACTIVATED_CLASS}`); + activatedNavigationItemEl.classList.remove(MDCListFoundation.cssClasses.LIST_ITEM_ACTIVATED_CLASS); + component.getDefaultFoundation().adapter_.focusActiveNavigationItem(); + + assert.equal(document.activeElement, prevActiveElement); document.body.removeChild(root); }); diff --git a/test/unit/mdc-drawer/modal.foundation.test.js b/test/unit/mdc-drawer/modal.foundation.test.js index f8ac43a2fea..4d2d5fbace7 100644 --- a/test/unit/mdc-drawer/modal.foundation.test.js +++ b/test/unit/mdc-drawer/modal.foundation.test.js @@ -48,7 +48,8 @@ test('#closed untraps the focus when drawer finishes animating close', () => { }); test('#handleScrimClick closes the drawer', () => { - const {foundation} = setupTest(); + const foundation = new MDCModalDrawerFoundation(); + foundation.close = td.func('close'); foundation.handleScrimClick(); td.verify(foundation.close(), {times: 1}); }); diff --git a/test/unit/mdc-drawer/util.test.js b/test/unit/mdc-drawer/util.test.js index e18b3a4f1f8..6f8d3dc90ae 100644 --- a/test/unit/mdc-drawer/util.test.js +++ b/test/unit/mdc-drawer/util.test.js @@ -24,22 +24,27 @@ import {assert} from 'chai'; import bel from 'bel'; import td from 'testdouble'; - import * as util from '../../../packages/mdc-drawer/util'; suite('MDCDrawer - util'); -test('#createFocusTrapInstance creates a properly configured focus trap instance', () => { +test('createFocusTrapInstance creates a properly configured focus trap instance with all args specified', () => { const rootEl = bel`
`; const focusTrapFactory = td.func('focusTrapFactory'); const properlyConfiguredFocusTrapInstance = {}; td.when(focusTrapFactory(rootEl, { clickOutsideDeactivates: true, - initialFocus: false, escapeDeactivates: false, + initialFocus: undefined, returnFocusOnDeactivate: false, })).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']); +});