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);