diff --git a/config/build.config.js b/config/build.config.js index b40f931bdf7..c5988350480 100644 --- a/config/build.config.js +++ b/config/build.config.js @@ -94,6 +94,7 @@ module.exports = { 'src/components/tabs/js/*.js', 'src/components/toast/toast.js', 'src/components/toolbar/toolbar.js', + 'src/components/tooltip/tooltip.js', 'src/components/whiteframe/whiteframe.js', 'src/components/divider/divider.js', 'src/components/linearProgress/linearProgress.js', diff --git a/src/components/content/content.js b/src/components/content/content.js index dc5e3ff531f..e130ef495ff 100644 --- a/src/components/content/content.js +++ b/src/components/content/content.js @@ -33,9 +33,13 @@ angular.module('material.components.content', [ function materialContentDirective() { return { restrict: 'E', - controller: angular.noop, + controller: ['$element', ContentController], link: function($scope, $element, $attr) { $scope.$broadcast('$materialContentLoaded', $element); } }; + + function ContentController($element) { + this.$element = $element; + } } diff --git a/src/components/tooltip/README.md b/src/components/tooltip/README.md new file mode 100644 index 00000000000..b2f729403d4 --- /dev/null +++ b/src/components/tooltip/README.md @@ -0,0 +1 @@ +Create a tooltip. diff --git a/src/components/tooltip/_tooltip.scss b/src/components/tooltip/_tooltip.scss new file mode 100644 index 00000000000..377d5af42f4 --- /dev/null +++ b/src/components/tooltip/_tooltip.scss @@ -0,0 +1,101 @@ +@include keyframes(tooltipBackgroundShow) { + 0% { + @include transform(scale(0.2)); + opacity: 0.25; + } + 50% { + opacity: 1; + } + 100% { + @include transform(scale(1.0)); + opacity: 1; + } +} +@include keyframes(tooltipBackgroundHide) { + 0% { opacity: 1; } + 100% { opacity: 0; } +} + +material-tooltip { + position: absolute; + font-size: 14px; + z-index: $z-index-tooltip; + overflow: hidden; + pointer-events: none; + color: white; + border-radius: 4px; + + &[tooltip-direction="bottom"] { + @include transform(translate3d(0, -30%, 0)); + margin-top: 8px; + } + &[tooltip-direction="top"] { + @include transform(translate3d(0, 30%, 0)); + margin-bottom: 8px; + } + + .tooltip-background { + background: rgb(115,115,115); + position: absolute; + left: 50%; + width: 256px; + height: 256px; + margin-left: -128px; + margin-top: -128px; + border-radius: 256px; + + opacity: 0.25; + @include transform(scale(0.2)); + } + + .tooltip-content { + max-width: 240px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + padding: 8px; + background: transparent; + opacity: 0.3; + @include transition(inherit); + } + + &.tooltip-show, + &.tooltip-hide { + @include transition(0.2s ease-out); + transition-property: transform, opacity; + -webkit-transition-property: -webkit-transform, opacity; + } + + &.tooltip-show { + pointer-events: auto; + @include transform(translate3d(0,0,0)); + + .tooltip-background { + @include transform(scale(1.0)); + opacity: 1.0; + @include animation(tooltipBackgroundShow linear); + } + .tooltip-content { + opacity: 0.99; + } + } + &.tooltip-hide .tooltip-background { + @include transform(scale(1.0)); + opacity: 0; + @include animation(tooltipBackgroundHide 0.2s linear); + } + + /** + * Depending on the tooltip's size as a multiple of 32 (set by JS), + * change the background's animation duration. + * The larger the tooltip, the less time the background should take to ripple outwards. + */ + @for $i from 1 through 8 { + &[width-32="#{$i}"].tooltip-show .tooltip-background { + $duration: 1000 - $i * 100; + animation-duration: #{$duration}ms; + -webkit-animation-duration: #{$duration}ms; + } + } +} diff --git a/src/components/tooltip/demo1/index.html b/src/components/tooltip/demo1/index.html new file mode 100644 index 00000000000..9c5aca73e44 --- /dev/null +++ b/src/components/tooltip/demo1/index.html @@ -0,0 +1,32 @@ +
+ + + + + Refresh + + + + + +
+
+
+
+
+ +

+

+ The tooltip is visible when the button is hovered, focused, or touched. +
+
+ Additionally, the tooltip's visibility is bound to the checkbox below. +
+

+ + + Tooltip is shown + + +
+
diff --git a/src/components/tooltip/demo1/script.js b/src/components/tooltip/demo1/script.js new file mode 100644 index 00000000000..1209f1d421d --- /dev/null +++ b/src/components/tooltip/demo1/script.js @@ -0,0 +1,4 @@ +angular.module('tooltipDemo1', ['ngMaterial']) + +.controller('AppCtrl', function($scope) { +}); diff --git a/src/components/tooltip/module.json b/src/components/tooltip/module.json new file mode 100644 index 00000000000..fad57db45f5 --- /dev/null +++ b/src/components/tooltip/module.json @@ -0,0 +1,10 @@ +{ + "module": "material.components.tooltip", + "name": "Tooltip", + "demos": { + "demo1": { + "name": "Tooltip Basic Usage", + "files": ["demo1/*"] + } + } +} diff --git a/src/components/tooltip/tooltip.js b/src/components/tooltip/tooltip.js new file mode 100644 index 00000000000..9a7154ef6e3 --- /dev/null +++ b/src/components/tooltip/tooltip.js @@ -0,0 +1,187 @@ +/** + * @ngdoc module + * @name material.components.tooltip + */ +angular.module('material.components.tooltip', []) + +.directive('materialTooltip', [ + '$timeout', + '$window', + '$$rAF', + '$document', + MaterialTooltipDirective +]); + +/** + * @ngdoc directive + * @name materialTooltip + * @module material.components.tooltip + * @description + * 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. + * + * @usage + * + * + * + * Play Music + * + * + * + * + * @param {expression=} visible Boolean bound to whether the tooltip is + * currently visible. + */ +function MaterialTooltipDirective($timeout, $window, $$rAF, $document) { + + var TOOLTIP_SHOW_DELAY = 400; + var TOOLTIP_WINDOW_EDGE_SPACE = 8; + // We have to append tooltips to the body, because we use + // getBoundingClientRect(). + // to find where to append the tooltip. + var tooltipParent = angular.element(document.body); + + return { + restrict: 'E', + transclude: true, + require: '^?materialContent', + template: + '
' + + '
', + scope: { + visible: '=?' + }, + link: postLink + }; + + function postLink(scope, element, attr, contentCtrl) { + var parent = element.parent(); + + // We will re-attach tooltip when visible + element.detach(); + element.attr('role', 'tooltip'); + element.attr('id', attr.id || Util.nextUid()); + + parent.on('focus mouseenter touchstart', function() { + setVisible(true); + }); + parent.on('blur mouseleave touchend touchcancel', function() { + // Don't hide the tooltip if the parent is still focused. + if (document.activeElement === parent[0]) return; + setVisible(false); + }); + + scope.$watch('visible', function(isVisible) { + if (isVisible) showTooltip(); + else hideTooltip(); + }); + + var debouncedOnResize = $$rAF.debounce(onWindowResize); + angular.element($window).on('resize', debouncedOnResize); + function onWindowResize() { + // Reposition on resize + if (scope.visible) positionTooltip(); + } + + // Be sure to completely cleanup the element on destroy + scope.$on('$destroy', function() { + scope.visible = false; + element.remove(); + angular.element($window).off('resize', debouncedOnResize); + }); + + // ******* + // Methods + // ******* + + // If setting visible to true, debounce to TOOLTIP_SHOW_DELAY ms + // If setting visible to false and no timeout is active, instantly hide the tooltip. + function setVisible(value) { + setVisible.value = !!value; + + if (!setVisible.queued) { + if (value) { + setVisible.queued = true; + $timeout(function() { + scope.visible = setVisible.value; + setVisible.queued = false; + }, TOOLTIP_SHOW_DELAY); + + } else { + $timeout(function() { scope.visible = false; }); + } + } + } + + function showTooltip() { + // Insert the element before positioning it, so we can get position + // (tooltip is hidden by default) + element.removeClass('tooltip-hide'); + parent.attr('aria-describedby', element.attr('id')); + tooltipParent.append(element); + + // Wait until the element has been in the dom for two frames before + // fading it in. + // Additionally, we position the tooltip twice to avoid positioning bugs + //positionTooltip(); + $$rAF(function() { + + $$rAF(function() { + positionTooltip(); + if (!scope.visible) return; + element.addClass('tooltip-show'); + }); + + }); + } + + function hideTooltip() { + element.removeClass('tooltip-show').addClass('tooltip-hide'); + parent.removeAttr('aria-describedby'); + $timeout(function() { + if (scope.visible) return; + element.detach(); + }, 200, false); + } + + function positionTooltip(rerun) { + var tipRect = element[0].getBoundingClientRect(); + var parentRect = parent[0].getBoundingClientRect(); + + if (contentCtrl) { + parentRect.top += contentCtrl.$element.prop('scrollTop'); + parentRect.left += contentCtrl.$element.prop('scrollLeft'); + } + + // Default to bottom position if possible + var tipDirection = 'bottom'; + var newPosition = { + left: parentRect.left + parentRect.width / 2 - tipRect.width / 2, + top: parentRect.top + parentRect.height + }; + + // If element bleeds over left/right of the window, place it on the edge of the window. + newPosition.left = Math.min( + newPosition.left, + $window.innerWidth - tipRect.width - TOOLTIP_WINDOW_EDGE_SPACE + ); + newPosition.left = Math.max(newPosition.left, TOOLTIP_WINDOW_EDGE_SPACE); + + // If element bleeds over the bottom of the window, place it above the parent. + if (newPosition.top + tipRect.height > $window.innerHeight) { + newPosition.top = parentRect.top - tipRect.height; + tipDirection = 'top'; + } + + element.css({top: newPosition.top + 'px', left: newPosition.left + 'px'}); + // Tell the CSS the size of this tooltip, as a multiple of 32. + element.attr('width-32', Math.ceil(tipRect.width / 32)); + element.attr('tooltip-direction', tipDirection); + } + + } + +} diff --git a/src/components/tooltip/tooltip.spec.js b/src/components/tooltip/tooltip.spec.js new file mode 100644 index 00000000000..4e2f60ab670 --- /dev/null +++ b/src/components/tooltip/tooltip.spec.js @@ -0,0 +1,108 @@ +describe(' directive', function() { + + beforeEach(module('material.components.tooltip', function($provide) { + $provide.value('$$rAF', mockRaf); + + // fake synchronous version of rAF + function mockRaf(cb) { cb(); } + mockRaf.debounce = function(cb) { + var context = this, args = arguments; + return function() { + cb.apply(context, args); + }; + }; + findTooltip().remove(); + })); + + function findTooltip() { + return angular.element(document.body).find('material-tooltip'); + } + + it('should show and hide when visible is set', inject(function($compile, $rootScope, $timeout) { + var element = $compile('' + + 'Hello' + + 'Tooltip' + + '')($rootScope); + + $rootScope.$apply(); + expect(findTooltip().length).toBe(0); + + $rootScope.$apply('isVisible = true'); + expect(findTooltip().length).toBe(1); + expect(findTooltip().hasClass('tooltip-show')).toBe(true); + expect(findTooltip().hasClass('tooltip-hide')).toBe(false); + + $rootScope.$apply('isVisible = false'); + expect(findTooltip().hasClass('tooltip-hide')).toBe(true); + expect(findTooltip().hasClass('tooltip-show')).toBe(false); + $timeout.flush(); + expect(findTooltip().length).toBe(0); + })); + + it('should describe parent', inject(function($compile, $rootScope, $timeout) { + var element = $compile('' + + 'Hello' + + 'Tooltip' + + '')($rootScope); + + $rootScope.$apply('isVisible = true'); + + expect(element.attr('aria-describedby')).toEqual(findTooltip().attr('id')); + + $rootScope.$apply('isVisible = false'); + expect(element.attr('aria-describedby')).toBeFalsy(); + + })); + + it('should set visible on mouseenter and mouseleave', inject(function($compile, $rootScope, $timeout) { + var element = $compile('' + + 'Hello' + + 'Tooltip' + + '')($rootScope); + + $rootScope.$apply(); + + element.triggerHandler('mouseenter'); + $timeout.flush(); + expect($rootScope.isVisible).toBe(true); + + element.triggerHandler('mouseleave'); + $timeout.flush(); + expect($rootScope.isVisible).toBe(false); + })); + + it('should set visible on focus and blur', inject(function($compile, $rootScope, $timeout) { + var element = $compile('' + + 'Hello' + + 'Tooltip' + + '')($rootScope); + + $rootScope.$apply(); + + element.triggerHandler('focus'); + $timeout.flush(); + expect($rootScope.isVisible).toBe(true); + + element.triggerHandler('blur'); + $timeout.flush(); + expect($rootScope.isVisible).toBe(false); + })); + + it('should set visible on touchstart and touchend', inject(function($compile, $rootScope, $timeout) { + var element = $compile('' + + 'Hello' + + 'Tooltip' + + '')($rootScope); + + $rootScope.$apply(); + + element.triggerHandler('touchstart'); + $timeout.flush(); + expect($rootScope.isVisible).toBe(true); + + element.triggerHandler('touchend'); + $timeout.flush(); + expect($rootScope.isVisible).toBe(false); + })); + +}); diff --git a/src/core/style/theme/_variables.scss b/src/core/style/theme/_variables.scss index 4606d653827..c2b93c2a052 100644 --- a/src/core/style/theme/_variables.scss +++ b/src/core/style/theme/_variables.scss @@ -218,4 +218,5 @@ $z-index-dialog: 10; $z-index-toast: 9; $z-index-sidenav: 8; $z-index-backdrop: 7; +$z-index-tooltip: 6; $z-index-fab: 2; diff --git a/src/main.scss b/src/main.scss index a46b8238657..6e4477e744c 100644 --- a/src/main.scss +++ b/src/main.scss @@ -26,6 +26,7 @@ "components/textField/textField", "components/toast/toast", "components/toolbar/toolbar", +"components/tooltip/tooltip", "components/tabs/tabs", "components/list/list", "components/divider/divider",