From 417b01f11ea3438b4a43ac229c03c7f38e1ff280 Mon Sep 17 00:00:00 2001 From: Josh David Miller Date: Fri, 26 Apr 2013 17:04:17 -0700 Subject: [PATCH] feat($tooltip): support for custom triggers The `$tooltip` service now has two ways to customize the default triggers. The `$tooltipProvider` takes a `trigger` option and the `*-trigger` attribute can be applied to a single element. The `$tooltipProvider`'s `trigger` option overwrites the default value but the attribute will overwrite both. A few logical default triggers are supported out of the box and have an associated map to determine which hide trigger to use. `mouseenter` -> `mouseleave`, `focus` -> `blur`, and `click` -> `click`. If any other trigger is provided, it will be used to both show and hide the tooltip. Custom hide triggers are not yet supported as they would require some code trickery due to the way `$observe` works. Closes #131 --- src/popover/docs/demo.html | 7 ++ src/popover/docs/readme.md | 2 + src/popover/test/popoverSpec.js | 86 ------------------ src/tooltip/docs/demo.html | 7 ++ src/tooltip/docs/readme.md | 13 +++ src/tooltip/test/tooltip.spec.js | 113 +++++++++++++++++++++--- src/tooltip/tooltip.js | 145 +++++++++++++++++++++---------- 7 files changed, 228 insertions(+), 145 deletions(-) diff --git a/src/popover/docs/demo.html b/src/popover/docs/demo.html index d8bbf5b10c..2a9bdfb1b4 100644 --- a/src/popover/docs/demo.html +++ b/src/popover/docs/demo.html @@ -14,6 +14,13 @@

Positional

+
+

Triggers

+ + +

Other

diff --git a/src/popover/docs/readme.md b/src/popover/docs/readme.md index 8b43c66c62..e94651ccb3 100644 --- a/src/popover/docs/readme.md +++ b/src/popover/docs/readme.md @@ -13,6 +13,8 @@ will display: - `popover-animation`: Should it fade in and out? Defaults to "true". - `popover-popup-delay`: For how long should the user have to have the mouse over the element before the popover shows (in milliseconds)? Defaults to 0. +- `popover-trigger`: What should trigger the show of the popover? See the + `tooltip` directive for supported values. The popover directives require the `$position` service. diff --git a/src/popover/test/popoverSpec.js b/src/popover/test/popoverSpec.js index 81e37d4734..b7c5b34ad7 100644 --- a/src/popover/test/popoverSpec.js +++ b/src/popover/test/popoverSpec.js @@ -44,92 +44,6 @@ describe('popover', function() { elm.trigger( 'click' ); expect( elmScope.tt_isOpen ).toBe( false ); })); - - it('should have default placement of "top"', inject(function() { - elm.trigger( 'click' ); - expect( elmScope.tt_placement ).toBe( "top" ); - })); - - it('should allow specification of placement', inject( function( $compile ) { - elm = $compile( angular.element( - 'Selector Text' - ) )( scope ); - elmScope = elm.scope(); - - elm.trigger( 'click' ); - expect( elmScope.tt_placement ).toBe( "bottom" ); - })); - - it('should work inside an ngRepeat', inject( function( $compile ) { - - elm = $compile( angular.element( - '' - ) )( scope ); - - scope.items = [ - { name: "One", popover: "First popover" } - ]; - - scope.$digest(); - - var tt = angular.element( elm.find("li > span")[0] ); - - tt.trigger( 'click' ); - - expect( tt.text() ).toBe( scope.items[0].name ); - expect( tt.scope().tt_content ).toBe( scope.items[0].popover ); - - tt.trigger( 'click' ); - })); - - it('should only have an isolate scope on the popup', inject( function ( $compile ) { - var ttScope; - - scope.popoverContent = "Popover Content"; - scope.popoverTitle = "Popover Title"; - scope.alt = "Alt Message"; - - elmBody = $compile( angular.element( - '
Selector Text
' - ) )( scope ); - - $compile( elmBody )( scope ); - scope.$digest(); - elm = elmBody.find( 'span' ); - elmScope = elm.scope(); - - elm.trigger( 'click' ); - expect( elm.attr( 'alt' ) ).toBe( scope.alt ); - - ttScope = angular.element( elmBody.children()[1] ).scope(); - expect( ttScope.placement ).toBe( 'top' ); - expect( ttScope.title ).toBe( scope.popoverTitle ); - expect( ttScope.content ).toBe( scope.popoverContent ); - - elm.trigger( 'click' ); - })); - - - it( 'should allow specification of delay', inject( function ($timeout, $compile) { - - elm = $compile( angular.element( - 'Selector Text' - ) )( scope ); - elmScope = elm.scope(); - scope.$digest(); - - elm.trigger( 'click' ); - expect( elmScope.tt_isOpen ).toBe( false ); - - $timeout.flush(); - expect( elmScope.tt_isOpen ).toBe( true ); - - } ) ); - }); diff --git a/src/tooltip/docs/demo.html b/src/tooltip/docs/demo.html index 48047c559c..40c8a3c110 100644 --- a/src/tooltip/docs/demo.html +++ b/src/tooltip/docs/demo.html @@ -20,5 +20,12 @@

I can even contain HTML. Check me out!

+

+ Or use custom triggers, like focus: + +

diff --git a/src/tooltip/docs/readme.md b/src/tooltip/docs/readme.md index ca8f1b4a73..5ecb79563d 100644 --- a/src/tooltip/docs/readme.md +++ b/src/tooltip/docs/readme.md @@ -15,6 +15,19 @@ will display: - `tooltip-animation`: Should it fade in and out? Defaults to "true". - `tooltip-popup-delay`: For how long should the user have to have the mouse over the element before the tooltip shows (in milliseconds)? Defaults to 0. +- `tooltip-trigger`: What should trigger a show of the tooltip? The tooltip directives require the `$position` service. +**Triggers** + +The following show triggers are supported out of the box, along with their +provided hide triggers: + +- `mouseenter`: `mouseleave` +- `click`: `click` +- `focus`: `blur` + +For any non-supported value, the trigger will be used to both show and hide the +tooltip. + diff --git a/src/tooltip/test/tooltip.spec.js b/src/tooltip/test/tooltip.spec.js index fcda8b6997..1bc4e2e69b 100644 --- a/src/tooltip/test/tooltip.spec.js +++ b/src/tooltip/test/tooltip.spec.js @@ -54,6 +54,7 @@ describe('tooltip', function() { elm = $compile( angular.element( 'Selector Text' ) )( scope ); + scope.$apply(); elmScope = elm.scope(); elm.trigger( 'mouseenter' ); @@ -161,6 +162,46 @@ describe('tooltip', function() { }); + describe( 'with a trigger attribute', function() { + var scope, elmBody, elm, elmScope; + + beforeEach( inject( function( $rootScope ) { + scope = $rootScope; + })); + + it( 'should use it to show but set the hide trigger based on the map for mapped triggers', inject( function( $compile ) { + elmBody = angular.element( + '
' + ); + $compile(elmBody)(scope); + scope.$apply(); + elm = elmBody.find('input'); + elmScope = elm.scope(); + + expect( elmScope.tt_isOpen ).toBeFalsy(); + elm.trigger('focus'); + expect( elmScope.tt_isOpen ).toBeTruthy(); + elm.trigger('blur'); + expect( elmScope.tt_isOpen ).toBeFalsy(); + })); + + it( 'should use it as both the show and hide triggers for unmapped triggers', inject( function( $compile ) { + elmBody = angular.element( + '
' + ); + $compile(elmBody)(scope); + scope.$apply(); + elm = elmBody.find('input'); + elmScope = elm.scope(); + + expect( elmScope.tt_isOpen ).toBeFalsy(); + elm.trigger('fakeTriggerAttr'); + expect( elmScope.tt_isOpen ).toBeTruthy(); + elm.trigger('fakeTriggerAttr'); + expect( elmScope.tt_isOpen ).toBeFalsy(); + })); + }); + }); describe( 'tooltipHtmlUnsafe', function() { @@ -202,13 +243,13 @@ describe( 'tooltipHtmlUnsafe', function() { }); describe( '$tooltipProvider', function() { - - describe( 'popupDelay', function() { - var elm, + var elm, elmBody, - scope, - elmScope; + scope, + elmScope, + body; + describe( 'popupDelay', function() { beforeEach(module('ui.bootstrap.tooltip', function($tooltipProvider){ $tooltipProvider.options({popupDelay: 1000}); })); @@ -241,12 +282,6 @@ describe( '$tooltipProvider', function() { }); describe('appendToBody', function() { - var elm, - elmBody, - scope, - elmScope, - body; - // load the tooltip code beforeEach(module('ui.bootstrap.tooltip', function ( $tooltipProvider ) { $tooltipProvider.options({ appendToBody: true }); @@ -275,5 +310,61 @@ describe( '$tooltipProvider', function() { expect( $body.children().length ).toEqual( bodyLength + 1 ); })); }); + + describe( 'triggers', function() { + describe( 'triggers with a mapped value', function() { + beforeEach(module('ui.bootstrap.tooltip', function($tooltipProvider){ + $tooltipProvider.options({trigger: 'focus'}); + })); + + // load the template + beforeEach(module('template/tooltip/tooltip-popup.html')); + + it( 'should use the show trigger and the mapped value for the hide trigger', inject( function ( $rootScope, $compile ) { + elmBody = angular.element( + '
' + ); + + scope = $rootScope; + $compile(elmBody)(scope); + scope.$digest(); + elm = elmBody.find('input'); + elmScope = elm.scope(); + + expect( elmScope.tt_isOpen ).toBeFalsy(); + elm.trigger('focus'); + expect( elmScope.tt_isOpen ).toBeTruthy(); + elm.trigger('blur'); + expect( elmScope.tt_isOpen ).toBeFalsy(); + })); + }); + + describe( 'triggers without a mapped value', function() { + beforeEach(module('ui.bootstrap.tooltip', function($tooltipProvider){ + $tooltipProvider.options({trigger: 'fakeTrigger'}); + })); + + // load the template + beforeEach(module('template/tooltip/tooltip-popup.html')); + + it( 'should use the show trigger to hide', inject( function ( $rootScope, $compile ) { + elmBody = angular.element( + '
Selector Text
' + ); + + scope = $rootScope; + $compile(elmBody)(scope); + scope.$digest(); + elm = elmBody.find('span'); + elmScope = elm.scope(); + + expect( elmScope.tt_isOpen ).toBeFalsy(); + elm.trigger('fakeTrigger'); + expect( elmScope.tt_isOpen ).toBeTruthy(); + elm.trigger('fakeTrigger'); + expect( elmScope.tt_isOpen ).toBeFalsy(); + })); + }); + }); }); diff --git a/src/tooltip/tooltip.js b/src/tooltip/tooltip.js index c72484ce71..281d532a4a 100644 --- a/src/tooltip/tooltip.js +++ b/src/tooltip/tooltip.js @@ -17,6 +17,13 @@ angular.module( 'ui.bootstrap.tooltip', [ 'ui.bootstrap.position' ] ) popupDelay: 0 }; + // Default hide triggers for each show trigger + var triggerMap = { + 'mouseenter': 'mouseleave', + 'click': 'click', + 'focus': 'blur' + }; + // The options specified to the provider globally. var globalOptions = {}; @@ -49,9 +56,41 @@ angular.module( 'ui.bootstrap.tooltip', [ 'ui.bootstrap.position' ] ) * TODO support multiple triggers */ this.$get = [ '$window', '$compile', '$timeout', '$parse', '$document', '$position', function ( $window, $compile, $timeout, $parse, $document, $position ) { - return function $tooltip ( type, prefix, defaultTriggerShow, defaultTriggerHide ) { + return function $tooltip ( type, prefix, defaultTriggerShow ) { var options = angular.extend( {}, defaultOptions, globalOptions ); + + /** + * Returns an object of show and hide triggers. + * + * If a trigger is supplied, + * it is used to show the tooltip; otherwise, it will use the `trigger` + * option passed to the `$tooltipProvider.options` method; else it will + * default to the trigger supplied to this directive factory. + * + * The hide trigger is based on the show trigger. If the `trigger` option + * was passed to the `$tooltipProvider.options` method, it will use the + * mapped trigger from `triggerMap` or the passed trigger if the map is + * undefined; otherwise, it uses the `triggerMap` value of the show + * trigger; else it will just use the show trigger. + */ + function setTriggers ( trigger ) { + var show, hide; + + show = trigger || options.trigger || defaultTriggerShow; + if ( angular.isDefined ( options.trigger ) ) { + hide = triggerMap[options.trigger] || show; + } else { + hide = triggerMap[show] || show; + } + + return { + show: show, + hide: hide + }; + } + var directiveName = snake_case( type ); + var triggers = setTriggers( undefined ); var template = '<'+ directiveName +'-popup '+ @@ -72,39 +111,32 @@ angular.module( 'ui.bootstrap.tooltip', [ 'ui.bootstrap.position' ] ) var popupTimeout; var $body; - attrs.$observe( type, function ( val ) { - scope.tt_content = val; - }); - - attrs.$observe( prefix+'Title', function ( val ) { - scope.tt_title = val; - }); - - attrs.$observe( prefix+'Placement', function ( val ) { - scope.tt_placement = angular.isDefined( val ) ? val : options.placement; - }); - - attrs.$observe( prefix+'Animation', function ( val ) { - scope.tt_animation = angular.isDefined( val ) ? $parse( val ) : function(){ return options.animation; }; - }); - - attrs.$observe( prefix+'PopupDelay', function ( val ) { - var delay = parseInt( val, 10 ); - scope.tt_popupDelay = ! isNaN(delay) ? delay : options.popupDelay; - }); - // By default, the tooltip is not open. // TODO add ability to start tooltip opened scope.tt_isOpen = false; - //show the tooltip with delay if specified, otherwise show it immediately - function showWithDelay() { - if( scope.tt_popupDelay ){ + function toggleTooltipBind () { + if ( ! scope.tt_isOpen ) { + showTooltipBind(); + } else { + hideTooltipBind(); + } + } + + // Show the tooltip with delay if specified, otherwise show it immediately + function showTooltipBind() { + if ( scope.tt_popupDelay ) { popupTimeout = $timeout( show, scope.tt_popupDelay ); - }else { + } else { scope.$apply( show ); } } + + function hideTooltipBind () { + scope.$apply(function () { + hide(); + }); + } // Show the tooltip popup element. function show() { @@ -182,7 +214,6 @@ angular.module( 'ui.bootstrap.tooltip', [ 'ui.bootstrap.position' ] ) // Hide the tooltip popup element. function hide() { // First things first: we don't show it anymore. - //tooltip.removeClass( 'in' ); scope.tt_isOpen = false; //if tooltip is going to be shown after delay, we must cancel this @@ -198,25 +229,43 @@ angular.module( 'ui.bootstrap.tooltip', [ 'ui.bootstrap.position' ] ) } } - // Register the event listeners. If only one event listener was - // supplied, we use the same event listener for showing and hiding. - // TODO add ability to customize event triggers - if ( ! angular.isDefined( defaultTriggerHide ) ) { - element.bind( defaultTriggerShow, function toggleTooltipBind () { - if ( ! scope.tt_isOpen ) { - showWithDelay(); - } else { - scope.$apply( hide ); - } - }); - } else { - element.bind( defaultTriggerShow, function showTooltipBind() { - showWithDelay(); - }); - element.bind( defaultTriggerHide, function hideTooltipBind() { - scope.$apply( hide ); - }); - } + /** + * Observe the relevant attributes. + */ + attrs.$observe( type, function ( val ) { + scope.tt_content = val; + }); + + attrs.$observe( prefix+'Title', function ( val ) { + scope.tt_title = val; + }); + + attrs.$observe( prefix+'Placement', function ( val ) { + scope.tt_placement = angular.isDefined( val ) ? val : options.placement; + }); + + attrs.$observe( prefix+'Animation', function ( val ) { + scope.tt_animation = angular.isDefined( val ) ? $parse( val ) : function(){ return options.animation; }; + }); + + attrs.$observe( prefix+'PopupDelay', function ( val ) { + var delay = parseInt( val, 10 ); + scope.tt_popupDelay = ! isNaN(delay) ? delay : options.popupDelay; + }); + + attrs.$observe( prefix+'Trigger', function ( val ) { + element.unbind( triggers.show ); + element.unbind( triggers.hide ); + + triggers = setTriggers( val ); + + if ( triggers.show === triggers.hide ) { + element.bind( triggers.show, toggleTooltipBind ); + } else { + element.bind( triggers.show, showTooltipBind ); + element.bind( triggers.hide, hideTooltipBind ); + } + }); } }; }; @@ -233,7 +282,7 @@ angular.module( 'ui.bootstrap.tooltip', [ 'ui.bootstrap.position' ] ) }) .directive( 'tooltip', [ '$tooltip', function ( $tooltip ) { - return $tooltip( 'tooltip', 'tooltip', 'mouseenter', 'mouseleave' ); + return $tooltip( 'tooltip', 'tooltip', 'mouseenter' ); }]) .directive( 'tooltipHtmlUnsafePopup', function () { @@ -246,7 +295,7 @@ angular.module( 'ui.bootstrap.tooltip', [ 'ui.bootstrap.position' ] ) }) .directive( 'tooltipHtmlUnsafe', [ '$tooltip', function ( $tooltip ) { - return $tooltip( 'tooltipHtmlUnsafe', 'tooltip', 'mouseenter', 'mouseleave' ); + return $tooltip( 'tooltipHtmlUnsafe', 'tooltip', 'mouseenter' ); }]) ;