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",