view/resources/jquery/wikibase/jquery.wikibase.wbtooltip.js
/**
* @license GPL-2.0-or-later
* @author H. Snater < mediawiki@snater.com >
*/
( function ( wb ) {
'use strict';
var PARENT = $.Widget;
/**
* Tooltip widget enhancing jQuery.tipsy.
*
* @option content {string|jQuery|Error} The tooltip balloon's content.
* (REQUIRED)
*
* @option permanent {boolean} Whether the tooltip shall be visible permanently (only closing with a
* click outside of it) or when hovering.
* Default: false
*
* @option gravity {string} Two-letter string consisting out of a combination of the letter 'n', 's'
* and 'e', 'w'. This specifies the direction the tooltip balloon will be anchored to. The
* direction is to be specified assuming an left-to-right context and is mirrored when
* detecting an RTL context.
* Default: 'nw'
*
* @option $anchor {jQuery} An anchor where the tooltip's tip shall point on. By default (null), it
* is the node the tooltip widget is initialized on.
* Default: null
*
* @event afterhide: Triggered after the tooltip has been hidden.
* (1) jQuery.Event
*/
$.widget( 'wikibase.wbtooltip', PARENT, {
/**
* Options.
*
* @type {Object}
*/
options: {
content: null,
permanent: false,
gravity: 'nw',
$anchor: null
},
/**
* Tipsy tooltip plugin object.
*
* @type {Object}
*/
_tipsy: null,
/**
* @see jQuery.Widget._create
* @throws {Error} when no content has been specified in the options.
*/
_create: function () {
var self = this;
PARENT.prototype._create.call( this );
if ( typeof this.options.content === 'string' ) {
// Tipsy, in general, makes use of the "title" attribute. Therefore, when setting a
// plain string as content, just assign it to the "title" attribute:
this.element.attr( 'title', this.options.content );
} else if ( this.options.content instanceof $ ) {
// with .html() compare html: true in .tipsy() options below
this.element.attr( 'title', this.options.content.html() );
} else {
// We have no content, yet. This happens when instantiated for an API error code.
// Init Tipsy with some placeholder since the tooltip message would not show without the
// title attribute being set.
this.element.attr( 'title', '.' );
}
// Flip horizontal gravity when in RTL context:
var gravity = this._evaluateGravity( this.options.gravity );
// Init tipsy:
if ( !this.element.data( 'tipsy' ) ) {
this.element.tipsy( {
gravity: gravity,
trigger: 'manual', // Prevent Tipsy's native hover handling.
html: true
} );
} else {
// If Tipsy is initialised already, just overwrite the gravity:
this.element.data( 'tipsy' ).options.gravity = gravity;
}
this._tipsy = this.element.data( 'tipsy' );
this.element.addClass( this.widgetFullName );
if ( !this.options.permanent ) {
this.element
.off( '.' + this.widgetName )
.on( 'mouseenter.' + this.widgetName, function ( event ) {
self.show();
} )
.on( 'mouseleave.' + this.widgetName, function ( event ) {
self.hide();
} );
}
// Reposition tooltip when resizing the browser window:
$( window )
.off( '.' + this.widgetName ) // Never need that event more than once.
.on( 'resize.' + this.widgetName, function ( event ) {
$( ':' + self.widgetFullName ).each( function ( i, node ) {
var tooltip = $( node ).data( self.widgetName );
if ( tooltip
&& tooltip._tipsy.$tip
// eslint-disable-next-line no-jquery/no-sizzle
&& tooltip._tipsy.$tip.is( ':visible' )
&& tooltip.option( 'permanent' )
) {
tooltip._tipsy.$tip.hide();
// Trigger show() to reposition:
// TODO: Implement option to show tooltip without a fade animation to prevent
// flickering.
tooltip.show( tooltip._permanent );
}
} );
} );
},
/**
* @see jQuery.Widget.destroy
*/
destroy: function () {
this._tipsy.tip().remove();
this.element.off( 'mouseenter.' + this.widgetName + ' mouseleave.' + this.widgetName );
this.element.removeData( 'tipsy' );
this._tipsy = null;
// Detach window event handler if no widget instances are left:
if ( $( ':' + this.widgetFullName ).length === 0 ) {
$( window ).off( '.' + this.widgetName );
}
PARENT.prototype.destroy.apply( this, arguments );
},
/**
* Hides the tooltip balloon and destroys the tooltip object afterwards.
*
* @param {boolean} [remove] Whether to remove the tooltip's node from the DOM.
*/
degrade: function ( remove ) {
var self = this;
this.element.one( 'wbtooltipafterhide', function ( event ) {
self.destroy();
if ( remove ) {
self.element.remove();
}
} );
this.hide();
},
/**
* Evaluates a given gravity string according to the language direction flipping the horizontal
* gravity in RTL context.
*
* @param {string} gravity
*/
_evaluateGravity: function ( gravity ) {
if ( document.documentElement.dir === 'rtl' ) {
if ( gravity.search( /e/ ) !== -1 ) {
gravity = gravity.replace( /e/g, 'w' );
} else {
gravity = gravity.replace( /w/g, 'e' );
}
}
return gravity;
},
/**
* Shows the tooltip balloon.
*/
show: function () {
var self = this;
// eslint-disable-next-line no-jquery/no-sizzle
if ( this._tipsy.$tip && this._tipsy.$tip.is( ':visible' ) ) {
return;
}
// The native Tipsy tooltip does not allow jQuery nodes to be set as content and when
// triggering Tipsy's show() method, the $tip is removed from the DOM while the $tips
// position is also set within the show() method. To work around that, we trigger showing
// the tooltip before filling it with content and cache the initial position.
// TODO: This is not the most elegant solution since the $tip might reach out of the
// viewport.
var content = null;
if ( this.options.content instanceof $ ) {
content = this.options.content;
} else if ( this.options.content.code ) {
// Content is an error object.
this._tipsy.tip().addClass( 'wb-error' );
// If not re-constructed on showing, click event on inner element (e.g. Details link)
// will be lost.
content = this._buildErrorTooltip();
}
// If a tooltip anchor is specified, use that for positioning the tip by overwriting the
// element referenced by Tipsy. In order for Tipsy's show() method to not abort, the anchor
// node needs to have the "title" attribute set.
if ( this.options.$anchor ) {
this._tipsy.$element = this.options.$anchor;
if ( !this._tipsy.$element.attr( 'title' ) ) {
this._tipsy.$element.attr( 'title', content.html() );
}
}
this._tipsy.show();
this._tipsy.$tip.addClass( this.widgetFullName + '-tip' );
var offset = this._tipsy.$tip.offset(),
height = this._tipsy.$tip.height(),
width = this._tipsy.$tip.width();
if ( this.options.permanent ) {
// Hide error tooltip when clicking outside of it by suppressing clicks on the $tip from
// bubbling:
this._tipsy.tip().on( 'mousedown.' + this.widgetName, function ( event ) {
event.stopPropagation();
} );
$( window ).one( 'mousedown.' + this.widgetName, function ( event ) {
// Tipsy might be destroyed already.
if ( self._tipsy ) {
self.hide();
}
} );
}
if ( typeof this.options.content !== 'string' ) {
this._tipsy.tip().find( '.tipsy-inner' ).empty().append( content );
}
// Reposition $tip since Tipsy evaluated the position before we filled it with DOM content:
if ( this._tipsy.options.gravity.charAt( 0 ) === 's' ) {
this._tipsy.$tip.offset(
{
top: offset.top - this._tipsy.$tip.height() + height,
left: offset.left - this._tipsy.$tip.width() + width
}
);
}
},
/**
* Hides the tooltip balloon.
*
* @triggers afterhide
*/
hide: function () {
// eslint-disable-next-line no-jquery/no-sizzle
if ( !this._tipsy || !this._tipsy.$tip || !this._tipsy.$tip.is( ':visible' ) ) {
return;
}
this._tipsy.tip().off( '.' + this.widgetName );
this._tipsy.hide();
// TODO: Implement afterHide properly to be called within some callback of tipsy.hide() or
// (probably) overwrite tipsy's hide().
this._trigger( 'afterhide' );
},
/**
* Constructs the DOM structure displayed within an error tooltip.
*
* @return {jQuery}
*
* @TODO: Error tooltip should be a separate tooltip derivative.
*/
_buildErrorTooltip: function () {
return wb.buildErrorOutput( this.options.content )
.addClass( this.widgetFullName + '-error' );
},
/**
* @see jQuery.Widget.option
*/
option: function ( key, value ) {
if ( key === 'gravity' ) {
// Consider language direction:
value = this._evaluateGravity( value );
}
return PARENT.prototype.option.call( this, key, value );
}
} );
}( wikibase ) );