From 0d2e4890148dae6bb4a7bf5a49a93c72dad85343 Mon Sep 17 00:00:00 2001 From: Derek Louie Date: Wed, 13 Apr 2016 11:03:48 -0700 Subject: [PATCH] feat(panel): animation hook and origin focus config - Add onDomAdded, onOpenComplete, and onRemoving animation hooks. - Updates the detach / close code to focus on close. --- src/components/panel/panel.js | 106 +++++--- src/components/panel/panel.spec.js | 391 +++++++++++++++++++++++++---- test/angular-material-spec.js | 22 +- 3 files changed, 444 insertions(+), 75 deletions(-) diff --git a/src/components/panel/panel.js b/src/components/panel/panel.js index e42edcbe3d7..2ee6673f8d1 100644 --- a/src/components/panel/panel.js +++ b/src/components/panel/panel.js @@ -131,6 +131,17 @@ angular * behind the panel. Defaults to false. * - `disableParentScroll` - `{boolean=}`: Whether the user can scroll the * page behind the panel. Defaults to false. + * - `onDomAdded` - `{function=}`: Callback function used to announce when + * the panel is added to the DOM. + * - `onOpenComplete` - `{function=}`: Callback function used to announce + * when the open() action is finished. + * - `onRemoving` - `{function=}`: Callback function used to announce the + * close/hide() action is starting. + * - `onDomRemoved` - `{function=}`: Callback function used to announce when the + * panel is removed from the DOM. + * - `origin` - `{(string|!angular.JQLite|!Element)=}`: The element to + * focus on when the panel closes. This is commonly the element which triggered + * the opening of the panel. * * TODO(ErinCoughlan): Add the following config options. * - `groupName` - `{string=}`: Name of panel groups. This group name is @@ -212,15 +223,6 @@ angular * create. * - `isAttached` - `{boolean}`: Whether the panel is attached to the DOM. * Visibility to the user does not factor into isAttached. - * - * TODO(ErinCoughlan): Add the following properties. - * - `onDomAdded` - `{function=}`: Callback function used to announce when - * the panel is added to the DOM. - * - `onOpenComplete` - `{function=}`: Callback function used to announce - * when the open() action is finished. - * - `onRemoving` - `{function=}`: Callback function used to announce the - * close/hide() action is starting. This allows developers to run custom - * animations in parallel the close animations. */ /** @@ -237,8 +239,7 @@ angular * @ngdoc method * @name MdPanelRef#close * @description - * Hides and detaches the panel. This method destroys the reference to the panel. - * In order to open the panel again, a new one must be created. + * Hides and detaches the panel. * * @returns {!angular.$q.Promise} A promise that is resolved when the panel is * closed. @@ -725,6 +726,7 @@ MdPanelService.prototype._wrapTemplate = function(origTemplate) { ''; }; + /***************************************************************************** * MdPanelRef * *****************************************************************************/ @@ -779,7 +781,6 @@ function MdPanelRef(config, $injector) { */ this.isAttached = false; - // Private variables. /** @private {!Object} */ this._config = config; @@ -821,8 +822,9 @@ MdPanelRef.prototype.open = function() { var show = self._simpleBind(self.show, self); self.attach() - .then(show, reject) - .then(done, reject); + .then(show) + .then(done) + .catch(reject); }); }; @@ -835,13 +837,15 @@ MdPanelRef.prototype.open = function() { */ MdPanelRef.prototype.close = function() { var self = this; + return this._$q(function(resolve, reject) { var done = self._done(resolve, self); var detach = self._simpleBind(self.detach, self); self.hide() - .then(detach, reject) - .then(done, reject); + .then(detach) + .then(done) + .catch(reject); }); }; @@ -860,14 +864,21 @@ MdPanelRef.prototype.attach = function() { var self = this; return this._$q(function(resolve, reject) { var done = self._done(resolve, self); - - self._$q.all([ - self._createBackdrop(), - self._createPanel().then(function() { + var onDomAdded = self._config['onDomAdded'] || angular.noop; + var addListeners = function(response) { self.isAttached = true; self._addEventListeners(); - }, reject) - ]).then(done, reject); + return response; + }; + + self._$q.all([ + self._createBackdrop(), + self._createPanel() + .then(addListeners) + .catch(reject) + ]).then(onDomAdded) + .then(done) + .catch(reject); }); }; @@ -884,8 +895,10 @@ MdPanelRef.prototype.detach = function() { } var self = this; + var onDomRemoved = self._config['onDomRemoved'] || angular.noop; + var detachFn = function() { - self._removeEventListener(); + self._removeEventListeners(); // Remove the focus traps that we added earlier for keeping focus within // the panel. @@ -913,11 +926,21 @@ MdPanelRef.prototype.detach = function() { self._$q.all([ detachFn(), self._backdropRef ? self._backdropRef.detach() : true - ]).then(done, reject); + ]).then(onDomRemoved) + .then(done) + .catch(reject); }); }; +/** + * Destroys the panel. The Panel cannot be opened again after this. + */ +MdPanelRef.prototype.destroy = function() { + this._config.locals = null; +}; + + /** * Shows the panel. * @@ -943,11 +966,14 @@ MdPanelRef.prototype.show = function() { return this._$q(function(resolve, reject) { var done = self._done(resolve, self); + var onOpenComplete = self._config['onOpenComplete'] || angular.noop; self._$q.all([ self._backdropRef ? self._backdropRef.show() : self, animatePromise().then(function() { self._focusOnOpen(); }, reject) - ]).then(done, reject); + ]).then(onOpenComplete) + .then(done) + .catch(reject); }); }; @@ -970,13 +996,29 @@ MdPanelRef.prototype.hide = function() { } var self = this; + return this._$q(function(resolve, reject) { var done = self._done(resolve, self); + var onRemoving = self._config['onRemoving'] || angular.noop; + + var focusOnOrigin = function() { + var origin = self._config['origin']; + if (origin) { + getElement(origin).focus(); + } + }; + + var hidePanel = function() { + self.addClass(MD_PANEL_HIDDEN); + }; self._$q.all([ self._backdropRef ? self._backdropRef.hide() : self, - self._animateClose().then(function() { self.addClass(MD_PANEL_HIDDEN); }, - reject) + self._animateClose() + .then(onRemoving) + .then(hidePanel) + .then(focusOnOrigin) + .catch(reject) ]).then(done, reject); }); }; @@ -1037,10 +1079,12 @@ MdPanelRef.prototype.toggleClass = function(toggleClass) { */ MdPanelRef.prototype._createPanel = function() { var self = this; + return this._$q(function(resolve, reject) { if (!self._config.locals) { self._config.locals = {}; } + self._config.locals.mdPanelRef = self; self._$mdCompiler.compile(self._config) .then(function(compileData) { @@ -1184,7 +1228,7 @@ MdPanelRef.prototype._addEventListeners = function() { * Remove event listeners added in _addEventListeners. * @private */ -MdPanelRef.prototype._removeEventListener = function() { +MdPanelRef.prototype._removeEventListeners = function() { this._removeListeners && this._removeListeners.forEach(function(removeFn) { removeFn(); }); @@ -1289,6 +1333,12 @@ MdPanelRef.prototype._configureTrapFocus = function() { this._topFocusTrap.addEventListener('focus', focusHandler); this._bottomFocusTrap.addEventListener('focus', focusHandler); + // Queue remove listeners function + this._removeListeners.push(this._simpleBind(function() { + this._topFocusTrap.removeEventListener('focus', focusHandler); + this._bottomFocusTrap.removeEventListener('focus', focusHandler); + }, this)); + // The top focus trap inserted immediately before the md-panel element (as // a sibling). The bottom focus trap inserted immediately after the // md-panel element (as a sibling). diff --git a/src/components/panel/panel.spec.js b/src/components/panel/panel.spec.js index 1e3f2ff6272..91557d56cb5 100644 --- a/src/components/panel/panel.spec.js +++ b/src/components/panel/panel.spec.js @@ -66,9 +66,9 @@ describe('$mdPanel', function() { pass: pass, message: 'Expected ' + expected + not + ' to be within ' + epsilon + ' of ' + actual - } + }; } - } + }; } }); }); @@ -129,6 +129,16 @@ describe('$mdPanel', function() { expect(PANEL_WRAPPER_CLASS).not.toHaveClass(HIDDEN_CLASS); }); + it('destroy should clear the config locals on the panelRef', function () { + openPanel(DEFAULT_CONFIG); + + expect(panelRef._config.locals).not.toEqual(null); + + panelRef.destroy(); + + expect(panelRef._config.locals).toEqual(null); + }); + describe('promises logic:', function() { var config; @@ -432,16 +442,7 @@ describe('$mdPanel', function() { it('should not close when clickOutsideToClose set to false', function() { openPanel(); - var container = panelRef._panelContainer; - container.triggerHandler({ - type: 'mousedown', - target: container[0] - }); - container.triggerHandler({ - type: 'mouseup', - target: container[0] - }); - flushPanel(); + clickPanelContainer(); expect(PANEL_EL).toExist(); }); @@ -453,16 +454,7 @@ describe('$mdPanel', function() { openPanel(config); - var container = panelRef._panelContainer; - container.triggerHandler({ - type: 'mousedown', - target: container[0] - }); - container.triggerHandler({ - type: 'mouseup', - target: container[0] - }); - flushPanel(); + clickPanelContainer(); // TODO(ErinCoughlan) - Add this when destroy is added. // expect(panelRef).toBeUndefined(); @@ -472,12 +464,7 @@ describe('$mdPanel', function() { it('should not close when escapeToClose set to false', function() { openPanel(); - var container = panelRef._panelContainer; - container.triggerHandler({ - type: 'keydown', - keyCode: $mdConstant.KEY_CODE.ESCAPE - }); - flushPanel(); + pressEscape(); expect(PANEL_EL).toExist(); }); @@ -489,12 +476,7 @@ describe('$mdPanel', function() { openPanel(config); - var container = panelRef._panelContainer; - container.triggerHandler({ - type: 'keydown', - keyCode: $mdConstant.KEY_CODE.ESCAPE - }); - flushPanel(); + pressEscape(); // TODO(ErinCoughlan) - Add this when destroy is added. // expect(panelRef).toBeUndefined(); @@ -611,16 +593,7 @@ describe('$mdPanel', function() { return panelRef._$q.resolve(self); }; - var container = panelRef._panelContainer; - container.triggerHandler({ - type: 'mousedown', - target: container[0] - }); - container.triggerHandler({ - type: 'mouseup', - target: container[0] - }); - flushPanel(); + clickPanelContainer(); expect(closeCalled).toBe(true); }); @@ -643,6 +616,297 @@ describe('$mdPanel', function() { expect(scrollMaskEl).not.toExist(); expect($mdUtil.disableScrollAround).toHaveBeenCalled(); }); + + describe('animation hooks: ', function() { + it('should call onDomAdded if provided when adding the panel to the DOM', + function() { + var onDomAddedCalled = false; + var onDomAdded = function() { + onDomAddedCalled = true; + return $q.when(this); + }; + var config = angular.extend( + {'onDomAdded': onDomAdded}, DEFAULT_CONFIG); + + panelRef = $mdPanel.create(config); + panelRef.attach(); + flushPanel(); + + expect(onDomAddedCalled).toBe(true); + expect(PANEL_EL).toExist(); + expect(PANEL_WRAPPER_CLASS).toHaveClass(HIDDEN_CLASS); + }); + + it('should continue resolving when onDomAdded resolves', function() { + var attachResolved = false; + var onDomAddedCalled = false; + var onDomAdded = function() { + onDomAddedCalled = true; + return $q.when(this); + }; + var config = angular.extend( + {'onDomAdded': onDomAdded}, DEFAULT_CONFIG); + + expect(PANEL_EL).not.toExist(); + + panelRef = $mdPanel.create(config); + panelRef.open().then(function() { + attachResolved = true; + }); + flushPanel(); + + expect(onDomAddedCalled).toBe(true); + expect(PANEL_EL).toExist(); + expect(attachResolved).toBe(true); + expect(panelRef.isAttached).toEqual(true); + expect(panelRef._panelContainer).not.toHaveClass(HIDDEN_CLASS); + }); + + it('should reject open when onDomAdded rejects', function() { + var openRejected = false; + var onDomAddedCalled = false; + var onDomAdded = function() { + onDomAddedCalled = true; + return $q.reject(); + }; + var config = angular.extend( + {'onDomAdded': onDomAdded}, DEFAULT_CONFIG); + + panelRef = $mdPanel.create(config); + panelRef.open().catch(function() { + openRejected = true; + }); + flushPanel(); + + expect(onDomAddedCalled).toBe(true); + expect(openRejected).toBe(true); + expect(panelRef.isAttached).toEqual(true); + expect(panelRef._panelContainer).toHaveClass(HIDDEN_CLASS); + }); + + it('should call onOpenComplete if provided after adding the panel to the ' + + 'DOM and animating', function() { + var onOpenCompleteCalled = false; + var onOpenComplete = function() { + onOpenCompleteCalled = true; + return $q.when(this); + }; + var config = angular.extend( + {'onOpenComplete': onOpenComplete}, DEFAULT_CONFIG); + + openPanel(config); + + expect(onOpenCompleteCalled).toBe(true); + expect(PANEL_EL).toExist(); + expect(PANEL_WRAPPER_CLASS).not.toHaveClass(HIDDEN_CLASS); + }); + + it('should call onRemoving if provided after hiding the panel but before ' + + 'the panel is removed', function() { + var onRemovingCalled = false; + var onDomRemovedCalled = false; + var onRemoving = function() { + onRemovingCalled = true; + return $q.when(this); + }; + var onDomRemoved = function() { + onDomRemovedCalled = true; + return $q.when(this); + }; + var config = angular.extend({'onRemoving': onRemoving, + 'onDomRemoved': onDomRemoved}, DEFAULT_CONFIG); + + openPanel(config); + hidePanel(); + + expect(onRemovingCalled).toBe(true); + expect(onDomRemovedCalled).toBe(false); + expect(PANEL_EL).toExist(); + }); + + it('should continue resolving when onRemoving resolves', function() { + var hideResolved = false; + var onRemovingCalled = false; + var onRemoving = function() { + onRemovingCalled = true; + return $q.when(this); + }; + var config = angular.extend({'onRemoving': onRemoving}, + DEFAULT_CONFIG); + + openPanel(config); + panelRef.hide().then(function() { + hideResolved = true; + }); + flushPanel(); + + expect(onRemovingCalled).toBe(true); + expect(PANEL_EL).toExist(); + expect(hideResolved).toBe(true); + expect(PANEL_WRAPPER_CLASS).toHaveClass(HIDDEN_CLASS); + }); + + it('should reject hide when onRemoving rejects', function() { + var hideRejected = false; + var onRemoving = function() { + return $q.reject(); + }; + var config = angular.extend( + {'onRemoving': onRemoving}, DEFAULT_CONFIG); + + openPanel(config); + panelRef.hide().catch(function() { + hideRejected = true; + }); + flushPanel(); + + expect(hideRejected).toBe(true); + expect(PANEL_EL).toExist(); + expect(PANEL_WRAPPER_CLASS).not.toHaveClass(HIDDEN_CLASS); + }); + + it('should call onRemoving on escapeToClose', function() { + var onRemovingCalled = false; + var onRemoving = function() { + onRemovingCalled = true; + return $q.when(this); + }; + var config = angular.extend({ + 'onRemoving': onRemoving, escapeToClose: true}, + DEFAULT_CONFIG); + + openPanel(config); + pressEscape(); + + expect(PANEL_EL).not.toExist(); + expect(onRemovingCalled).toBe(true); + }); + + it('should call onRemoving on clickOutsideToClose', function() { + var onRemovingCalled = false; + var onRemoving = function() { + onRemovingCalled = true; + return $q.when(this); + }; + var config = angular.extend({ + 'onRemoving': onRemoving, clickOutsideToClose: true}, + DEFAULT_CONFIG); + + openPanel(config); + clickPanelContainer(); + + expect(PANEL_EL).not.toExist(); + expect(onRemovingCalled).toBe(true); + }); + + + it('should call onDomRemoved if provided when removing the panel from ' + + 'the DOM', function() { + var onDomRemovedCalled = false; + var onDomRemoved = function() { + onDomRemovedCalled = true; + return $q.when(this); + }; + var config = angular.extend( + {'onDomRemoved': onDomRemoved}, DEFAULT_CONFIG); + + openPanel(config); + closePanel(); + + expect(onDomRemovedCalled).toBe(true); + expect(PANEL_EL).not.toExist(); + }); + + it('should call onDomRemoved on escapeToClose', function() { + var onDomRemovedCalled = false; + var onDomRemoved = function() { + onDomRemovedCalled = true; + return $q.when(this); + }; + var config = angular.extend({ + 'onDomRemoved': onDomRemoved, escapeToClose: true}, + DEFAULT_CONFIG); + + openPanel(config); + pressEscape(); + + expect(PANEL_EL).not.toExist(); + expect(onDomRemovedCalled).toBe(true); + }); + + it('should call onDomRemoved on clickOutsideToClose', function() { + var onDomRemovedCalled = false; + var onDomRemoved = function() { + onDomRemovedCalled = true; + return $q.when(this); + }; + var config = angular.extend({ + 'onDomRemoved': onDomRemoved, clickOutsideToClose: true}, + DEFAULT_CONFIG); + + openPanel(config); + clickPanelContainer(); + + expect(PANEL_EL).not.toExist(); + expect(onDomRemovedCalled).toBe(true); + }); + }); + + describe('should focus on the origin element on', function() { + var myButton; + var detachFocusConfig; + beforeEach(function() { + attachToBody(''); + + myButton = angular.element(document.querySelector('#donuts')); + + detachFocusConfig = angular.extend({ origin: '#donuts' }, DEFAULT_CONFIG); + }); + + it('hide when provided', function () { + openPanel(detachFocusConfig); + + expect(myButton).not.toBeFocused(); + + hidePanel(); + + expect(myButton).toBeFocused(); + }); + + it('close when provided', function () { + openPanel(detachFocusConfig); + + expect(myButton).not.toBeFocused(); + + closePanel(); + + expect(myButton).toBeFocused(); + }); + + it('clickOutsideToClose', function() { + detachFocusConfig.clickOutsideToClose = true; + + openPanel(detachFocusConfig); + + expect(myButton).not.toBeFocused(); + + clickPanelContainer(); + + expect(myButton).toBeFocused(); + }); + + it('escapeToClose', function() { + detachFocusConfig.escapeToClose = true; + + openPanel(detachFocusConfig); + + expect(myButton).not.toBeFocused(); + + pressEscape(); + + expect(myButton).toBeFocused(); + }); + }); }); describe('component logic: ', function() { @@ -1467,6 +1731,41 @@ describe('$mdPanel', function() { attachedElements.push(element); } + function clickPanelContainer() { + if (!panelRef) { + return; + } + + var container = panelRef._panelContainer; + + container.triggerHandler({ + type: 'mousedown', + target: container[0] + }); + + container.triggerHandler({ + type: 'mouseup', + target: container[0] + }); + + flushPanel(); + } + + function pressEscape() { + if (!panelRef) { + return; + } + + var container = panelRef._panelContainer; + + container.triggerHandler({ + type: 'keydown', + keyCode: $mdConstant.KEY_CODE.ESCAPE + }); + + flushPanel(); + } + /** * Opens the panel. If a config value is passed, creates a new panelRef * using $mdPanel.open(config); Otherwise, called open on the panelRef, @@ -1500,12 +1799,12 @@ describe('$mdPanel', function() { } function showPanel() { - panelRef && panelRef.show(HIDDEN_CLASS); + panelRef && panelRef.show(); flushPanel(); } function hidePanel() { - panelRef && panelRef.hide(HIDDEN_CLASS); + panelRef && panelRef.hide(); flushPanel(); } diff --git a/test/angular-material-spec.js b/test/angular-material-spec.js index 95e89618fdc..6dfb3aaafe7 100644 --- a/test/angular-material-spec.js +++ b/test/angular-material-spec.js @@ -203,7 +203,27 @@ }, /** - * Asserts that a given selector matches#006b75 one or more items. + * Asserts that an element has keyboard focus in the DOM. + * Accepts any of: + * {string} - A CSS selector. + * {angular.JQLite} - The result of a jQuery query. + * {Element} - A DOM element. + */ + toBeFocused: function() { + return { + 'compare': function(actual) { + var pass = getElement(actual)[0] === document.activeElement; + var not = pass ? 'not ' : ''; + return { + 'pass': pass, + 'message': 'Expected element ' + not + 'to have focus.' + }; + } + }; + }, + + /** + * Asserts that a given selector matches one or more items. * Accepts any of: * {string} - A CSS selector. * {angular.JQLite} - The result of a jQuery query.