Tuesday, March 26, 2013

jQuery UI: Tooltips For Selected Text

The jQuery UI tooltip widget can be used to display contextual information about some text that a user has selected. Typically, the tooltip is used to display little tidbits about widgets or data fields in the UI — what does this do, or what does this mean? By default, the tooltip is displayed when the user hovers the mouse pointer over the element in question. But we don't want that behavior in a paragraph of text. Instead, we want the user to highlight the word or phrase they're unsure about. It is then that we lookup in our knowledge base something useful to tell the user, displayed through the tooltip widget.

The best way to approach the problem of displaying contextual information for selected text inside a paragraph is to build a new widget. It has a simple functional requirement — when the user selects some text, check if the selected text has a tip associated with it. We'll need a way to pass in this term-to-tip data map as well. And that's it. All we have to do now is create the widget that handles the text selection and deselection events. We'll call it tips, and this is what it looks like in action.


My simple one line paragraph has two tips, one for Red Wings, and one for Toronto. They're only displayed when the text is selected. Here is what the tips widget definition looks like, and the code to instantiate it.

( function( $, undefined ) {

$.widget( "ab.tips", {

    options: {
        terms: []
    },

    ttPos: $.ui.tooltip.prototype.options.position,

    _create: function() {

        this._super();

        this._on({
            mouseup: this._tip,
            mouseenter: this._tip
        });

    },

    _destroy: function() {
        this._super();
        this._destroyTooltip();
    },

    _tip: function( e ) {

        var text = this._selectedText(),
            term = this._selectedTerm( text );

        if ( text === undefined || term === undefined ) {
            this._destroyTooltip();
            return;
        }

        if ( this.element.attr( "title" ) !== term.tip ) {
            this._destroyTooltip();
        }

        this._createTooltip( e, term );

    },

    _selectedText: function() {

        var selection, range, fragement;

        selection = window.getSelection();

        if ( selection.type !== "Range" ) {
            return;
        }

        range = selection.getRangeAt( 0 ),
        fragment = $( range.cloneContents() );

        return $.trim( fragment.text().toLowerCase() );

    },

    _selectedTerm: function( text ) {

        function isTerm( v ) {
            if ( v.term === text ) {
                return v;
            }
        }

        return $.map( this.options.terms, isTerm )[ 0 ];

    },

    _createTooltip: function( e, term ) {

        if ( this.element.is( ":ui-tooltip" ) ) {
            return;
        }

        var pos = $.extend( this.ttPos, { of: e } );

        this.element.attr( "title", term.tip )
                    .tooltip( { position: pos } )
                    .tooltip( "open" );
        
    },

    _destroyTooltip: function() {

        if ( !this.element.is( ":ui-tooltip" ) ) {
            return;
        }

        this.element.tooltip( "destroy" )
                    .attr( "title", "" );

    }

});

})( jQuery );

$(function() {

    $( "p" ).tips({
        terms: [
            {
                term: "red wings",
                tip: "Probably the best NHL team"
            },
            {
                term: "toronto",
                tip: "A hockey town in Ontario"
            }
        ]    
    });

});

As you can see, there is a little flexibility in how we apply tips throughout the UI. We could use a single tips instance as we are here, that applies to all paragraphs. This works because the UI is simple — there aren't a lot of paragraphs and there are only two terms defined. Or, you could define tips widgets on a per-element basis, passing different term data. This latter approach is only relevant when there is a lot of text, and lots of terms.

The widget itself defines a single option — terms. This is the array of tip objects that has the term and the tip text that we look up when the user makes a selection. The ttPos attribute holds the default tooltip position configuration - we extend this later with the position of the mouseup event. The constructor, the _create() method, sets up our event handling using _on(). The _on() method is a built-in utility available to all widgets and takes care of cleaning up event bindings when the widget is destroyed.

The _tip() method is our main event handler — it's job is to find the selected text, and from that, lookup the selected term object from the terms options passed into the widget. Once it has a term, it can create the tooltip. The _selectedText() method deals with the user text selection. We use the window.getSelection() method here, which returns a selection object. Selection objects can have different types, and we're only interested in the Range variety. The next step is to get the range object, and then the contents, which is a document fragment. Once we have a document fragment, it's easy to create a jQuery object, and use the text() method to get the text selection. Which is important since the document fragment could have markup in it, which we're not interested in. The last, and vital step of _selectedText() is formatting the selected text by converting it to lower case, and removing any leading or trailing white space. Without this formatting, we would have trouble finding matches in out term map.

The _selectedTerm() method actually performs the search in the terms option. It is a straightforward call to $.map(), and we either return the term object, or undefined. The _createTooltip() method will create and position the tooltip widget. This is based on the position of the mouseup event. The _destroyTooltip() method destroys the tooltip and resets the title attribute of this element. We use this method when the widget is destroyed, or when the user selection is removed. For example, the mouseenter event is necessary because the user could have clicked another element on the page which will remove the selection. When they return with the mouse pointer, we have to destroy the tooltip before it displays.