wikimedia/mediawiki-extensions-Wikibase

View on GitHub
client/resources/jquery.ui/jquery.ui.suggester.js

Summary

Maintainability
C
1 day
Test Coverage
( function () {
    'use strict';

    require( './jquery.ui.ooMenu.js' );

    /**
     * Flips a complete position specification to be used by jQuery.ui.position (1.8).
     *
     * @ignore
     *
     * @param {Object} position
     * @return {Object}
     */
    function flipPosition( position ) {
        function flipOrientation( orientation ) {
            if ( /right/i.test( orientation ) ) {
                return orientation.replace( /right/i, 'left' );
            } else {
                return orientation.replace( /left/i, 'right' );
            }
        }

        function flipHorizontalOffset( offset ) {
            var offsets = offset.split( ' ' ),
                hOffset = parseInt( offsets[ 0 ], 10 );

            hOffset = ( hOffset <= 0 ) ? Math.abs( hOffset ) : hOffset * -1;
            return hOffset + ' ' + offsets[ 1 ];
        }

        position.my = flipOrientation( position.my );
        position.at = flipOrientation( position.at );

        if ( position.offset ) {
            position.offset = flipHorizontalOffset( position.offset );
        }

        return position;
    }

    /**
     * Enhances an input box by retrieving a list of suggestions that are displayed in a list below the
     * input box.
     * (uses `jQuery.ui.ooMenu`, `jQuery.ui.position`)
     *
     *     @example
     *     // Creates a simple suggester using an array as result set.
     *     $( 'input' ).suggester( { source: ['a', 'b', 'c'] } );
     *
     *     // Creates an auto-completion input element fetching suggestions via AJAX.
     *     $( 'input' ).suggester( {
     *         source: function( term ) {
     *             var deferred = $.Deferred();
     *
     *             $.ajax( {
     *                 url: 'https://commons.wikimedia.org/w/api.php',
     *                 dataType: 'jsonp',
     *                 data: {
     *                 search: term,
     *                 action: 'opensearch',
     *                 namespace: 6
     *             }, timeout: 8000 } )
     *             .done( function( response ) {
     *                 deferred.resolve( response[1], response[0] );
     *             } )
     *             .fail( function( jqXHR, textStatus ) {
     *                 deferred.reject( textStatus );
     *             } );
     *
     *             return deferred.promise();
     *         }
     *     } );
     *
     * @class jQuery.ui.suggester
     * @extends jQuery.Widget
     * @uses jQuery.ui
     * @license GNU GPL v2+
     * @author H. Snater < mediawiki@snater.com >
     *
     * @constructor
     *
     * @param {Object} options
     * @param {string[]|Function} options.source
     *        An array of strings that shall be used to provide suggestions. Alternatively, a function
     *        may be provided
     *        Parameters:
     *
     * - {string} Search term
     *
     * Expected return values:
     *
     * - {Object} jQuery promise
     *
     * Resolved parameters:
     *
     * - {string[]} Suggestions
     *
     * - {string} (optional) Search term corresponding to the suggestions. This allows checking whether
     *   the response belongs to the most current request.
     *
     * Rejected parameters:
     *
     * - {string} Plain text or HTML error message.
     *
     * @param {number} [options.minTermLength=1]
     *        Minimum number of characters to trigger a search with.
     * @param {number} [options.delay=300]
     *        Delay in milliseconds of the request querying for suggestions.
     * @param {jQuery.ui.ooMenu|null} [menu=null]
     *        A pre-initialized menu instance featuring one or more custom list item may be provided.
     *        This should be the preferred way to define custom items.
     * @param {Object} [position=Object]
     *        Object to be evaluated by `jQuery.ui.position` to set the suggestion list's position.
     *        In RTL context, the specified value is flipped automatically.
     *        Default: (position suggestion list's top left corner at input box's bottom left corner)
     * @param {jQuery|null} [confineMinWidthTo]
     *        The suggestion list's width shall not be smaller than the width of the referenced
     *        element. If `undefined`, the minimum width will be the width of the element the suggester
     *        is initialized on. Specifying `null` or `undefined` will prevent applying a minimum
     *        width.
     */
    /**
     * @event open
     * Triggered when the list of suggestions is opened.
     * @param {jQuery.Event} event
     */
    /**
     * @event close
     * Triggered when the list of suggestions is closed.
     * @param {jQuery.Event} event
     */
    /**
     * @event change
     * Triggered when the suggester's value has changed.
     * @param {jQuery.Event} event
     */
    /**
     * @event error
     * Triggered whenever an error occurred while gathering suggestions. This may happen only when using
     * a function as source. The {string} parameter is forwarded from the rejected promise returned by
     * the source function.
     * @param {jQuery.Event} event
     * @param {string} message
     */
    $.widget( 'ui.suggester', {

        /**
         * @see jQuery.Widget.options
         * @protected
         * @readonly
         */
        options: {
            source: null,
            minTermLength: 1,
            delay: 300,
            menu: null,
            position: {
                my: 'left top',
                at: 'left bottom',
                collision: 'none'
            },
            confineMinWidthTo: undefined
        },

        /**
         * Counter for the number of pending requests.
         *
         * @property {number}
         * @protected
         */
        _pending: null,

        /**
         * Current search term.
         *
         * @property {string}
         * @protected
         */
        _term: null,

        /**
         * Caches whether searching is in progress by either storing the ID of the timer used to delay
         * the actual search request or by storing a boolean "true" while the actual search request is
         * in progress.
         *
         * @property {number|boolean} [_searching=false]
         * @protected
         */
        _searching: false,

        /**
         * @see jQuery.Widget._create
         * @protected
         */
        _create: function () {
            var self = this;

            this._pending = 0;
            this._term = this.element.val();

            this.element
        .addClass( 'ui-suggester-input' )
        .on( 'blur.' + this.widgetName, function () {
            if ( !self.options.menu.element.is( ':focus' ) ) {
                self._close();
            }
        } );

            if ( !( this.options.menu instanceof $.ui.ooMenu ) ) {
                var $menu = $( '<ul>' ).ooMenu();
                this.options.menu = $menu.data( 'ooMenu' );
            }

            this.options.menu = this._initMenu( this.options.menu );

            this._attachInputEventHandlers();
            this._attachWindowEventHandlers();
        },

        /**
         * @see jQuery.Widget.destroy
         */
        destroy: function () {
            this._clearTimeout();

            var menu = this.option( 'menu' );
            menu.destroy();
            menu.element.remove();
            this.option( 'menu', null );

            // About to remove the last suggester instance on the page:
            if ( $( ':' + this.widgetBaseClass ).length === 1 ) {
                $( window ).off( '.' + this.widgetBaseClass );
            }

            this.element.removeClass( 'ui-suggester-input ui-suggester-loading ui-suggester-error' );

            $.Widget.prototype.destroy.call( this );
        },

        /**
         * @param key
         * @param value
         * @see jQuery.Widget._setOption
         * @protected
         */
        _setOption: function ( key, value ) {
            if ( key === 'menu' ) {
                this.options.menu.destroy();
                this.options.menu.element.remove();
            }

            var response = $.Widget.prototype._setOption.apply( this, arguments );

            if ( key === 'menu' && value instanceof $.ui.ooMenu ) {
                this.options.menu = this._initMenu( value );
            }

            if ( key === 'disabled' ) {
                if ( value ) {
                    this._close();
                }
                this.element.prop( 'disabled', value );
            }

            return response;
        },

        /**
         * Renders the menu and attaches the menu's event handlers.
         *
         * @protected
         *
         * @param {jQuery.ui.ooMenu} ooMenu
         * @return {jQuery.ui.ooMenu}
         */
        _initMenu: function ( ooMenu ) {
            var self = this;

            ooMenu.element
        .addClass( 'ui-suggester-list' )
        .hide()
        .appendTo( 'body' );

            $( ooMenu )
        .on( 'selected.suggester', function ( event, item ) {
            if ( item instanceof $.ui.ooMenu.Item && !( item instanceof $.ui.ooMenu.CustomItem ) ) {
                self._term = item.getValue();
                self.element.val( item.getValue() );
                self._close();
                self._trigger( 'change' );

                if ( !event.originalEvent || !/^key/.test( event.originalEvent.type ) ) {
                    setTimeout( function () {
                        // Run refocusing out of the execution chain to allow redrawing in IE.
                        self.element.trigger( 'focus' );
                    }, 0 );
                }
            }
        } );

            return ooMenu;
        },

        /**
         * Attaches input event handlers to the input element.
         *
         * @protected
         */
        _attachInputEventHandlers: function () {
            var self = this,
                suppressKeyPress = false;

            this.element
        .on( 'click.suggester', function ( event ) {
            if ( !self.isSearching() ) {
                self._updateMenuVisibility();
            }
        } )
        .on( 'keydown.suggester', function ( event ) {
            var isDisabled = self.element.hasClass( 'ui-state-disabled' );

            if ( isDisabled || self.element.prop( 'readOnly' ) ) {
                return;
            }

            self.element.removeClass( 'ui-suggester-error' );

            suppressKeyPress = false;

            var keyCode = $.ui.keyCode;

            switch ( event.keyCode ) {
                case keyCode.UP:
                    self._keyMove( 'previous', event );
                    break;

                case keyCode.DOWN:
                    self._keyMove( 'next', event );
                    break;

                case keyCode.ENTER:
                case keyCode.NUMPAD_ENTER:
                    if ( self.options.menu.getActiveItem() ) {
                        // Prevent form submission and select currently active item.
                        event.preventDefault();
                        event.stopPropagation();
                        suppressKeyPress = true;
                        self.options.menu.select( event );
                    }
                    break;

                case keyCode.TAB:
                    if ( !self.options.menu.getActiveItem() ) {
                        self._close();
                        return;
                    }
                    self.options.menu.select( event );
                    break;

                case keyCode.ESCAPE:
                    self.element.val( self._term );
                    // eslint-disable-next-line no-jquery/no-sizzle
                    if ( self.options.menu.element.is( ':visible' ) ) {
                        event.stopPropagation();
                        self._close();
                    }
                    break;

                default:
                    if ( self.element.val() === ''
                        && (
                            event.keyCode === keyCode.BACKSPACE
                            || event.keyCode === keyCode.DELETE
                        )
                    ) {
                        break;
                    }

                    self._triggerSearch();

                    break;
            }

            self._trigger( 'change' );
        } )
        .on( 'keypress.suggester', function ( event ) {
            if ( suppressKeyPress ) {
                suppressKeyPress = false;
                event.preventDefault();
            }
        } );
        },

        /**
         * Attaches event listeners to the `window` object.
         *
         * @protected
         */
        _attachWindowEventHandlers: function () {
            var self = this;

            $( window )
        .off( '.' + this.widgetBaseClass )
        .on( 'resize.' + this.widgetBaseClass, function () {
            $( ':' + self.widgetBaseClass ).each( function ( i, node ) {
                var suggester = $( node ).data( self.widgetName );
                suggester.repositionMenu();
                suggester.options.menu.scale();
            } );
        } )
        .on( 'click.' + this.widgetBaseClass, function ( event ) {
            var $target = $( event.target );
            $( ':' + self.widgetBaseClass ).each( function ( i, node ) {
                var suggester = $( node ).data( self.widgetName );
                // Close suggester if not clicked on suggester or corresponding list:
                if ( $target.closest( suggester.element ).length === 0
                    && $target.closest( suggester.options.menu.element ).length === 0
                ) {
                    suggester._close();
                }
            } );
        } );
        },

        /**
         * @private
         */
        _triggerSearch: function () {
            var self = this;

            this._clearTimeout();

            this._searching = setTimeout( function () {
            // Only search if the value has changed:
                if ( self._term !== self.element.val() ) {
                    self.search()
                .done( function () {
                    // Widget might have been destroyed in the meantime.
                    if ( self.element.data( self.widgetName ) ) {
                        self._trigger( 'change' );
                    }
                } );
                }
            }, this.options.delay );
        },

        /**
         * Returns whether searching is in progress.
         *
         * @return {boolean}
         */
        isSearching: function () {
            return this._searching !== false;
        },

        /**
         * Handles moving through the list of suggestions using arrow keys.
         *
         * @protected
         *
         * @param {string} direction (either "previous" or "next")
         * @param {jQuery.Event} event
         */
        _keyMove: function ( direction, event ) {
        // Prevent moving cursor to beginning/end of the text field in some browsers:
            event.preventDefault();

            // eslint-disable-next-line no-jquery/no-sizzle
            if ( !this.options.menu.element.is( ':visible' ) ) {
                this.search();
                return;
            }

            var allItems = $.merge( [], this.options.menu.option( 'items' ) );
            $.merge( allItems, this.options.menu.option( 'customItems' ) );

            if ( allItems.length > 0 ) {
                this._move( direction, this.options.menu.getActiveItem(), allItems );
            }
        },

        /**
         * Shifts the suggestions menu focus by one item.
         *
         * @protected
         *
         * @param {string} direction
         * @param {jQuery.ui.ooMenu.Item} activeItem
         * @param {jQuery.ui.ooMenu.Item[]} allItems
         */
        _move: function ( direction, activeItem, allItems ) {
            var self = this,
                isFirst = activeItem === allItems[ 0 ],
                isLast = activeItem === allItems[ allItems.length - 1 ];

            if ( isFirst && direction === 'previous' || isLast && direction === 'next' ) {
                this._moveOffEdge( direction );
            } else {
                $( this.options.menu ).one( 'focus.suggester', function ( event, item ) {
                    var isCustomMenuItem = item instanceof $.ui.ooMenu.CustomItem;

                    if ( item instanceof $.ui.ooMenu.Item && !isCustomMenuItem ) {
                        self.element.val( item.getValue() );
                    } else if ( isCustomMenuItem ) {
                        self.element.val( self._term );
                    }
                    self._trigger( 'change' );
                } );
                this.options.menu[ direction ]();
            }
        },

        /**
         * Handler called when the suggestion menu focus is to be shifted off the end of the list.
         *
         * @protected
         *
         * @param {string} direction
         */
        _moveOffEdge: function ( direction ) {
            this.element.val( this._term );
            this.options.menu.deactivate();
        },

        /**
         * Performs a search on the current input.
         *
         * @return {Object} jQuery.Promise
         * @return {Function} return.done
         * @return {string[]} return.done.suggestions
         * @return {Function} return.fail
         * @return {string} return.fail.message
         */
        search: function () {
            var self = this,
                deferred = $.Deferred();

            this._clearTimeout();
            this._searching = true;

            this._term = this.element.val();

            if ( this._term.length < this.options.minTermLength ) {
                this._close();
                return deferred.resolve( [], this._term ).promise();
            }

            this.element.addClass( 'ui-suggester-loading' );
            this._pending++;

            return this._getSuggestions( this._term )
        .done( function ( suggestions, requestTerm ) {
            self._searching = false;

            if ( typeof requestTerm === 'string' && requestTerm !== self._term ) {
                // Skip request since it does not correspond to the current search term.
                return;
            }
            if ( self.options.menu ) {
                // Suggester (including the menu) might have been destroyed in the meantime.
                self._updateMenu( suggestions, requestTerm );
            }
        } )
        .fail( function ( message ) {
            self.element.addClass( 'ui-suggester-error' );
            self._trigger( 'error', null, [ message ] );
        } )
        .always( function () {
            if ( --self._pending === 0 ) {
                self.element.removeClass( 'ui-suggester-loading' );
            }
        } );
        },

        /**
         * Clears the timeout used to delay searching if there is an active timer.
         *
         * @protected
         */
        _clearTimeout: function () {
            if ( typeof this._searching !== 'boolean' ) {
                clearTimeout( this._searching );
            }
        },

        /**
         * Updates the menu.
         *
         * @protected
         *
         * @param {string[]} suggestions
         * @param {string} requestTerm
         */
        _updateMenu: function ( suggestions, requestTerm ) {
            this._updateMenuItems( suggestions, requestTerm );
            this._updateMenuVisibility();
        },

        /**
         * Updates the suggestion menu with the received suggestions.
         *
         * @protected
         *
         * @param {string[]} suggestions
         * @param {string} requestTerm
         */
        _updateMenuItems: function ( suggestions, requestTerm ) {
            var menuItems = [];

            for ( var i = 0; i < suggestions.length; i++ ) {
                menuItems.push( this._createMenuItemFromSuggestion( suggestions[ i ], requestTerm ) );
            }

            this.options.menu.option( 'items', menuItems );
        },

        /**
         * Updates the menu's visibility.
         *
         * @protected
         */
        _updateMenuVisibility: function () {
            if ( !this.options.menu.hasVisibleItems( true ) ) {
                this._close();
            } else {
                this._open();
            }
        },

        /**
         * Instantiates a menu item instance from a suggestion.
         *
         * @protected
         *
         * @param {string} suggestion
         * @param {string} requestTerm
         * @return {jQuery.ui.ooMenu.Item}
         */
        _createMenuItemFromSuggestion: function ( suggestion, requestTerm ) {
            return new $.ui.ooMenu.Item( suggestion );
        },

        /**
         * Retrieves the suggestions for a specific search term.
         *
         * @protected
         *
         * @param {string} term
         * @return {Object} jQuery.Promise
         * @return {Function} return.done
         * @return {string[]} return.done.suggestions
         * @return {string} return.done.requestTerm
         * @return {Function} return.fail
         * @return {string} return.fail.message
         */
        _getSuggestions: function ( term ) {
            if ( typeof this.options.source === 'function' ) {
                return this.options.source( term );
            }

            return this._getSuggestionsFromArray( term, this.options.source );
        },

        /**
         * Filters an array using a specific search term.
         *
         * @protected
         *
         * @param {string} term
         * @param {string[]} source
         * @return {Object} jQuery.Promise
         * @return {Function} return.done
         * @return {string[]} return.done.suggestions
         * @return {string} return.done.requestTerm
         * @return {Function} return.fail
         * @return {string} return.fail.message
         */
        _getSuggestionsFromArray: function ( term, source ) {
            var deferred = $.Deferred();

            // eslint-disable-next-line security/detect-non-literal-regexp
            var matcher = new RegExp( this._escapeRegex( term ), 'i' );

            // eslint-disable-next-line no-jquery/no-grep
            deferred.resolve( $.grep( source, function ( item ) {
                return matcher.test( item );
            } ), term );

            return deferred.promise();
        },

        /**
         * Escapes a string to be used in a regular expression.
         *
         * @protected
         *
         * @param {string} value
         * @return {string}
         */
        _escapeRegex: function ( value ) {
            return value.replace( /[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&' );
        },

        /**
         * Shows the suggester menu.
         *
         * @protected
         */
        _open: function () {
            // eslint-disable-next-line no-jquery/no-sizzle
            if ( this.options.menu.element.is( ':visible' ) ) {
                return;
            }

            this.options.menu.element.show();
            this.repositionMenu();

            this._trigger( 'open' );
        },

        /**
         * Hides the suggester menu.
         *
         * @protected
         */
        _close: function () {
            // eslint-disable-next-line no-jquery/no-sizzle
            if ( !this.options.menu.element.is( ':visible' ) ) {
                return;
            }

            this.options.menu.deactivate();
            this.options.menu.element.hide();

            this._trigger( 'close' );
        },

        /**
         * Aligns the menu to the input element.
         */
        repositionMenu: function () {
            var dir = this.element.attr( 'dir' )
            || $( document.documentElement ).css( 'direction' )
            || 'auto';

            var position = $.extend( {}, this.options.position ),
                $menu = this.options.menu.element;

            if ( dir === 'rtl' ) {
                position = flipPosition( position );
            }

            $menu.position( $.extend( {
                of: this.element
            }, position ) );

            $menu.zIndex( this.element.zIndex() + 1 );

            if ( this.element.attr( 'lang' ) ) {
                $menu.attr( 'lang', this.element.attr( 'lang' ) );
            }
            $menu.attr( 'dir', dir );

            this.options.menu.scale();

            if ( this.options.confineMinWidthTo !== null ) {
                var $minWidthConfinement = this.options.confineMinWidthTo || this.element;

                $menu.css(
                    'min-width',
                    $minWidthConfinement.outerWidth() - ( $menu.outerWidth() - $menu.width() )
                );
            }
        }

    } );

}() );