From ffa32b1685a18f5cb9a2d8f94fbea35d330422e5 Mon Sep 17 00:00:00 2001 From: Brad Richardson Date: Thu, 15 Sep 2016 10:43:22 -0400 Subject: [PATCH] feat(tooltip): Tooltip use MdPanel API Tooltip has been refactored to use the MdPanel API to position, animate open and closed, and show the tooltip as a result of user interaction. Breaking Change: Tooltips with no content will no longer show and result in an error. Fixes #9563 --- .../tooltip/demoBasicUsage/index.html | 128 ++-- .../tooltip/demoBasicUsage/script.js | 20 +- .../tooltip/demoBasicUsage/style.css | 8 - src/components/tooltip/tooltip-theme.scss | 4 +- src/components/tooltip/tooltip.js | 592 +++++++++--------- src/components/tooltip/tooltip.scss | 108 ++-- src/components/tooltip/tooltip.spec.js | 475 +++++++------- 7 files changed, 670 insertions(+), 665 deletions(-) delete mode 100644 src/components/tooltip/demoBasicUsage/style.css diff --git a/src/components/tooltip/demoBasicUsage/index.html b/src/components/tooltip/demoBasicUsage/index.html index 82a521c09d..9314b50a6f 100644 --- a/src/components/tooltip/demoBasicUsage/index.html +++ b/src/components/tooltip/demoBasicUsage/index.html @@ -1,87 +1,111 @@
-

- Awesome Md App - - - Refresh - - +
+ +

Awesome Material App

+ + + Refresh + -

+ +
+

The tooltip is visible when the button is hovered, focused, or touched. + Hover over the Refresh icon in the above toolbar.

-
- - - - Insert Drive - - - - - Photos - - - +
+

+ The Tooltip's md-z-index attribute can be used to change + the tooltip's visual level in comparison with the other elements of + the application.
+ Note: the z-index default is 100. +

- -
-

md-direction attribute can used to dynamically change the direction of the tooltip.
- Note: the direction default value is 'bottom'.

-
- - Left - Top - Bottom - Right - +
+

+ The Tooltip's md-direction attribute can be used to + dynamically change the direction of the tooltip.
+ Note: the direction default value is + 'bottom'. +

+
+ + Top + Right + Bottom + Left + + + + Insert Drive + + + +
- -
+

- Additionally, the Tooltip's md-visible attribute can use data-binding to + The Tooltip's md-visible attribute can be used to programmatically show/hide itself. Toggle the checkbox below...

-
- - Insert Drive - +
+
+ + Insert Drive + +
+ + Photos + +
-
+

- Additionally, the Tooltip's md-delay attribute can use to delay the - show animation. The default values is 0 mSecs... + The Tooltip's md-delay attribute can be used to delay + the show animation.
+ Note: the delay default value is + 0 milliseconds.

-
- - - - - - - +
+
+ + + + +
+ - Photos with Tooltip Delay {{demo.delayTooltip}} msecs + Menu with Tooltip Delay {{demo.delayTooltip}} msecs + -
+
diff --git a/src/components/tooltip/demoBasicUsage/script.js b/src/components/tooltip/demoBasicUsage/script.js index 8059b4ea7c..92b15cca7c 100644 --- a/src/components/tooltip/demoBasicUsage/script.js +++ b/src/components/tooltip/demoBasicUsage/script.js @@ -1,18 +1,14 @@ -angular.module('tooltipDemo1', ['ngMaterial']) -.controller('AppCtrl', function($scope) { +angular.module('tooltipDemo', ['ngMaterial']) + .controller('AppCtrl', AppCtrl); + +function AppCtrl($scope) { $scope.demo = { - showTooltip : false, - tipDirection : '' + showTooltip: false, + tipDirection: 'bottom' }; $scope.demo.delayTooltip = undefined; - $scope.$watch('demo.delayTooltip',function(val) { + $scope.$watch('demo.delayTooltip', function(val) { $scope.demo.delayTooltip = parseInt(val, 10) || 0; }); - - $scope.$watch('demo.tipDirection',function(val) { - if (val && val.length ) { - $scope.demo.showTooltip = true; - } - }); -}); +} diff --git a/src/components/tooltip/demoBasicUsage/style.css b/src/components/tooltip/demoBasicUsage/style.css deleted file mode 100644 index 0f65234fd0..0000000000 --- a/src/components/tooltip/demoBasicUsage/style.css +++ /dev/null @@ -1,8 +0,0 @@ - -md-toolbar .md-toolbar-tools .md-button, -md-toolbar .md-toolbar-tools .md-button:hover { - box-shadow: none; - border: none; - transform: none; - -webkit-transform: none; -} \ No newline at end of file diff --git a/src/components/tooltip/tooltip-theme.scss b/src/components/tooltip/tooltip-theme.scss index 4a04f43b06..84b777a092 100644 --- a/src/components/tooltip/tooltip-theme.scss +++ b/src/components/tooltip/tooltip-theme.scss @@ -1,6 +1,6 @@ -md-tooltip.md-THEME_NAME-theme { +.md-tooltip.md-THEME_NAME-theme { color: '{{background-700-contrast}}'; - .md-content { + &.md-panel { background-color: '{{background-700}}'; } } diff --git a/src/components/tooltip/tooltip.js b/src/components/tooltip/tooltip.js index abebd504c0..86db40fae1 100644 --- a/src/components/tooltip/tooltip.js +++ b/src/components/tooltip/tooltip.js @@ -3,392 +3,402 @@ * @name material.components.tooltip */ angular - .module('material.components.tooltip', [ 'material.core' ]) - .directive('mdTooltip', MdTooltipDirective) - .service('$$mdTooltipRegistry', MdTooltipRegistry); + .module('material.components.tooltip', [ + 'material.core', + 'material.components.panel' + ]) + .directive('mdTooltip', MdTooltipDirective) + .service('$$mdTooltipRegistry', MdTooltipRegistry); + /** * @ngdoc directive * @name mdTooltip * @module material.components.tooltip * @description - * Tooltips are used to describe elements that are interactive and primarily graphical (not textual). + * Tooltips are used to describe elements that are interactive and primarily + * graphical (not textual). * * Place a `` as a child of the element it describes. * - * A tooltip will activate when the user focuses, hovers over, or touches the parent. + * A tooltip will activate when the user hovers over, focuses, or touches the + * parent element. * * @usage * - * - * - * Play Music - * - * - * + * + * Play Music + * + * * * - * @param {expression=} md-visible Boolean bound to whether the tooltip is currently visible. - * @param {number=} md-delay How many milliseconds to wait to show the tooltip after the user focuses, hovers, or touches the - * parent. Defaults to 0ms on non-touch devices and 75ms on touch. - * @param {boolean=} md-autohide If present or provided with a boolean value, the tooltip will hide on mouse leave, regardless of focus - * @param {string=} md-direction Which direction would you like the tooltip to go? Supports left, right, top, and bottom. Defaults to bottom. + * @param {number=} md-z-index The visual level that the tooltip will appear + * in comparison with the rest of the elements of the application. + * @param {expression=} md-visible Boolean bound to whether the tooltip is + * currently visible. + * @param {number=} md-delay How many milliseconds to wait to show the tooltip + * after the user hovers over, focuses, or touches the parent element. + * Defaults to 0ms on non-touch devices and 75ms on touch. + * @param {boolean=} md-autohide If present or provided with a boolean value, + * the tooltip will hide on mouse leave, regardless of focus. + * @param {string=} md-direction The direction that the tooltip is shown, + * relative to the parent element. Supports top, right, bottom, and left. + * Defaults to bottom. */ -function MdTooltipDirective($timeout, $window, $$rAF, $document, $mdUtil, $mdTheming, $animate, - $interpolate, $mdConstant, $$mdTooltipRegistry) { +function MdTooltipDirective($timeout, $window, $$rAF, $document, $interpolate, + $mdUtil, $mdTheming, $mdPanel, $$mdTooltipRegistry) { var ENTER_EVENTS = 'focus touchstart mouseenter'; var LEAVE_EVENTS = 'blur touchcancel mouseleave'; - var SHOW_CLASS = 'md-show'; - var TOOLTIP_SHOW_DELAY = 0; - var TOOLTIP_WINDOW_EDGE_SPACE = 8; + var TOOLTIP_DEFAULT_Z_INDEX = 100; + var TOOLTIP_DEFAULT_SHOW_DELAY = 0; + var TOOLTIP_DEFAULT_DIRECTION = 'bottom'; + var TOOLTIP_DIRECTIONS = { + top: { x: $mdPanel.xPosition.CENTER, y: $mdPanel.yPosition.ABOVE }, + right: { x: $mdPanel.xPosition.OFFSET_END, y: $mdPanel.yPosition.CENTER }, + bottom: { x: $mdPanel.xPosition.CENTER, y: $mdPanel.yPosition.BELOW }, + left: { x: $mdPanel.xPosition.OFFSET_START, y: $mdPanel.yPosition.CENTER } + }; return { restrict: 'E', - transclude: true, - priority: $mdConstant.BEFORE_NG_ARIA, - template: '
', + priority: 210, // Before ngAria scope: { - delay: '=?mdDelay', - visible: '=?mdVisible', - autohide: '=?mdAutohide', - direction: '@?mdDirection' // only expect raw or interpolated string value; not expression + mdZIndex: '=?mdZIndex', + mdDelay: '=?mdDelay', + mdVisible: '=?mdVisible', + mdAutohide: '=?mdAutohide', + mdDirection: '@?mdDirection' // Do not expect expressions. }, - compile: function(tElement, tAttr) { - if (!tAttr.mdDirection) { - tAttr.$set('mdDirection', 'bottom'); - } - - return postLink; - } + link: linkFunc }; - function postLink(scope, element, attr) { + function linkFunc(scope, element, attr) { + // Set constants. + var parent = $mdUtil.getParentWithPointerEvents(element); + var debouncedOnResize = $$rAF.throttle(updatePosition); + var mouseActive = false; + var origin, position, panelPosition, panelRef, autohide, showTimeout, + elementFocusedOnWindowBlur = null; + // Initialize the theming of the tooltip. $mdTheming(element); - var parent = $mdUtil.getParentWithPointerEvents(element), - content = angular.element(element[0].querySelector('.md-content')), - tooltipParent = angular.element(document.body), - showTimeout = null, - debouncedOnResize = $$rAF.throttle(function () { updatePosition(); }); - - if ($animate.pin) $animate.pin(element, parent); - - // Initialize element - + // Set defaults setDefaults(); - manipulateElement(); - bindEvents(); - // Default origin transform point is 'center top' - // positionTooltip() is always relative to center top - updateContentOrigin(); - - configureWatchers(); + // Set parent aria-label. addAriaLabel(); + // Remove the element from its current DOM position. + element.detach(); + element.attr('role', 'tooltip'); - function setDefaults () { - scope.delay = scope.delay || TOOLTIP_SHOW_DELAY; - } + updatePosition(); + bindEvents(); + configureWatchers(); - function updateContentOrigin() { - var origin = 'center top'; - switch (scope.direction) { - case 'left' : origin = 'right center'; break; - case 'right' : origin = 'left center'; break; - case 'top' : origin = 'center bottom'; break; - case 'bottom': origin = 'center top'; break; + function setDefaults() { + scope.mdZIndex = scope.mdZIndex || TOOLTIP_DEFAULT_Z_INDEX; + scope.mdDelay = scope.mdDelay || TOOLTIP_DEFAULT_SHOW_DELAY; + if (!TOOLTIP_DIRECTIONS[scope.mdDirection]) { + scope.mdDirection = TOOLTIP_DEFAULT_DIRECTION; } - content.css('transform-origin', origin); } - function onVisibleChanged (isVisible) { - if (isVisible) showTooltip(); - else hideTooltip(); + function addAriaLabel(override) { + if (override || !parent.attr('aria-label')) { + var rawText = override || element.text().trim(); + var interpolatedText = $interpolate(rawText)(parent.scope()); + parent.attr('aria-label', interpolatedText); + } } - function configureWatchers () { - if (element[0] && 'MutationObserver' in $window) { - var attributeObserver = new MutationObserver(function(mutations) { - mutations - .forEach(function (mutation) { - if (mutation.attributeName === 'md-visible') { - if (!scope.visibleWatcher) - scope.visibleWatcher = scope.$watch('visible', onVisibleChanged ); - } - if (mutation.attributeName === 'md-direction') { - updatePosition(scope.direction); - } - }); - }); - - attributeObserver.observe(element[0], { attributes: true }); + function updatePosition() { + setDefaults(); - // build watcher only if mdVisible is being used - if (attr.hasOwnProperty('mdVisible')) { - scope.visibleWatcher = scope.$watch('visible', onVisibleChanged ); - } - } else { // MutationObserver not supported - scope.visibleWatcher = scope.$watch('visible', onVisibleChanged ); - scope.$watch('direction', updatePosition ); + // If the panel has already been created, remove the current origin + // class from the panel element. + if (panelRef && panelRef.panelEl) { + panelRef.panelEl.removeClass(origin); } - var onElementDestroy = function() { - scope.$destroy(); - }; + // Set the panel element origin class based off of the current + // mdDirection. + origin = 'md-origin-' + scope.mdDirection; - // Clean up if the element or parent was removed via jqLite's .remove. - // A couple of notes: - // - In these cases the scope might not have been destroyed, which is why we - // destroy it manually. An example of this can be having `md-visible="false"` and - // adding tooltips while they're invisible. If `md-visible` becomes true, at some - // point, you'd usually get a lot of tooltips. - // - We use `.one`, not `.on`, because this only needs to fire once. If we were - // using `.on`, it would get thrown into an infinite loop. - // - This kicks off the scope's `$destroy` event which finishes the cleanup. - element.one('$destroy', onElementDestroy); - parent.one('$destroy', onElementDestroy); - scope.$on('$destroy', function() { - setVisible(false); - element.remove(); - attributeObserver && attributeObserver.disconnect(); - }); + // Create the position of the panel based off of the mdDirection. + position = TOOLTIP_DIRECTIONS[scope.mdDirection]; - // Updates the aria-label when the element text changes. This watch - // doesn't need to be set up if the element doesn't have any data - // bindings. - if (element.text().indexOf($interpolate.startSymbol()) > -1) { - scope.$watch(function() { - return element.text().trim(); - }, addAriaLabel); - } - } + // Using the newly created position object, use the MdPanel + // panelPosition API to build the panel's position. + panelPosition = $mdPanel.newPanelPosition() + .relativeTo(parent) + .addPanelPosition(position.x, position.y); - function addAriaLabel (override) { - if ((override || !parent.attr('aria-label')) && !parent.text().trim()) { - var rawText = override || element.text().trim(); - var interpolatedText = $interpolate(rawText)(parent.scope()); - parent.attr('aria-label', interpolatedText); + // If the panel has already been created, add the new origin class to + // the panel element and update it's position with the panelPosition. + if (panelRef && panelRef.panelEl) { + panelRef.panelEl.addClass(origin); + panelRef.updatePosition(panelPosition); } } - function manipulateElement () { - element.detach(); - element.attr('role', 'tooltip'); - } - - function bindEvents () { - var mouseActive = false; - - // add an mutationObserver when there is support for it - // and the need for it in the form of viable host(parent[0]) + function bindEvents() { + // Add a mutationObserver where there is support for it and the need + // for it in the form of viable host(parent[0]). if (parent[0] && 'MutationObserver' in $window) { - // use an mutationObserver to tackle #2602 + // Use a mutationObserver to tackle #2602. var attributeObserver = new MutationObserver(function(mutations) { - if (mutations.some(function (mutation) { - return (mutation.attributeName === 'disabled' && parent[0].disabled); - })) { - $mdUtil.nextTick(function() { - setVisible(false); - }); + if (isDisabledMutation(mutations)) { + $mdUtil.nextTick(function() { + setVisible(false); + }); } }); - attributeObserver.observe(parent[0], { attributes: true}); + attributeObserver.observe(parent[0], { + attributes: true + }); } - // Store whether the element was focused when the window loses focus. - var windowBlurHandler = function() { - elementFocusedOnWindowBlur = document.activeElement === parent[0]; - }; + elementFocusedOnWindowBlur = false; - var elementFocusedOnWindowBlur = false; + $$mdTooltipRegistry.register('scroll', windowScrollEventHandler, true); + $$mdTooltipRegistry.register('blur', windowBlurEventHandler); + $$mdTooltipRegistry.register('resize', debouncedOnResize); - function windowScrollHandler() { - setVisible(false); - } + scope.$on('$destroy', onDestroy); - $$mdTooltipRegistry.register('scroll', windowScrollHandler, true); - $$mdTooltipRegistry.register('blur', windowBlurHandler); - $$mdTooltipRegistry.register('resize', debouncedOnResize); + // To avoid 'synthetic clicks', we listen to mousedown instead of + // 'click'. + parent.on('mousedown', mousedownEventHandler); + parent.on(ENTER_EVENTS, enterEventHandler); - scope.$on('$destroy', function() { - $$mdTooltipRegistry.deregister('scroll', windowScrollHandler, true); - $$mdTooltipRegistry.deregister('blur', windowBlurHandler); - $$mdTooltipRegistry.deregister('resize', debouncedOnResize); + function isDisabledMutation(mutations) { + mutations.some(function(mutation) { + return mutation.attributeName === 'disabled' && parent[0].disabled; + }); + return false; + } - parent - .off(ENTER_EVENTS, enterHandler) - .off(LEAVE_EVENTS, leaveHandler) - .off('mousedown', mousedownHandler); + function windowScrollEventHandler() { + setVisible(false); + } - // Trigger the handler in case any the tooltip was still visible. - leaveHandler(); - attributeObserver && attributeObserver.disconnect(); - }); + function windowBlurEventHandler() { + elementFocusedOnWindowBlur = document.activeElement === parent[0]; + } - var enterHandler = function(e) { - // Prevent the tooltip from showing when the window is receiving focus. - if (e.type === 'focus' && elementFocusedOnWindowBlur) { + function enterEventHandler($event) { + // Prevent the tooltip from showing when the window is receiving + // focus. + if ($event.type === 'focus' && elementFocusedOnWindowBlur) { elementFocusedOnWindowBlur = false; - } else if (!scope.visible) { - parent.on(LEAVE_EVENTS, leaveHandler); + } else if (!scope.mdVisible) { + parent.on(LEAVE_EVENTS, leaveEventHandler); setVisible(true); - // If the user is on a touch device, we should bind the tap away after - // the `touched` in order to prevent the tooltip being removed immediately. - if (e.type === 'touchstart') { + // If the user is on a touch device, we should bind the tap away + // after the 'touched' in order to prevent the tooltip being + // removed immediately. + if ($event.type === 'touchstart') { parent.one('touchend', function() { $mdUtil.nextTick(function() { - $document.one('touchend', leaveHandler); + $document.one('touchend', leaveEventHandler); }, false); }); } } - }; + } - var leaveHandler = function () { - var autohide = scope.hasOwnProperty('autohide') ? scope.autohide : attr.hasOwnProperty('mdAutohide'); + function leaveEventHandler() { + autohide = scope.hasOwnProperty('mdAutohide') ? + scope.mdAutohide : + attr.hasOwnProperty('mdAutohide'); - if (autohide || mouseActive || $document[0].activeElement !== parent[0]) { - // When a show timeout is currently in progress, then we have to cancel it. - // Otherwise the tooltip will remain showing without focus or hover. + if (autohide || mouseActive || + $document[0].activeElement !== parent[0]) { + // When a show timeout is currently in progress, then we have + // to cancel it, otherwise the tooltip will remain showing + // without focus or hover. if (showTimeout) { $timeout.cancel(showTimeout); setVisible.queued = false; showTimeout = null; } - parent.off(LEAVE_EVENTS, leaveHandler); + parent.off(LEAVE_EVENTS, leaveEventHandler); parent.triggerHandler('blur'); setVisible(false); } mouseActive = false; - }; + } - var mousedownHandler = function() { + function mousedownEventHandler() { mouseActive = true; - }; + } - // to avoid `synthetic clicks` we listen to mousedown instead of `click` - parent.on('mousedown', mousedownHandler); - parent.on(ENTER_EVENTS, enterHandler); + function onDestroy() { + $$mdTooltipRegistry.deregister('scroll', windowScrollEventHandler, true); + $$mdTooltipRegistry.deregister('blur', windowBlurEventHandler); + $$mdTooltipRegistry.deregister('resize', debouncedOnResize); + + parent + .off(ENTER_EVENTS, enterEventHandler) + .off(LEAVE_EVENTS, leaveEventHandler) + .off('mousedown', mousedownEventHandler); + + // Trigger the handler in case any of the tooltips are + // still visible. + leaveEventHandler(); + attributeObserver && attributeObserver.disconnect(); + } + } + + function configureWatchers() { + if (element[0] && 'MutationObserver' in $window) { + var attributeObserver = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + if (mutation.attributeName === 'md-visible' && + !scope.visibleWatcher ) { + scope.visibleWatcher = scope.$watch('mdVisible', + onVisibleChanged); + } + }); + }); + + attributeObserver.observe(element[0], { + attributes: true + }); + + // Build watcher only if mdVisible is being used. + if (attr.hasOwnProperty('mdVisible')) { + scope.visibleWatcher = scope.$watch('mdVisible', + onVisibleChanged); + } + } else { + // MutationObserver not supported + scope.visibleWatcher = scope.$watch('mdVisible', onVisibleChanged); + } + + // Direction watcher + scope.$watch('mdDirection', updatePosition); + + // Clean up if the element or parent was removed via jqLite's .remove. + // A couple of notes: + // - In these cases the scope might not have been destroyed, which + // is why we destroy it manually. An example of this can be having + // `md-visible="false"` and adding tooltips while they're + // invisible. If `md-visible` becomes true, at some point, you'd + // usually get a lot of tooltips. + // - We use `.one`, not `.on`, because this only needs to fire once. + // If we were using `.on`, it would get thrown into an infinite + // loop. + // - This kicks off the scope's `$destroy` event which finishes the + // cleanup. + element.one('$destroy', onElementDestroy); + parent.one('$destroy', onElementDestroy); + scope.$on('$destroy', function() { + setVisible(false); + element.remove(); + attributeObserver && attributeObserver.disconnect(); + }); + + // Updates the aria-label when the element text changes. This watch + // doesn't need to be set up if the element doesn't have any data + // bindings. + if (element.text().indexOf($interpolate.startSymbol()) > -1) { + scope.$watch(function() { + return element.text().trim(); + }, addAriaLabel); + } + + function onElementDestroy() { + scope.$destroy(); + } } - function setVisible (value) { - // break if passed value is already in queue or there is no queue and passed value is current in the scope - if (setVisible.queued && setVisible.value === !!value || !setVisible.queued && scope.visible === !!value) return; + function setVisible(value) { + // Break if passed value is already in queue or there is no queue and + // passed value is current in the controller. + if (setVisible.queued && setVisible.value === !!value || + !setVisible.queued && scope.mdVisible === !!value) { + return; + } setVisible.value = !!value; if (!setVisible.queued) { if (value) { setVisible.queued = true; showTimeout = $timeout(function() { - scope.visible = setVisible.value; + scope.mdVisible = setVisible.value; setVisible.queued = false; showTimeout = null; - if (!scope.visibleWatcher) { - onVisibleChanged(scope.visible); + onVisibleChanged(scope.mdVisible); } - }, scope.delay); + }, scope.mdDelay); } else { $mdUtil.nextTick(function() { - scope.visible = false; - if (!scope.visibleWatcher) + scope.mdVisible = false; + if (!scope.visibleWatcher) { onVisibleChanged(false); + } }); } } } - function showTooltip() { - // Do not show the tooltip if the text is empty. - if (!element[0].textContent.trim()) return; - - // Insert the element and position at top left, so we can get the position - // and check if we should display it - element.css({top: 0, left: 0}); - tooltipParent.append(element); - - // Check if we should display it or not. - // This handles hide-* and show-* along with any user defined css - if ( $mdUtil.hasComputedStyle(element, 'display', 'none')) { - scope.visible = false; - element.detach(); - return; - } - - updatePosition(); - - $animate.addClass(content, SHOW_CLASS).then(function() { - element.addClass(SHOW_CLASS); - }); - } - - function hideTooltip() { - $animate.removeClass(content, SHOW_CLASS).then(function(){ - element.removeClass(SHOW_CLASS); - if (!scope.visible) element.detach(); - }); + function onVisibleChanged(isVisible) { + isVisible ? showTooltip() : hideTooltip(); } - function updatePosition() { - if ( !scope.visible ) return; - - updateContentOrigin(); - positionTooltip(); - } - - function positionTooltip() { - var tipRect = $mdUtil.offsetRect(element, tooltipParent); - var parentRect = $mdUtil.offsetRect(parent, tooltipParent); - var newPosition = getPosition(scope.direction); - var offsetParent = element.prop('offsetParent'); - - // If the user provided a direction, just nudge the tooltip onto the screen - // Otherwise, recalculate based on 'top' since default is 'bottom' - if (scope.direction) { - newPosition = fitInParent(newPosition); - } else if (offsetParent && newPosition.top > offsetParent.scrollHeight - tipRect.height - TOOLTIP_WINDOW_EDGE_SPACE) { - newPosition = fitInParent(getPosition('top')); + function showTooltip() { + // Do not show the tooltip if the text is empty. + if (!element[0].textContent.trim()) { + throw new Error('Text for the tooltip has not been provided. ' + + 'Please include text within the mdTooltip element.'); } - element.css({ - left: newPosition.left + 'px', - top: newPosition.top + 'px' - }); + if (!panelRef) { + var id = 'tooltip-' + $mdUtil.nextUid(); + var attachTo = angular.element(document.body); + var content = element.html().trim(); + var panelAnimation = $mdPanel.newPanelAnimation() + .openFrom(parent) + .closeTo(parent) + .withAnimation({ + open: 'md-show', + close: 'md-hide' + }); - function fitInParent (pos) { - var newPosition = { left: pos.left, top: pos.top }; - newPosition.left = Math.min( newPosition.left, tooltipParent.prop('scrollWidth') - tipRect.width - TOOLTIP_WINDOW_EDGE_SPACE ); - newPosition.left = Math.max( newPosition.left, TOOLTIP_WINDOW_EDGE_SPACE ); - newPosition.top = Math.min( newPosition.top, tooltipParent.prop('scrollHeight') - tipRect.height - TOOLTIP_WINDOW_EDGE_SPACE ); - newPosition.top = Math.max( newPosition.top, TOOLTIP_WINDOW_EDGE_SPACE ); - return newPosition; + var panelConfig = { + id: id, + attachTo: attachTo, + template: content, + propagateContainerEvents: true, + panelClass: 'md-tooltip ' + origin, + animation: panelAnimation, + position: panelPosition, + zIndex: scope.mdZIndex, + focusOnOpen: false + }; + + panelRef = $mdPanel.create(panelConfig); } - function getPosition (dir) { - return dir === 'left' - ? { left: parentRect.left - tipRect.width - TOOLTIP_WINDOW_EDGE_SPACE, - top: parentRect.top + parentRect.height / 2 - tipRect.height / 2 } - : dir === 'right' - ? { left: parentRect.left + parentRect.width + TOOLTIP_WINDOW_EDGE_SPACE, - top: parentRect.top + parentRect.height / 2 - tipRect.height / 2 } - : dir === 'top' - ? { left: parentRect.left + parentRect.width / 2 - tipRect.width / 2, - top: parentRect.top - tipRect.height - TOOLTIP_WINDOW_EDGE_SPACE } - : { left: parentRect.left + parentRect.width / 2 - tipRect.width / 2, - top: parentRect.top + parentRect.height + TOOLTIP_WINDOW_EDGE_SPACE }; - } + panelRef.open(); } + function hideTooltip() { + panelRef && panelRef.close(); + } } } + /** * Service that is used to reduce the amount of listeners that are being * registered on the `window` by the tooltip component. Works by collecting @@ -406,9 +416,9 @@ function MdTooltipRegistry() { }; /** - * Global event handler that dispatches the registered - * handlers in the service. - * @param {Event} event Event object passed in by the browser. + * Global event handler that dispatches the registered handlers in the + * service. + * @param {!Event} event Event object passed in by the browser */ function globalEventHandler(event) { if (listeners[event.type]) { @@ -420,45 +430,39 @@ function MdTooltipRegistry() { /** * Registers a new handler with the service. - * @param {String} type Type of event to be registered. - * @param {Function} handler Event handler - * @param {Boolean} useCapture Whether to use event capturing. + * @param {string} type Type of event to be registered. + * @param {!Function} handler Event handler. + * @param {boolean} useCapture Whether to use event capturing. */ function register(type, handler, useCapture) { - var array = listeners[type] = listeners[type] || []; + var handlers = listeners[type] = listeners[type] || []; - if (!array.length) { - if (useCapture) { - window.addEventListener(type, globalEventHandler, true); - } else { - ngWindow.on(type, globalEventHandler); - } + if (!handlers.length) { + useCapture ? window.addEventListener(type, globalEventHandler, true) : + ngWindow.on(type, globalEventHandler); } - if (array.indexOf(handler) === -1) { - array.push(handler); + if (handlers.indexOf(handler) === -1) { + handlers.push(handler); } } /** * Removes an event handler from the service. - * @param {String} type Type of event handler. - * @param {Function} handler The event handler itself. - * @param {Boolean} useCapture Whether the event handler used event capturing. + * @param {string} type Type of event handler. + * @param {!Function} handler The event handler itself. + * @param {boolean} useCapture Whether the event handler used event capturing. */ function deregister(type, handler, useCapture) { - var array = listeners[type]; - var index = array ? array.indexOf(handler) : -1; + var handlers = listeners[type]; + var index = handlers ? handlers.indexOf(handler) : -1; if (index > -1) { - array.splice(index, 1); + handlers.splice(index, 1); - if (array.length === 0) { - if (useCapture) { - window.removeEventListener(type, globalEventHandler, true); - } else { - ngWindow.off(type, globalEventHandler); - } + if (handlers.length === 0) { + useCapture ? window.removeEventListener(type, globalEventHandler, true) : + ngWindow.off(type, globalEventHandler); } } } diff --git a/src/components/tooltip/tooltip.scss b/src/components/tooltip/tooltip.scss index 7bfe98f6bf..ab87b5d0af 100644 --- a/src/components/tooltip/tooltip.scss +++ b/src/components/tooltip/tooltip.scss @@ -1,71 +1,67 @@ -$tooltip-fontsize-lg: rem(1) !default; -$tooltip-fontsize-sm: rem(1.4) !default; -$tooltip-height-lg: rem(2.2) !default; -$tooltip-height-sm: rem(3.2) !default; -$tooltip-top-margin-lg: rem(1.4) !default; -$tooltip-top-margin-sm: rem(2.4) !default; -$tooltip-lr-padding-lg: rem(0.8) !default; -$tooltip-lr-padding-sm: rem(1.6) !default; -$tooltip-max-width: rem(3.20) !default; +$tooltip-fontsize-lg: 10px !default; +$tooltip-fontsize-sm: 14px !default; +$tooltip-height-lg: 22px !default; +$tooltip-height-sm: 32px !default; +$tooltip-top-margin-lg: 14px !default; +$tooltip-top-margin-sm: 24px !default; +$tooltip-lr-padding-lg: 8px !default; +$tooltip-lr-padding-sm: 16px !default; +$tooltip-max-width: 32px !default; -md-tooltip { - position: absolute; - z-index: $z-index-tooltip; - overflow: hidden; +.md-tooltip { pointer-events: none; border-radius: 4px; - + overflow: hidden; + opacity: 0; font-weight: 500; font-size: $tooltip-fontsize-sm; - @media (min-width: $layout-breakpoint-sm) { - font-size: $tooltip-fontsize-lg; + white-space: nowrap; + text-overflow: ellipsis; + height: $tooltip-height-sm; + line-height: $tooltip-height-sm; + padding-right: $tooltip-lr-padding-sm; + padding-left: $tooltip-lr-padding-sm; + &.md-origin-top { + transform-origin: center bottom; + margin-top: -$tooltip-top-margin-sm; } - - .md-content { - position: relative; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + &.md-origin-right { + transform-origin: left center; + margin-left: $tooltip-top-margin-sm; + } + &.md-origin-bottom { transform-origin: center top; - transform: scale(0); - opacity: 0; - height: $tooltip-height-sm; - line-height: $tooltip-height-sm; - padding-left: $tooltip-lr-padding-sm; - padding-right: $tooltip-lr-padding-sm; - @media (min-width: $layout-breakpoint-sm) { - height: $tooltip-height-lg; - line-height: $tooltip-height-lg; - padding-left: $tooltip-lr-padding-lg; - padding-right: $tooltip-lr-padding-lg; - } - &.md-show-add { - transition: $swift-ease-out; - transition-duration: .2s; - transform: scale(0); - opacity: 0; - } - &.md-show, &.md-show-add-active { - transform: scale(1); - opacity: 0.9; - transform-origin: center top; - } - &.md-show-remove { - transition: $swift-ease-out; - transition-duration: .2s; - &.md-show-remove-active { - transform: scale(0); - opacity: 0; - } - } + margin-top: $tooltip-top-margin-sm; + } + &.md-origin-left { + transform-origin: right center; + margin-left: -$tooltip-top-margin-sm; } - &.md-hide { - transition: $swift-ease-in; + @media (min-width: $layout-breakpoint-sm) { + font-size: $tooltip-fontsize-lg; + height: $tooltip-height-lg; + line-height: $tooltip-height-lg; + padding-right: $tooltip-lr-padding-lg; + padding-left: $tooltip-lr-padding-lg; + &.md-origin-top { margin-top: -$tooltip-top-margin-lg; } + &.md-origin-right { margin-left: $tooltip-top-margin-lg; } + &.md-origin-bottom { margin-top: $tooltip-top-margin-lg; } + &.md-origin-left { margin-left: -$tooltip-top-margin-lg; } } + &.md-show-add { + transform: scale(0); + } &.md-show { transition: $swift-ease-out; - pointer-events: auto; + transform: scale(1); + opacity: 0.9; + } + &.md-hide { + transition: $swift-ease-in; + transition-duration: .1s; + transform: scale(0); + opacity: 0; } } diff --git a/src/components/tooltip/tooltip.spec.js b/src/components/tooltip/tooltip.spec.js index c1427d843c..6b9cf6bf0d 100644 --- a/src/components/tooltip/tooltip.spec.js +++ b/src/components/tooltip/tooltip.spec.js @@ -1,20 +1,30 @@ -describe(' directive', function() { - var $compile, $rootScope, $material, $timeout, $$mdTooltipRegistry; +describe('MdTooltip Component', function() { + var $compile, $rootScope, $material, $timeout, $mdPanel, $$mdTooltipRegistry; var element; - beforeEach(module('material.components.tooltip')); - beforeEach(module('material.components.button')); - beforeEach(inject(function(_$compile_, _$rootScope_, _$material_, _$timeout_, _$$mdTooltipRegistry_){ - $compile = _$compile_; - $rootScope = _$rootScope_; - $material = _$material_; - $timeout = _$timeout_; - $$mdTooltipRegistry = _$$mdTooltipRegistry_; - })); + var injectLocals = function($injector) { + $compile = $injector.get('$compile'); + $rootScope = $injector.get('$rootScope'); + $material = $injector.get('$material'); + $timeout = $injector.get('$timeout'); + $mdPanel = $injector.get('$mdPanel'); + $$mdTooltipRegistry = $injector.get('$$mdTooltipRegistry'); + }; + + beforeEach(function() { + module( + 'material.components.tooltip', + 'material.components.button' + ); + + inject(injectLocals); + }); + afterEach(function() { - // Make sure to remove/cleanup after each test + // Make sure to remove/cleanup after each test. + element.remove(); var scope = element && element.scope(); - scope && scope.$destroy(); + scope && scope.$destroy; element = undefined; }); @@ -29,96 +39,97 @@ describe(' directive', function() { }).not.toThrow(); }); - it('should set the position to "bottom", if it is undefined', function() { + it('should set the position to "bottom" if it is undefined', function() { buildTooltip( '' + - 'Tooltip' + + 'Tooltip' + '' ); - expect(findTooltip().attr('md-direction')).toBe('bottom'); + expect(findTooltip()).toHaveClass('md-origin-bottom'); }); - it('should preserve parent text', function(){ - buildTooltip( - '' + - 'Hello' + - 'Tooltip' + - '' - ); + it('should preserve parent text', function() { + buildTooltip( + '' + + 'Hello' + + 'Tooltip' + + '' + ); - expect(element.text()).toBe("Hello"); + expect(element.text()).toBe('Hello'); }); - it('should label parent', function(){ - buildTooltip( - '' + - '' + - 'Tooltip' + - ''+ - '' - ); + it('should label parent', function() { + buildTooltip( + '' + + '' + + 'Tooltip' + + '' + + '' + ); - expect(element.attr('aria-label')).toEqual('Tooltip'); + expect(element.attr('aria-label')).toEqual('Tooltip'); }); - it('should interpolate the aria-label', function(){ - buildTooltip( - '' + - '{{ "hello" | uppercase }}' + - '' - ); + it('should interpolate the aria-label', function() { + buildTooltip( + '' + + '{{ "hello" | uppercase }}' + + '' + ); - expect(element.attr('aria-label')).toBe('HELLO'); + expect(element.attr('aria-label')).toBe('HELLO'); }); - it('should update the aria-label when the interpolated value changes', function(){ - buildTooltip( - '' + - '{{ testModel.ariaTest }}' + - '' - ); + it('should update the aria-label when the interpolated value changes', + function() { + buildTooltip( + '' + + '{{ testModel.ariaText }}' + + '' + ); - $rootScope.$apply(function() { - $rootScope.testModel.ariaTest = 'test 1'; - }); + $rootScope.$apply(function() { + $rootScope.testModel.ariaText = 'test 1'; + }); - expect(element.attr('aria-label')).toBe('test 1'); + expect(element.attr('aria-label')).toBe('test 1'); - $rootScope.$apply(function() { - $rootScope.testModel.ariaTest = 'test 2'; - }); + $rootScope.$apply(function() { + $rootScope.testModel.ariaText = 'test 2'; + }); - expect(element.attr('aria-label')).toBe('test 2'); - }); + expect(element.attr('aria-label')).toBe('test 2'); + }); - it('should not set parent to items with no pointer events', inject(function($window){ - spyOn($window, 'getComputedStyle').and.callFake(function(el) { - return { 'pointer-events': el ? 'none' : '' }; - }); + it('should not set parent to items with no pointer events', + inject(function($window) { + spyOn($window, 'getComputedStyle').and.callFake(function(el) { + return { 'pointer-events': el ? 'none' : '' }; + }); - buildTooltip( - '' + - '' + - '' + - 'Hello world' + - '' + - '' + - '' - ); - - triggerEvent('mouseenter', true); - expect($rootScope.testModel.isVisible).toBeUndefined(); + buildTooltip( + '' + + '' + + '' + + 'Hello world' + + '' + + '' + + '' + ); - })); + triggerEvent('mouseenter', true); + expect($rootScope.testModel.isVisible).toBeUndefined(); + })); it('should show after tooltipDelay ms', function() { buildTooltip( '' + - 'Hello' + - '' + - 'Tooltip' + - '' + + 'Hello' + + '' + + 'Tooltip' + + '' + '' ); @@ -132,7 +143,6 @@ describe(' directive', function() { // Total 300 == tooltipDelay $timeout.flush(1); expect($rootScope.testModel.isVisible).toBe(true); - }); it('should register itself with the $$mdTooltipRegistry', function() { @@ -148,9 +158,7 @@ describe(' directive', function() { }); describe('show and hide', function() { - - it('should show and hide when visible is set', function() { - + it('should show and hide when visible is set', function() { expect(findTooltip().length).toBe(0); buildTooltip( @@ -169,56 +177,60 @@ describe(' directive', function() { showTooltip(false); - expect(findTooltip().length).toBe(0); + expect(findTooltip().length).toBe(1); + expect(findTooltip().hasClass('md-hide')).toBe(true); }); it('should set visible on mouseenter and mouseleave', function() { - buildTooltip( - '' + - 'Hello' + - '' + - 'Tooltip' + - '' + - '' - ); + buildTooltip( + '' + + 'Hello' + + '' + + 'Tooltip' + + '' + + '' + ); - triggerEvent('mouseenter'); - expect($rootScope.testModel.isVisible).toBe(true); + triggerEvent('mouseenter'); + expect($rootScope.testModel.isVisible).toBe(true); - triggerEvent('mouseleave'); - expect($rootScope.testModel.isVisible).toBe(false); + triggerEvent('mouseleave'); + expect($rootScope.testModel.isVisible).toBe(false); }); - it('should should toggle visibility on the next touch', inject(function($document) { - buildTooltip( - '' + - 'Hello' + - '' + - 'Tooltip' + - '' + - '' - ); - - triggerEvent('touchstart'); - expect($rootScope.testModel.isVisible).toBe(true); - triggerEvent('touchend'); - - $document.triggerHandler('touchend'); - $timeout.flush(); - expect($rootScope.testModel.isVisible).toBe(false); - })); + it('should toggle visibility on the next touch', + inject(function($document) { + buildTooltip( + '' + + 'Hello' + + '' + + 'Tooltip' + + '' + + '' + ); + + triggerEvent('touchstart'); + expect($rootScope.testModel.isVisible).toBe(true); + triggerEvent('touchend'); + + $document.triggerHandler('touchend'); + $timeout.flush(); + expect($rootScope.testModel.isVisible).toBe(false); + })); it('should cancel when mouseleave was before the delay', function() { buildTooltip( '' + 'Hello' + - '' + + '' + 'Tooltip' + '' + '' ); - triggerEvent('mouseenter', true); expect($rootScope.testModel.isVisible).toBeFalsy(); @@ -231,39 +243,27 @@ describe(' directive', function() { expect($rootScope.testModel.isVisible).toBe(false); }); - it('should not show when the text is empty', function() { - - expect(findTooltip().length).toBe(0); - + it('should throw when the tooltip text is empty', function() { buildTooltip( '' + 'Hello' + - '{{ textContent }} ' + + '' + + '{{ textContent }}' + + '' + '' ); - showTooltip(true); - - expect(findTooltip().length).toBe(0); - - $rootScope.textContent = 'Tooltip'; - $rootScope.$apply(); - - // Trigger a change on the model, otherwise the tooltip component can't detect the - // change. - showTooltip(false); - showTooltip(true); - - expect(findTooltip().length).toBe(1); - expect(findTooltip().hasClass('md-show')).toBe(true); + expect(function() { + showTooltip(true); + }).toThrow(); }); it('should set visible on focus and blur', function() { buildTooltip( '' + - 'Hello' + - '' + - 'Tooltip' + + 'Hello' + + '' + + 'Tooltip' + '' + '' ); @@ -275,83 +275,68 @@ describe(' directive', function() { expect($rootScope.testModel.isVisible).toBe(false); }); - it('should not be visible on mousedown and then mouseleave', inject(function($document) { - buildTooltip( - '' + - 'Hello' + - '' + - 'Tooltip' + - '' + - '' - ); - - // Append element to DOM so it can be set as activeElement. - $document[0].body.appendChild(element[0]); - element[0].focus(); - triggerEvent('focus,mousedown'); - - expect($document[0].activeElement).toBe(element[0]); - expect($rootScope.testModel.isVisible).toBe(true); - - triggerEvent('mouseleave'); - expect($rootScope.testModel.isVisible).toBe(false); - - // Clean up document.body. - $document[0].body.removeChild(element[0]); - })); - - it('should not be visible when the window is refocused', inject(function($window, $document) { - buildTooltip( - '' + - 'Hello' + - '' + - 'Tooltip' + - '' + - '' - ); - - // Append element to DOM so it can be set as activeElement. - $document[0].body.appendChild(element[0]); - element[0].focus(); - triggerEvent('focus,mousedown'); - expect(document.activeElement).toBe(element[0]); - - triggerEvent('mouseleave'); - - // Simulate tabbing away. - angular.element($window).triggerHandler('blur'); - - // Simulate focus event that occurs when tabbing back to the window. - triggerEvent('focus'); - expect($rootScope.testModel.isVisible).toBe(false); - - // Clean up document.body. - $document[0].body.removeChild(element[0]); - })); - + it('should not be visible on mousedown and then mouseleave', + inject(function($document) { + buildTooltip( + '' + + 'Hello' + + '' + + 'Tooltip' + + '' + + '' + ); + + // Append element to DOM so it can be set as activeElement. + $document[0].body.appendChild(element[0]); + element[0].focus(); + triggerEvent('focus,mousedown'); + + expect($document[0].activeElement).toBe(element[0]); + expect($rootScope.testModel.isVisible).toBe(true); + + triggerEvent('mouseleave'); + expect($rootScope.testModel.isVisible).toBe(false); + + // Clean up document.body. + // element.remove(); + })); + + it('should not be visible when the window is refocused', + inject(function($window, $document) { + buildTooltip( + '' + + 'Hello' + + '' + + 'Tooltip' + + '' + + '' + ); + + // Append element to DOM so it can be set as activeElement. + $document[0].body.appendChild(element[0]); + element[0].focus(); + triggerEvent('focus,mousedown'); + expect(document.activeElement).toBe(element[0]); + + triggerEvent('mouseleave'); + + // Simulate tabbing away. + angular.element($window).triggerHandler('blur'); + + // Simulate focus event that occurs when tabbing back to the window. + triggerEvent('focus'); + expect($rootScope.testModel.isVisible).toBe(false); + + // Clean up document.body. + $document[0].body.removeChild(element[0]); + })); }); describe('cleanup', function() { - it('should clean up the scope if the parent was removed from the DOM', function() { - buildTooltip( - '' + - 'Tooltip' + - '' - ); - var tooltip = findTooltip(); - - expect(tooltip.length).toBe(1); - expect(tooltip.scope()).toBeTruthy(); - - element.remove(); - expect(tooltip.scope()).toBeUndefined(); - expect(findTooltip().length).toBe(0); - }); - it('should clean up if the parent scope was destroyed', function() { buildTooltip( '' + - 'Tooltip' + + 'Tooltip' + '' ); var tooltip = findTooltip(); @@ -367,7 +352,7 @@ describe(' directive', function() { it('should remove the tooltip when its own scope is destroyed', function() { buildTooltip( '' + - 'Tooltip' + + 'Tooltip' + '' ); var tooltip = findTooltip(); @@ -377,32 +362,36 @@ describe(' directive', function() { expect(findTooltip().length).toBe(0); }); - it('should remove itself from the $$mdTooltipRegistry when it is destroyed', function() { - buildTooltip( - '' + - 'Tooltip' + - '' - ); - - spyOn($$mdTooltipRegistry, 'deregister'); - findTooltip().scope().$destroy(); - expect($$mdTooltipRegistry.deregister).toHaveBeenCalled(); - }); - - it('should not re-appear if it was outside the DOM when the parent was removed', function() { - buildTooltip( - '' + - 'Tooltip' + - '' - ); - - showTooltip(false); - expect(findTooltip().length).toBe(0); - - element.remove(); - showTooltip(true); - expect(findTooltip().length).toBe(0); - }); + it('should remove itself from the $$mdTooltipRegistry when the parent ' + + 'scope is destroyed', function() { + buildTooltip( + '' + + 'Tooltip' + + '' + ); + + spyOn($$mdTooltipRegistry, 'deregister'); + element.scope().$destroy(); + expect($$mdTooltipRegistry.deregister).toHaveBeenCalled(); + }); + + it('should not re-appear if it was outside the DOM when the parent was ' + + 'removed', function() { + buildTooltip( + '' + + '' + + 'Tooltip' + + '' + + '' + ); + + showTooltip(false); + expect(findTooltip().length).toBe(0); + + element.remove(); + showTooltip(true); + expect(findTooltip().length).toBe(0); + }); it('should unbind the parent listeners when it gets destroyed', function() { buildTooltip( @@ -425,7 +414,6 @@ describe(' directive', function() { // ****************************************************** function buildTooltip(markup) { - element = $compile(markup)($rootScope); $rootScope.testModel = {}; @@ -436,26 +424,31 @@ describe(' directive', function() { } function showTooltip(isVisible) { - if (angular.isUndefined(isVisible)) isVisible = true; - - $rootScope.$apply('testModel.isVisible = ' + (isVisible ? 'true' : 'false') ); + if (angular.isUndefined(isVisible)) { + isVisible = true; + } + $rootScope.testModel.isVisible = !!isVisible; + $rootScope.$apply(); $material.flushOutstandingAnimations(); } function findTooltip() { - return angular.element(document.body).find('md-tooltip'); + return angular.element(document.querySelector('.md-tooltip')); } - function triggerEvent(eventType, skipFlush) { - angular.forEach(eventType.split(','),function(name) { + angular.forEach(eventType.split(','), function(name) { element.triggerHandler(name); }); !skipFlush && $timeout.flush(); } - }); + +// ****************************************************** +// mdTooltipRegistry Testing +// ****************************************************** + describe('$$mdTooltipRegistry service', function() { var tooltipRegistry, ngWindow;