wikimedia/mediawiki-extensions-Wikibase

View on GitHub
client/resources/jquery.wikibase/jquery.wikibase.wbtooltip.js

Summary

Maintainability
F
1 wk
Test Coverage
/**
 * @license GPL-2.0-or-later
 * @author H. Snater < mediawiki@snater.com >
 */
( function () {

    'use strict';

    var PARENT = $.Widget;

    require( './jquery.tipsy/jquery.tipsy.js' );

    /**
     * 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 () {
            var buildErrorOutput = require( '../wikibase.buildErrorOutput.js' );
            return 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 );
        }

    } );

}() );