From 9434120930a4d600684b3563c065ccf6838b0954 Mon Sep 17 00:00:00 2001 From: Brian Nenninger Date: Thu, 2 Jul 2015 11:27:10 -0400 Subject: [PATCH] fix(dialog): autoclose fixed and close animation shrinks to originating element poisition. capture originating element and position, If targetEvent element is hidden when dialog closes, shrink to the element's original position. add tests to verify that the correct CSS transforms are set when expanding and shrinking. code refactor to fix listeners fix clickOutsideToClose escape keyup listeners to auto-close. Closes #3555. Fixes #3541. Fixes #2479. --- .../dialog/demoBasicUsage/script.js | 15 +- src/components/dialog/dialog.js | 374 ++++++++++-------- src/components/dialog/dialog.spec.js | 159 +++++++- 3 files changed, 383 insertions(+), 165 deletions(-) diff --git a/src/components/dialog/demoBasicUsage/script.js b/src/components/dialog/demoBasicUsage/script.js index 0e4acb523af..44cf2818bff 100644 --- a/src/components/dialog/demoBasicUsage/script.js +++ b/src/components/dialog/demoBasicUsage/script.js @@ -10,6 +10,7 @@ angular.module('dialogDemo1', ['ngMaterial']) $mdDialog.show( $mdDialog.alert() .parent(angular.element(document.body)) + .clickOutsideToClose(true) .title('This is an alert title') .content('You can specify some description text in here.') .ariaLabel('Alert Dialog Demo') @@ -21,13 +22,13 @@ angular.module('dialogDemo1', ['ngMaterial']) $scope.showConfirm = function(ev) { // Appending dialog to document.body to cover sidenav in docs app var confirm = $mdDialog.confirm() - .parent(angular.element(document.body)) - .title('Would you like to delete your debt?') - .content('All of the banks have agreed to forgive you your debts.') - .ariaLabel('Lucky day') - .ok('Please do it!') - .cancel('Sounds like a scam') - .targetEvent(ev); + .parent(angular.element(document.body)) + .title('Would you like to delete your debt?') + .content('All of the banks have agreed to forgive you your debts.') + .ariaLabel('Lucky day') + .ok('Please do it!') + .cancel('Sounds like a scam') + .targetEvent(ev); $mdDialog.show(confirm).then(function() { $scope.alert = 'You decided to get rid of your debt.'; diff --git a/src/components/dialog/dialog.js b/src/components/dialog/dialog.js index 10bac0de38f..5f92d70d03d 100644 --- a/src/components/dialog/dialog.js +++ b/src/components/dialog/dialog.js @@ -12,9 +12,9 @@ angular.module('material.components.dialog', [ function MdDialogDirective($$rAF, $mdTheming) { return { restrict: 'E', - link: function(scope, element, attr) { + link: function (scope, element, attr) { $mdTheming(element); - $$rAF(function() { + $$rAF(function () { var content = element[0].querySelector('md-dialog-content'); if (content && content.scrollHeight > content.clientHeight) { element.addClass('md-content-overflow'); @@ -263,7 +263,7 @@ function MdDialogDirective($$rAF, $mdTheming) { * */ - /** +/** * @ngdoc method * @name $mdDialog#alert * @@ -279,7 +279,7 @@ function MdDialogDirective($$rAF, $mdTheming) { * */ - /** +/** * @ngdoc method * @name $mdDialog#confirm * @@ -375,8 +375,6 @@ function MdDialogDirective($$rAF, $mdTheming) { function MdDialogProvider($$interimElementProvider) { - var alertDialogMethods = ['title', 'content', 'ariaLabel', 'ok']; - return $$interimElementProvider('$mdDialog') .setDefaults({ methods: ['disableParentScroll', 'hasBackdrop', 'clickOutsideToClose', 'escapeToClose', 'targetEvent', 'parent'], @@ -396,28 +394,24 @@ function MdDialogProvider($$interimElementProvider) { return { template: [ '', - '', - '

{{ dialog.title }}

', - '

{{ dialog.content }}

', - '
', - '
', - '', - '{{ dialog.cancel }}', - '', - '', - '{{ dialog.ok }}', - '', - '
', + ' ', + '

{{ dialog.title }}

', + '

{{ dialog.content }}

', + '
', + '
', + ' ', + ' {{ dialog.cancel }}', + ' ', + ' ', + ' {{ dialog.ok }}', + ' ', + '
', '
' - ].join(''), + ].join('').replace(/\s\s+/g,''), controller: function mdDialogCtrl() { - this.hide = function() { - $mdDialog.hide(true); - }; - this.abort = function() { - $mdDialog.cancel(); - }; + this.hide = function () { $mdDialog.hide(true); }; + this.abort = function (){ $mdDialog.cancel(); }; }, controllerAs: 'dialog', bindToController: true, @@ -426,7 +420,7 @@ function MdDialogProvider($$interimElementProvider) { } /* @ngInject */ - function dialogDefaultOptions($mdAria, $document, $mdUtil, $mdConstant, $mdTheming, $mdDialog, $timeout, $rootElement, $animate, $$rAF, $q) { + function dialogDefaultOptions($mdAria, $document, $mdUtil, $mdConstant, $mdTheming, $mdDialog, $timeout, $rootElement, $animate, $$rAF) { return { hasBackdrop: true, isolateScope: true, @@ -437,144 +431,203 @@ function MdDialogProvider($$interimElementProvider) { targetEvent: null, focusOnOpen: true, disableParentScroll: true, - transformTemplate: function(template) { + transformTemplate: function (template) { return '
' + template + '
'; } }; - - // On show method for dialogs + /** + * Show method for dialogs + */ function onShow(scope, element, options) { - angular.element($document[0].body).addClass('md-dialog-is-showing'); element = $mdUtil.extractElementByName(element, 'md-dialog'); + angular.element($document[0].body).addClass('md-dialog-is-showing'); - // Incase the user provides a raw dom element, always wrap it in jqLite - options.parent = angular.element(options.parent); + captureSourceAndParent(element, options); + configureAria(element.find('md-dialog'), options); + showBackdrop(element, options); - options.popInTarget = angular.element((options.targetEvent || {}).target); - var closeButton = findCloseButton(); + return dialogPopIn(element, options) + .then(function () { + applyAriaToSiblings(element, true); + activateListeners(element, options); + focusOnOpen(); + }); - if (options.hasBackdrop) { - // Fix for IE 10 - var computeFrom = (options.parent[0] == $document[0].body && $document[0].documentElement - && $document[0].documentElement.scrollTop) ? angular.element($document[0].documentElement) : options.parent; - var parentOffset = computeFrom.prop('scrollTop'); - options.backdrop = angular.element(''); - options.backdrop.css('top', parentOffset +'px'); - $mdTheming.inherit(options.backdrop, options.parent); - $animate.enter(options.backdrop, options.parent); - element.css('top', parentOffset +'px'); + function focusOnOpen() { + if (options.focusOnOpen) { + var target = (options.$type === 'alert') ? element.find('md-dialog-content') : findCloseButton(); + target.focus(); + } + + function findCloseButton() { + //If no element with class dialog-close, try to find the last + //button child in md-actions and assume it is a close button + var closeButton = element[0].querySelector('.dialog-close'); + if (!closeButton) { + var actionButtons = element[0].querySelectorAll('.md-actions button'); + closeButton = actionButtons[actionButtons.length - 1]; + } + return angular.element(closeButton); + } } - var role = 'dialog', - elementToFocus = closeButton; + } + + /** + * Remove function for all dialogs + */ + function onRemove(scope, element, options) { + angular.element($document[0].body).removeClass('md-dialog-is-showing'); - if (options.$type === 'alert') { - role = 'alertdialog'; - elementToFocus = element.find('md-dialog-content'); - } + options.deactivateListeners(); + applyAriaToSiblings(element, false); + hideBackdrop(element, options); - configureAria(element.find('md-dialog'), role, options); + return dialogPopOut(element, options) + .then(function () { + element.remove(); + options.origin.focus(); + }); + } + function captureSourceAndParent(element, options) { + options.origin = { + element: null, + bounds: null, + focus: angular.noop + }; + + var source = angular.element((options.targetEvent || {}).target); + if (source && source.length) { + // Compute and save the target element's bounding rect, so that if the + // element is hidden when the dialog closes, we can shrink the dialog + // back to the same position it expanded from. + options.origin.element = source; + options.origin.bounds = source[0].getBoundingClientRect(); + options.origin.focus = function () { + source.focus(); + } + } + + // Incase the user provides a raw dom element, always wrap it in jqLite + options.parent = angular.element(options.parent); + + if (options.disableParentScroll) { + options.restoreScroll = $mdUtil.disableScrollAround(element); + } + } - if (options.disableParentScroll) { - options.restoreScroll = $mdUtil.disableScrollAround(element); + /** + * Listen for escape keys and outside clicks to auto close + */ + function activateListeners(element, options) { + var removeListeners = [ ]; + + if (options.escapeToClose) { + var target = options.parent; + var keyHandlerFn = function (ev) { + if (ev.keyCode === $mdConstant.KEY_CODE.ESCAPE) { + ev.stopPropagation(); + ev.preventDefault(); + + $timeout($mdDialog.cancel); + } + }; + + // Add keyup listeners + element.on('keyup', keyHandlerFn); + target.on('keyup', keyHandlerFn); + + // Queue remove listeners function + removeListeners.push(function() { + element.off('keyup', keyHandlerFn); + target.off('keyup', keyHandlerFn); + }); } - return dialogPopIn( - element, - options.parent, - options.popInTarget && options.popInTarget.length && options.popInTarget - ) - .then(function() { + if (options.clickOutsideToClose) { + var target = element; + var clickHandler = function (ev) { + // Only close if we click the flex container outside the backdrop + if (ev.target === target[0]) { + ev.stopPropagation(); + ev.preventDefault(); + + $timeout($mdDialog.cancel); + } + }; + + // Add click listeners + target.on('click', clickHandler); + + // Queue remove listeners function + removeListeners.push(function(){ + target.off('click',clickHandler); + }); + } - applyAriaToSiblings(element, true); + // Attach specific `remove` listener handler + options.deactivateListeners = function() { + removeListeners.forEach(function(removeFn){ + removeFn(); + }) + }; + } - if (options.escapeToClose) { - options.rootElementKeyupCallback = function(e) { - if (e.keyCode === $mdConstant.KEY_CODE.ESCAPE) { - $timeout($mdDialog.cancel); - } - }; - $rootElement.on('keyup', options.rootElementKeyupCallback); - } - if (options.clickOutsideToClose) { - options.dialogClickOutsideCallback = function(ev) { - // Only close if we click the flex container outside the backdrop - if (ev.target === element[0]) { - $timeout($mdDialog.cancel); - } - }; - element.on('click', options.dialogClickOutsideCallback); - } + /** + * Show modal backdrop element... + */ + function showBackdrop(element, options) { - if (options.focusOnOpen) { - elementToFocus.focus(); - } - }); + if (options.hasBackdrop) { + // Fix for IE 10 + var docElement = $document[0].documentElement; + var hasScrollTop = (options.parent[0] == $document[0].body) && (docElement && docElement.scrollTop); + var computeFrom = hasScrollTop ? angular.element(docElement) : options.parent; + var parentOffset = computeFrom.prop('scrollTop'); + options.backdrop = angular.element(''); + options.backdrop.css('top', parentOffset + 'px'); + $mdTheming.inherit(options.backdrop, options.parent); - function findCloseButton() { - //If no element with class dialog-close, try to find the last - //button child in md-actions and assume it is a close button - var closeButton = element[0].querySelector('.dialog-close'); - if (!closeButton) { - var actionButtons = element[0].querySelectorAll('.md-actions button'); - closeButton = actionButtons[ actionButtons.length - 1 ]; - } - return angular.element(closeButton); + $animate.enter(options.backdrop, options.parent); + element.css('top', parentOffset + 'px'); } - } - // On remove function for all dialogs - function onRemove(scope, element, options) { - angular.element($document[0].body).removeClass('md-dialog-is-showing'); - + /** + * Hide modal backdrop element... + */ + function hideBackdrop(element, options) { if (options.backdrop) { $animate.leave(options.backdrop); } if (options.disableParentScroll) { options.restoreScroll(); } - if (options.escapeToClose) { - $rootElement.off('keyup', options.rootElementKeyupCallback); - } - if (options.clickOutsideToClose) { - element.off('click', options.dialogClickOutsideCallback); - } - - applyAriaToSiblings(element, false); - - - return dialogPopOut( - element, - options.parent, - options.popInTarget && options.popInTarget.length && options.popInTarget - ).then(function() { - element.remove(); - options.popInTarget && options.popInTarget.focus(); - }); - } + /** * Inject ARIA-specific attributes appropriate for Dialogs */ - function configureAria(element, role, options) { + function configureAria(element, options) { + + var role = (options.$type === 'alert') ? 'alertdialog' : 'dialog'; + var dialogContent = element.find('md-dialog-content'); + var dialogId = element.attr('id') || ('dialog_' + $mdUtil.nextUid()); element.attr({ 'role': role, 'tabIndex': '-1' }); - var dialogContent = element.find('md-dialog-content'); - if (dialogContent.length === 0){ + if (dialogContent.length === 0) { dialogContent = element; } - var dialogId = element.attr('id') || ('dialog_' + $mdUtil.nextUid()); dialogContent.attr('id', dialogId); element.attr('aria-describedby', dialogId); @@ -582,21 +635,14 @@ function MdDialogProvider($$interimElementProvider) { $mdAria.expect(element, 'aria-label', options.ariaLabel); } else { - $mdAria.expectAsync(element, 'aria-label', function() { + $mdAria.expectAsync(element, 'aria-label', function () { var words = dialogContent.text().split(/\s+/); - if (words.length > 3) words = words.slice(0,3).concat('...'); + if (words.length > 3) words = words.slice(0, 3).concat('...'); return words.join(' '); }); } } - /** - * Utility function to filter out raw DOM nodes - */ - function isNodeOneOf(elem, nodeTypeArray) { - if (nodeTypeArray.indexOf(elem.nodeName) !== -1) { - return true; - } - } + /** * Walk DOM to apply or remove aria-hidden on sibling nodes * and parent sibling nodes @@ -627,16 +673,20 @@ function MdDialogProvider($$interimElementProvider) { walkDOM(element = element.parentNode); } } + walkDOM(element); } - function dialogPopIn(container, parentElement, clickElement) { + /** + * Dialog open and pop-in animation + */ + function dialogPopIn(container, options ) { var dialogEl = container.find('md-dialog'); - parentElement.append(container); - transformToClickElement(dialogEl, clickElement); + options.parent.append(container); + transformToClickElement(dialogEl, options.origin); - $$rAF(function() { + $$rAF(function () { dialogEl.addClass('transition-in') .css($mdConstant.CSS.TRANSFORM, ''); }); @@ -644,42 +694,54 @@ function MdDialogProvider($$interimElementProvider) { return $mdUtil.transitionEndPromise(dialogEl); } - function dialogPopOut(container, parentElement, clickElement) { + /** + * Dialog close and pop-out animation + */ + function dialogPopOut(container, options) { var dialogEl = container.find('md-dialog'); dialogEl.addClass('transition-out').removeClass('transition-in'); - transformToClickElement(dialogEl, clickElement); + transformToClickElement(dialogEl, options.origin); return $mdUtil.transitionEndPromise(dialogEl); } - function transformToClickElement(dialogEl, clickElement) { - if (clickElement) { - var clickRect = clickElement[0].getBoundingClientRect(); - var dialogRect = dialogEl[0].getBoundingClientRect(); + /** + * Utility function to filter out raw DOM nodes + */ + function isNodeOneOf(elem, nodeTypeArray) { + if (nodeTypeArray.indexOf(elem.nodeName) !== -1) { + return true; + } + } - var scaleX = Math.min(0.5, clickRect.width / dialogRect.width); - var scaleY = Math.min(0.5, clickRect.height / dialogRect.height); - dialogEl.css($mdConstant.CSS.TRANSFORM, 'translate3d(' + - (-dialogRect.left + clickRect.left + clickRect.width/2 - dialogRect.width/2) + 'px,' + - (-dialogRect.top + clickRect.top + clickRect.height/2 - dialogRect.height/2) + 'px,' + - '0) scale(' + scaleX + ',' + scaleY + ')' - ); - } + function isPositiveSizeClientRect(rect) { + return rect && (rect.width > 0) && (rect.height > 0); } - function dialogTransitionEnd(dialogEl) { - var deferred = $q.defer(); - dialogEl.on($mdConstant.CSS.TRANSITIONEND, finished); - function finished(ev) { - //Make sure this transitionend didn't bubble up from a child - if (ev.target === dialogEl[0]) { - dialogEl.off($mdConstant.CSS.TRANSITIONEND, finished); - deferred.resolve(); + function transformToClickElement(dialogEl, originator) { + var target = originator.element; + var targetBnds = originator.bounds; + + if (target) { + var currentBnds = target[0].getBoundingClientRect(); + // If the event target element has zero size, it has probably been hidden. + // Use its initial position if available. + if (isPositiveSizeClientRect(currentBnds)) { + targetBnds = currentBnds; } + + var dialogRect = dialogEl[0].getBoundingClientRect(); + var scaleX = Math.min(0.5, targetBnds.width / dialogRect.width); + var scaleY = Math.min(0.5, targetBnds.height / dialogRect.height); + + dialogEl.css($mdConstant.CSS.TRANSFORM, 'translate3d(' + + (-dialogRect.left + targetBnds.left + targetBnds.width / 2 - dialogRect.width / 2) + 'px,' + + (-dialogRect.top + targetBnds.top + targetBnds.height / 2 - dialogRect.height / 2) + 'px,' + + '0) scale(' + scaleX + ',' + scaleY + ')' + ); } - return deferred.promise; } } diff --git a/src/components/dialog/dialog.spec.js b/src/components/dialog/dialog.spec.js index 104556e8d4b..7055f3bed21 100644 --- a/src/components/dialog/dialog.spec.js +++ b/src/components/dialog/dialog.spec.js @@ -236,13 +236,13 @@ describe('$mdDialog', function() { expect(parent.find('md-dialog').length).toBe(1); - $rootElement.triggerHandler({type: 'keyup', + parent.triggerHandler({type: 'keyup', keyCode: $mdConstant.KEY_CODE.ESCAPE }); - $timeout.flush(); parent.find('md-dialog').triggerHandler('transitionend'); $rootScope.$apply(); + expect(parent.find('md-dialog').length).toBe(0); })); @@ -413,6 +413,161 @@ describe('$mdDialog', function() { expect($document.activeElement).toBe(undefined); })); + /** + * Verifies that an element has the expected CSS for its transform property. + * Works by creating a new element, setting the expected CSS on that + * element, and comparing to the element being tested. This convoluted + * approach is needed because if jQuery is installed it can rewrite + * 'translate3d' values to equivalent 'matrix' values, for example turning + * 'translate3d(240px, 120px, 0px) scale(0.5, 0.5)' into + * 'matrix(0.5, 0, 0, 0.5, 240, 120)'. + */ + var verifyTransformCss = function(element, transformAttr, expectedCss) { + var testDiv = angular.element('
'); + testDiv.css(transformAttr, expectedCss); + expect(element.css(transformAttr)).toBe(testDiv.css(transformAttr)); + }; + + it('should expand from and shrink to targetEvent element', inject(function($mdDialog, $rootScope, $timeout, $mdConstant) { + // Create a targetEvent parameter pointing to a fake element with a + // defined bounding rectangle. + var fakeEvent = { + target: { + getBoundingClientRect: function() { + return {top: 100, left: 200, bottom: 140, right: 280, height: 40, width: 80}; + } + } + }; + var parent = angular.element('
'); + $mdDialog.show({ + template: '', + parent: parent, + targetEvent: fakeEvent, + clickOutsideToClose: true + }); + $rootScope.$apply(); + + var container = angular.element(parent[0].querySelector('.md-dialog-container')); + var dialog = parent.find('md-dialog'); + + dialog.triggerHandler('transitionend'); + $rootScope.$apply(); + + // The dialog's bounding rectangle is always zero size and position in + // these tests, so the target of the CSS transform should be the midpoint + // of the targetEvent element's bounding rect. + verifyTransformCss(dialog, $mdConstant.CSS.TRANSFORM, + 'translate3d(240px, 120px, 0px) scale(0.5, 0.5)'); + + // Clear the animation CSS so we can be sure it gets reset. + dialog.css($mdConstant.CSS.TRANSFORM, ''); + + // When the dialog is closed (here by an outside click), the animation + // should shrink to the same point it expanded from. + container.triggerHandler({ + type: 'click', + target: container[0] + }); + $timeout.flush(); + + verifyTransformCss(dialog, $mdConstant.CSS.TRANSFORM, + 'translate3d(240px, 120px, 0px) scale(0.5, 0.5)'); + })); + + it('should shrink to updated targetEvent element location', inject(function($mdDialog, $rootScope, $timeout, $mdConstant) { + // Create a targetEvent parameter pointing to a fake element with a + // defined bounding rectangle. + var fakeEvent = { + target: { + getBoundingClientRect: function() { + return {top: 100, left: 200, bottom: 140, right: 280, height: 40, width: 80}; + } + } + }; + + var parent = angular.element('
'); + $mdDialog.show({ + template: '', + parent: parent, + targetEvent: fakeEvent, + clickOutsideToClose: true + }); + $rootScope.$apply(); + + var container = angular.element(parent[0].querySelector('.md-dialog-container')); + var dialog = parent.find('md-dialog'); + + dialog.triggerHandler('transitionend'); + $rootScope.$apply(); + + verifyTransformCss(dialog, $mdConstant.CSS.TRANSFORM, + 'translate3d(240px, 120px, 0px) scale(0.5, 0.5)'); + + // Simulate the event target element moving on the page. When the dialog + // is closed, it should animate to the new midpoint. + fakeEvent.target.getBoundingClientRect = function() { + return {top: 300, left: 400, bottom: 360, right: 500, height: 60, width: 100}; + }; + container.triggerHandler({ + type: 'click', + target: container[0] + }); + $timeout.flush(); + + verifyTransformCss(dialog, $mdConstant.CSS.TRANSFORM, + 'translate3d(450px, 330px, 0px) scale(0.5, 0.5)'); + })); + + it('should shrink to original targetEvent element location if element is hidden', inject(function($mdDialog, $rootScope, $timeout, $mdConstant) { + // Create a targetEvent parameter pointing to a fake element with a + // defined bounding rectangle. + var fakeEvent = { + target: { + getBoundingClientRect: function() { + return {top: 100, left: 200, bottom: 140, right: 280, height: 40, width: 80}; + } + } + }; + + var parent = angular.element('
'); + $mdDialog.show({ + template: '', + parent: parent, + targetEvent: fakeEvent, + clickOutsideToClose: true + }); + $rootScope.$apply(); + + var container = angular.element(parent[0].querySelector('.md-dialog-container')); + var dialog = parent.find('md-dialog'); + + $timeout.flush(); + verifyTransformCss(dialog, $mdConstant.CSS.TRANSFORM, + 'translate3d(240px, 120px, 0px) scale(0.5, 0.5)'); + + dialog.triggerHandler('transitionend'); + $rootScope.$apply(); + + // Clear the animation CSS so we can be sure it gets reset. + dialog.css($mdConstant.CSS.TRANSFORM, ''); + + // Simulate the event target element being hidden, which would cause + // getBoundingClientRect() to return a rect with zero position and size. + // When the dialog is closed, the animation should shrink to the point + // it originally expanded from. + fakeEvent.target.getBoundingClientRect = function() { + return {top: 0, left: 0, bottom: 0, right: 0, height: 0, width: 0}; + }; + container.triggerHandler({ + type: 'click', + target: container[0] + }); + $timeout.flush(); + + verifyTransformCss(dialog, $mdConstant.CSS.TRANSFORM, + 'translate3d(240px, 120px, 0px) scale(0.5, 0.5)'); + })); + it('should focus the last `md-button` in md-actions open if no `.dialog-close`', inject(function($mdDialog, $rootScope, $document, $timeout, $mdConstant) { jasmine.mockElementFocus(this);