jquery.suggestions: Remove public object $.suggestions
authorFomafix <fomafix@googlemail.com>
Wed, 29 May 2019 22:25:03 +0000 (00:25 +0200)
committerFomafix <fomafix@googlemail.com>
Fri, 31 May 2019 05:40:02 +0000 (07:40 +0200)
Private functions are sufficient here.

Also reorder function declarations to avoid forward references.

Change-Id: Idf99e56b6cb1dd0730ea349818fbb2133f941b90

resources/src/jquery/jquery.suggestions.js

index f58d733..4a1d637 100644 (file)
@@ -10,8 +10,6 @@
  *
  *  $( '#textbox' ).suggestions();
  *
- * Uses jQuery.suggestions singleton internally.
- *
  * @class jQuery.plugin.suggestions
  */
 
        var hasOwn = Object.hasOwnProperty;
 
        /**
-        * Used by jQuery.plugin.suggestions.
+        * Cancel any delayed maybeFetch() call and callback the context so
+        * they can cancel any async fetching if they use AJAX or something.
         *
-        * @class jQuery.suggestions
-        * @singleton
-        * @private
+        * @param {Object} context
         */
-       $.suggestions = {
-               /**
-                * Cancel any delayed maybeFetch() call and callback the context so
-                * they can cancel any async fetching if they use AJAX or something.
-                *
-                * @param {Object} context
-                */
-               cancel: function ( context ) {
-                       if ( context.data.timerID !== null ) {
-                               clearTimeout( context.data.timerID );
-                       }
-                       if ( typeof context.config.cancel === 'function' ) {
-                               context.config.cancel.call( context.data.$textbox );
+       function cancel( context ) {
+               if ( context.data.timerID !== null ) {
+                       clearTimeout( context.data.timerID );
+               }
+               if ( typeof context.config.cancel === 'function' ) {
+                       context.config.cancel.call( context.data.$textbox );
+               }
+       }
+
+       /**
+        * Hide the element with suggestions and clean up some state.
+        *
+        * @param {Object} context
+        */
+       function hide( context ) {
+               // Remove any highlights, including on "special" items
+               context.data.$container.find( '.suggestions-result-current' ).removeClass( 'suggestions-result-current' );
+               // Hide the container
+               context.data.$container.hide();
+       }
+
+       /**
+        * Restore the text the user originally typed in the textbox, before it
+        * was overwritten by highlight(). This restores the value the currently
+        * displayed suggestions are based on, rather than the value just before
+        * highlight() overwrote it; the former is arguably slightly more sensible.
+        *
+        * @param {Object} context
+        */
+       function restore( context ) {
+               context.data.$textbox.val( context.data.prevText );
+       }
+
+       /**
+        * @param {Object} context
+        */
+       function special( context ) {
+               // Allow custom rendering - but otherwise don't do any rendering
+               if ( typeof context.config.special.render === 'function' ) {
+                       // Wait for the browser to update the value
+                       setTimeout( function () {
+                               // Render special
+                               var $special = context.data.$container.find( '.suggestions-special' );
+                               context.config.special.render.call( $special, context.data.$textbox.val(), context );
+                       }, 1 );
+               }
+       }
+
+       /**
+        * Ask the user-specified callback for new suggestions. Any previous delayed
+        * call to this function still pending will be canceled. If the value in the
+        * textbox is empty or hasn't changed since the last time suggestions were fetched,
+        * this function does nothing.
+        *
+        * @param {Object} context
+        * @param {boolean} delayed Whether or not to delay this by the currently configured amount of time
+        */
+       function update( context, delayed ) {
+               function maybeFetch() {
+                       var val = context.data.$textbox.val(),
+                               cache = context.data.cache,
+                               cacheHit;
+
+                       if ( typeof context.config.update.before === 'function' ) {
+                               context.config.update.before.call( context.data.$textbox );
                        }
-               },
-
-               /**
-                * Hide the element with suggestions and clean up some state.
-                *
-                * @param {Object} context
-                */
-               hide: function ( context ) {
-                       // Remove any highlights, including on "special" items
-                       context.data.$container.find( '.suggestions-result-current' ).removeClass( 'suggestions-result-current' );
-                       // Hide the container
-                       context.data.$container.hide();
-               },
-
-               /**
-                * Restore the text the user originally typed in the textbox, before it
-                * was overwritten by highlight(). This restores the value the currently
-                * displayed suggestions are based on, rather than the value just before
-                * highlight() overwrote it; the former is arguably slightly more sensible.
-                *
-                * @param {Object} context
-                */
-               restore: function ( context ) {
-                       context.data.$textbox.val( context.data.prevText );
-               },
-
-               /**
-                * Ask the user-specified callback for new suggestions. Any previous delayed
-                * call to this function still pending will be canceled. If the value in the
-                * textbox is empty or hasn't changed since the last time suggestions were fetched,
-                * this function does nothing.
-                *
-                * @param {Object} context
-                * @param {boolean} delayed Whether or not to delay this by the currently configured amount of time
-                */
-               update: function ( context, delayed ) {
-                       function maybeFetch() {
-                               var val = context.data.$textbox.val(),
-                                       cache = context.data.cache,
-                                       cacheHit;
-
-                               if ( typeof context.config.update.before === 'function' ) {
-                                       context.config.update.before.call( context.data.$textbox );
-                               }
 
-                               // Only fetch if the value in the textbox changed and is not empty, or if the results were hidden
-                               // if the textbox is empty then clear the result div, but leave other settings intouched
-                               if ( val.length === 0 ) {
-                                       $.suggestions.hide( context );
-                                       context.data.prevText = '';
-                               } else if (
-                                       val !== context.data.prevText ||
-                                       !context.data.$container.is( ':visible' )
-                               ) {
-                                       context.data.prevText = val;
-                                       // Try cache first
-                                       if ( context.config.cache && hasOwn.call( cache, val ) ) {
-                                               if ( mw.now() - cache[ val ].timestamp < context.config.cacheMaxAge ) {
-                                                       context.data.$textbox.suggestions( 'suggestions', cache[ val ].suggestions );
+                       // Only fetch if the value in the textbox changed and is not empty, or if the results were hidden
+                       // if the textbox is empty then clear the result div, but leave other settings intouched
+                       if ( val.length === 0 ) {
+                               hide( context );
+                               context.data.prevText = '';
+                       } else if (
+                               val !== context.data.prevText ||
+                               !context.data.$container.is( ':visible' )
+                       ) {
+                               context.data.prevText = val;
+                               // Try cache first
+                               if ( context.config.cache && hasOwn.call( cache, val ) ) {
+                                       if ( mw.now() - cache[ val ].timestamp < context.config.cacheMaxAge ) {
+                                               context.data.$textbox.suggestions( 'suggestions', cache[ val ].suggestions );
+                                               if ( typeof context.config.update.after === 'function' ) {
+                                                       context.config.update.after.call( context.data.$textbox, cache[ val ].metadata );
+                                               }
+                                               cacheHit = true;
+                                       } else {
+                                               // Cache expired
+                                               delete cache[ val ];
+                                       }
+                               }
+                               if ( !cacheHit && typeof context.config.fetch === 'function' ) {
+                                       context.config.fetch.call(
+                                               context.data.$textbox,
+                                               val,
+                                               function ( suggestions, metadata ) {
+                                                       suggestions = suggestions.slice( 0, context.config.maxRows );
+                                                       context.data.$textbox.suggestions( 'suggestions', suggestions );
                                                        if ( typeof context.config.update.after === 'function' ) {
-                                                               context.config.update.after.call( context.data.$textbox, cache[ val ].metadata );
+                                                               context.config.update.after.call( context.data.$textbox, metadata );
+                                                       }
+                                                       if ( context.config.cache ) {
+                                                               cache[ val ] = {
+                                                                       suggestions: suggestions,
+                                                                       metadata: metadata,
+                                                                       timestamp: mw.now()
+                                                               };
                                                        }
-                                                       cacheHit = true;
+                                               },
+                                               context.config.maxRows
+                                       );
+                               }
+                       }
+
+                       // Always update special rendering
+                       special( context );
+               }
+
+               // Cancels any delayed maybeFetch call, and invokes context.config.cancel.
+               cancel( context );
+
+               if ( delayed ) {
+                       // To avoid many started/aborted requests while typing, we're gonna take a short
+                       // break before trying to fetch data.
+                       context.data.timerID = setTimeout( maybeFetch, context.config.delay );
+               } else {
+                       maybeFetch();
+               }
+       }
+
+       /**
+        * Highlight a result in the results table
+        *
+        * @param {Object} context
+        * @param {jQuery|string} result `<tr>` to highlight, or 'prev' or 'next'
+        * @param {boolean} updateTextbox If true, put the suggestion in the textbox
+        */
+       function highlight( context, result, updateTextbox ) {
+               var selected = context.data.$container.find( '.suggestions-result-current' );
+               if ( !result.get || selected.get( 0 ) !== result.get( 0 ) ) {
+                       if ( result === 'prev' ) {
+                               if ( selected.hasClass( 'suggestions-special' ) ) {
+                                       result = context.data.$container.find( '.suggestions-result:last' );
+                               } else {
+                                       result = selected.prev();
+                                       if ( !( result.length && result.hasClass( 'suggestions-result' ) ) ) {
+                                               // there is something in the DOM between selected element and the wrapper, bypass it
+                                               result = selected.parents( '.suggestions-results > *' ).prev().find( '.suggestions-result' ).eq( 0 );
+                                       }
+
+                                       if ( selected.length === 0 ) {
+                                               // we are at the beginning, so lets jump to the last item
+                                               if ( context.data.$container.find( '.suggestions-special' ).html() !== '' ) {
+                                                       result = context.data.$container.find( '.suggestions-special' );
                                                } else {
-                                                       // Cache expired
-                                                       delete cache[ val ];
+                                                       result = context.data.$container.find( '.suggestions-results .suggestions-result:last' );
                                                }
                                        }
-                                       if ( !cacheHit && typeof context.config.fetch === 'function' ) {
-                                               context.config.fetch.call(
-                                                       context.data.$textbox,
-                                                       val,
-                                                       function ( suggestions, metadata ) {
-                                                               suggestions = suggestions.slice( 0, context.config.maxRows );
-                                                               context.data.$textbox.suggestions( 'suggestions', suggestions );
-                                                               if ( typeof context.config.update.after === 'function' ) {
-                                                                       context.config.update.after.call( context.data.$textbox, metadata );
-                                                               }
-                                                               if ( context.config.cache ) {
-                                                                       cache[ val ] = {
-                                                                               suggestions: suggestions,
-                                                                               metadata: metadata,
-                                                                               timestamp: mw.now()
-                                                                       };
-                                                               }
-                                                       },
-                                                       context.config.maxRows
-                                               );
-                                       }
                                }
+                       } else if ( result === 'next' ) {
+                               if ( selected.length === 0 ) {
+                                       // No item selected, go to the first one
+                                       result = context.data.$container.find( '.suggestions-results .suggestions-result:first' );
+                                       if ( result.length === 0 && context.data.$container.find( '.suggestions-special' ).html() !== '' ) {
+                                               // No suggestion exists, go to the special one directly
+                                               result = context.data.$container.find( '.suggestions-special' );
+                                       }
+                               } else {
+                                       result = selected.next();
+                                       if ( !( result.length && result.hasClass( 'suggestions-result' ) ) ) {
+                                               // there is something in the DOM between selected element and the wrapper, bypass it
+                                               result = selected.parents( '.suggestions-results > *' ).next().find( '.suggestions-result' ).eq( 0 );
+                                       }
 
-                               // Always update special rendering
-                               $.suggestions.special( context );
+                                       if ( selected.hasClass( 'suggestions-special' ) ) {
+                                               result = $( [] );
+                                       } else if (
+                                               result.length === 0 &&
+                                               context.data.$container.find( '.suggestions-special' ).html() !== ''
+                                       ) {
+                                               // We were at the last item, jump to the specials!
+                                               result = context.data.$container.find( '.suggestions-special' );
+                                       }
+                               }
                        }
-
-                       // Cancels any delayed maybeFetch call, and invokes context.config.cancel.
-                       $.suggestions.cancel( context );
-
-                       if ( delayed ) {
-                               // To avoid many started/aborted requests while typing, we're gonna take a short
-                               // break before trying to fetch data.
-                               context.data.timerID = setTimeout( maybeFetch, context.config.delay );
+                       selected.removeClass( 'suggestions-result-current' );
+                       result.addClass( 'suggestions-result-current' );
+               }
+               if ( updateTextbox ) {
+                       if ( result.length === 0 || result.is( '.suggestions-special' ) ) {
+                               restore( context );
                        } else {
-                               maybeFetch();
-                       }
-               },
-
-               /**
-                * @param {Object} context
-                */
-               special: function ( context ) {
-                       // Allow custom rendering - but otherwise don't do any rendering
-                       if ( typeof context.config.special.render === 'function' ) {
-                               // Wait for the browser to update the value
-                               setTimeout( function () {
-                                       // Render special
-                                       var $special = context.data.$container.find( '.suggestions-special' );
-                                       context.config.special.render.call( $special, context.data.$textbox.val(), context );
-                               }, 1 );
+                               context.data.$textbox.val( result.data( 'text' ) );
+                               // .val() doesn't call any event handlers, so
+                               // let the world know what happened
+                               context.data.$textbox.trigger( 'change' );
                        }
-               },
-
-               /**
-                * Sets the value of a property, and updates the widget accordingly
-                *
-                * @param {Object} context
-                * @param {string} property Name of property
-                * @param {Mixed} value Value to set property with
-                */
-               configure: function ( context, property, value ) {
-                       var newCSS,
-                               $result, $results, $spanForWidth, childrenWidth,
-                               regionIsFixed, regionPosition,
-                               i, expWidth, maxWidth, text;
-
-                       // Validate creation using fallback values
-                       switch ( property ) {
-                               case 'fetch':
-                               case 'cancel':
-                               case 'special':
-                               case 'result':
-                               case 'update':
-                               case '$region':
-                               case 'expandFrom':
-                                       context.config[ property ] = value;
-                                       break;
-                               case 'suggestions':
-                                       context.config[ property ] = value;
-                                       // Update suggestions
-                                       if ( context.data !== undefined ) {
-                                               if ( context.data.$textbox.val().length === 0 ) {
-                                                       // Hide the div when no suggestion exist
-                                                       $.suggestions.hide( context );
-                                               } else {
-                                                       // Rebuild the suggestions list
-                                                       context.data.$container.show();
-                                                       // Update the size and position of the list
-                                                       regionIsFixed = ( function () {
-                                                               var $el = context.config.$region;
-                                                               do {
-                                                                       if ( $el.css( 'position' ) === 'fixed' ) {
-                                                                               return true;
-                                                                       }
-                                                                       $el = $( $el[ 0 ].offsetParent );
-                                                               } while ( $el.length );
-                                                               return false;
-                                                       }() );
-                                                       regionPosition = regionIsFixed ?
-                                                               context.config.$region[ 0 ].getBoundingClientRect() :
-                                                               context.config.$region.offset();
-                                                       newCSS = {
-                                                               position: regionIsFixed ? 'fixed' : 'absolute',
-                                                               top: regionPosition.top + context.config.$region.outerHeight(),
-                                                               bottom: 'auto',
-                                                               width: context.config.$region.outerWidth(),
-                                                               height: 'auto'
-                                                       };
-
-                                                       // Process expandFrom, after this it is set to left or right.
-                                                       context.config.expandFrom = ( function ( expandFrom ) {
-                                                               var regionWidth, docWidth, regionCenter, docCenter,
-                                                                       docDir = $( document.documentElement ).css( 'direction' ),
-                                                                       $region = context.config.$region;
-
-                                                               // Backwards compatible
-                                                               if ( context.config.positionFromLeft ) {
-                                                                       expandFrom = 'left';
-
-                                                               // Catch invalid values, default to 'auto'
-                                                               } else if ( [ 'left', 'right', 'start', 'end', 'auto' ].indexOf( expandFrom ) === -1 ) {
-                                                                       expandFrom = 'auto';
+                       context.data.$textbox.trigger( 'change' );
+               }
+       }
+
+       /**
+        * Sets the value of a property, and updates the widget accordingly
+        *
+        * @param {Object} context
+        * @param {string} property Name of property
+        * @param {Mixed} value Value to set property with
+        */
+       function configure( context, property, value ) {
+               var newCSS,
+                       $result, $results, $spanForWidth, childrenWidth,
+                       regionIsFixed, regionPosition,
+                       i, expWidth, maxWidth, text;
+
+               // Validate creation using fallback values
+               switch ( property ) {
+                       case 'fetch':
+                       case 'cancel':
+                       case 'special':
+                       case 'result':
+                       case 'update':
+                       case '$region':
+                       case 'expandFrom':
+                               context.config[ property ] = value;
+                               break;
+                       case 'suggestions':
+                               context.config[ property ] = value;
+                               // Update suggestions
+                               if ( context.data !== undefined ) {
+                                       if ( context.data.$textbox.val().length === 0 ) {
+                                               // Hide the div when no suggestion exist
+                                               hide( context );
+                                       } else {
+                                               // Rebuild the suggestions list
+                                               context.data.$container.show();
+                                               // Update the size and position of the list
+                                               regionIsFixed = ( function () {
+                                                       var $el = context.config.$region;
+                                                       do {
+                                                               if ( $el.css( 'position' ) === 'fixed' ) {
+                                                                       return true;
                                                                }
+                                                               $el = $( $el[ 0 ].offsetParent );
+                                                       } while ( $el.length );
+                                                       return false;
+                                               }() );
+                                               regionPosition = regionIsFixed ?
+                                                       context.config.$region[ 0 ].getBoundingClientRect() :
+                                                       context.config.$region.offset();
+                                               newCSS = {
+                                                       position: regionIsFixed ? 'fixed' : 'absolute',
+                                                       top: regionPosition.top + context.config.$region.outerHeight(),
+                                                       bottom: 'auto',
+                                                       width: context.config.$region.outerWidth(),
+                                                       height: 'auto'
+                                               };
+
+                                               // Process expandFrom, after this it is set to left or right.
+                                               context.config.expandFrom = ( function ( expandFrom ) {
+                                                       var regionWidth, docWidth, regionCenter, docCenter,
+                                                               docDir = $( document.documentElement ).css( 'direction' ),
+                                                               $region = context.config.$region;
+
+                                                       // Backwards compatible
+                                                       if ( context.config.positionFromLeft ) {
+                                                               expandFrom = 'left';
+
+                                                       // Catch invalid values, default to 'auto'
+                                                       } else if ( [ 'left', 'right', 'start', 'end', 'auto' ].indexOf( expandFrom ) === -1 ) {
+                                                               expandFrom = 'auto';
+                                                       }
 
-                                                               if ( expandFrom === 'auto' ) {
-                                                                       if ( $region.data( 'searchsuggest-expand-dir' ) ) {
-                                                                               // If the markup explicitly contains a direction, use it.
-                                                                               expandFrom = $region.data( 'searchsuggest-expand-dir' );
+                                                       if ( expandFrom === 'auto' ) {
+                                                               if ( $region.data( 'searchsuggest-expand-dir' ) ) {
+                                                                       // If the markup explicitly contains a direction, use it.
+                                                                       expandFrom = $region.data( 'searchsuggest-expand-dir' );
+                                                               } else {
+                                                                       regionWidth = $region.outerWidth();
+                                                                       docWidth = $( document ).width();
+                                                                       if ( regionWidth > ( 0.85 * docWidth ) ) {
+                                                                               // If the input size takes up more than 85% of the document horizontally
+                                                                               // expand the suggestions to the writing direction's native end.
+                                                                               expandFrom = 'start';
                                                                        } else {
-                                                                               regionWidth = $region.outerWidth();
-                                                                               docWidth = $( document ).width();
-                                                                               if ( regionWidth > ( 0.85 * docWidth ) ) {
-                                                                                       // If the input size takes up more than 85% of the document horizontally
-                                                                                       // expand the suggestions to the writing direction's native end.
+                                                                               // Calculate the center points of the input and document
+                                                                               regionCenter = regionPosition.left + regionWidth / 2;
+                                                                               docCenter = docWidth / 2;
+                                                                               if ( Math.abs( regionCenter - docCenter ) < ( 0.10 * docCenter ) ) {
+                                                                                       // If the input's center is within 10% of the document center
+                                                                                       // use the writing direction's native end.
                                                                                        expandFrom = 'start';
                                                                                } else {
-                                                                                       // Calculate the center points of the input and document
-                                                                                       regionCenter = regionPosition.left + regionWidth / 2;
-                                                                                       docCenter = docWidth / 2;
-                                                                                       if ( Math.abs( regionCenter - docCenter ) < ( 0.10 * docCenter ) ) {
-                                                                                               // If the input's center is within 10% of the document center
-                                                                                               // use the writing direction's native end.
-                                                                                               expandFrom = 'start';
-                                                                                       } else {
-                                                                                               // Otherwise expand the input from the closest side of the page,
-                                                                                               // towards the side of the page with the most free open space
-                                                                                               expandFrom = regionCenter > docCenter ? 'right' : 'left';
-                                                                                       }
+                                                                                       // Otherwise expand the input from the closest side of the page,
+                                                                                       // towards the side of the page with the most free open space
+                                                                                       expandFrom = regionCenter > docCenter ? 'right' : 'left';
                                                                                }
                                                                        }
                                                                }
+                                                       }
 
-                                                               if ( expandFrom === 'start' ) {
-                                                                       expandFrom = docDir === 'rtl' ? 'right' : 'left';
-
-                                                               } else if ( expandFrom === 'end' ) {
-                                                                       expandFrom = docDir === 'rtl' ? 'left' : 'right';
-                                                               }
-
-                                                               return expandFrom;
-
-                                                       }( context.config.expandFrom ) );
+                                                       if ( expandFrom === 'start' ) {
+                                                               expandFrom = docDir === 'rtl' ? 'right' : 'left';
 
-                                                       if ( context.config.expandFrom === 'left' ) {
-                                                               // Expand from left
-                                                               newCSS.left = regionPosition.left;
-                                                               newCSS.right = 'auto';
-                                                       } else {
-                                                               // Expand from right
-                                                               newCSS.left = 'auto';
-                                                               newCSS.right = $( 'body' ).width() - ( regionPosition.left + context.config.$region.outerWidth() );
+                                                       } else if ( expandFrom === 'end' ) {
+                                                               expandFrom = docDir === 'rtl' ? 'left' : 'right';
                                                        }
 
-                                                       context.data.$container.css( newCSS );
-                                                       $results = context.data.$container.children( '.suggestions-results' );
-                                                       $results.empty();
-                                                       expWidth = -1;
-                                                       for ( i = 0; i < context.config.suggestions.length; i++ ) {
-                                                               text = context.config.suggestions[ i ];
-                                                               $result = $( '<div>' )
-                                                                       .addClass( 'suggestions-result' )
-                                                                       .attr( 'rel', i )
-                                                                       .data( 'text', context.config.suggestions[ i ] )
-                                                                       .on( 'mousemove', function () {
-                                                                               context.data.selectedWithMouse = true;
-                                                                               $.suggestions.highlight(
-                                                                                       context,
-                                                                                       $( this ).closest( '.suggestions-results .suggestions-result' ),
-                                                                                       false
-                                                                               );
-                                                                       } )
-                                                                       .appendTo( $results );
-                                                               // Allow custom rendering
-                                                               if ( typeof context.config.result.render === 'function' ) {
-                                                                       context.config.result.render.call( $result, context.config.suggestions[ i ], context );
-                                                               } else {
-                                                                       $result.text( text );
-                                                               }
+                                                       return expandFrom;
 
-                                                               if ( context.config.highlightInput ) {
-                                                                       $result.highlightText( context.data.prevText, { method: 'prefixHighlight' } );
-                                                               }
+                                               }( context.config.expandFrom ) );
 
-                                                               // Widen results box if needed (new width is only calculated here, applied later).
-
-                                                               // The monstrosity below accomplishes two things:
-                                                               // * Wraps the text contents in a DOM element, so that we can know its width. There is
-                                                               //   no way to directly access the width of a text node, and we can't use the parent
-                                                               //   node width as it has text-overflow: ellipsis; and overflow: hidden; applied to
-                                                               //   it, which trims it to a smaller width.
-                                                               // * Temporarily applies position: absolute; to the wrapper to pull it out of normal
-                                                               //   document flow. Otherwise the CSS text-overflow: ellipsis; and overflow: hidden;
-                                                               //   rules would cause some browsers (at least all versions of IE from 6 to 11) to
-                                                               //   still report the "trimmed" width. This should not be done in regular CSS
-                                                               //   stylesheets as we don't want this rule to apply to other <span> elements, like
-                                                               //   the ones generated by jquery.highlightText.
-                                                               $spanForWidth = $result.wrapInner( '<span>' ).children();
-                                                               childrenWidth = $spanForWidth.css( 'position', 'absolute' ).outerWidth();
-                                                               $spanForWidth.contents().unwrap();
-
-                                                               if ( childrenWidth > $result.width() && childrenWidth > expWidth ) {
-                                                                       // factor in any padding, margin, or border space on the parent
-                                                                       expWidth = childrenWidth + ( context.data.$container.width() - $result.width() );
-                                                               }
+                                               if ( context.config.expandFrom === 'left' ) {
+                                                       // Expand from left
+                                                       newCSS.left = regionPosition.left;
+                                                       newCSS.right = 'auto';
+                                               } else {
+                                                       // Expand from right
+                                                       newCSS.left = 'auto';
+                                                       newCSS.right = $( 'body' ).width() - ( regionPosition.left + context.config.$region.outerWidth() );
+                                               }
+
+                                               context.data.$container.css( newCSS );
+                                               $results = context.data.$container.children( '.suggestions-results' );
+                                               $results.empty();
+                                               expWidth = -1;
+                                               for ( i = 0; i < context.config.suggestions.length; i++ ) {
+                                                       text = context.config.suggestions[ i ];
+                                                       $result = $( '<div>' )
+                                                               .addClass( 'suggestions-result' )
+                                                               .attr( 'rel', i )
+                                                               .data( 'text', context.config.suggestions[ i ] )
+                                                               .on( 'mousemove', function () {
+                                                                       context.data.selectedWithMouse = true;
+                                                                       highlight(
+                                                                               context,
+                                                                               $( this ).closest( '.suggestions-results .suggestions-result' ),
+                                                                               false
+                                                                       );
+                                                               } )
+                                                               .appendTo( $results );
+                                                       // Allow custom rendering
+                                                       if ( typeof context.config.result.render === 'function' ) {
+                                                               context.config.result.render.call( $result, context.config.suggestions[ i ], context );
+                                                       } else {
+                                                               $result.text( text );
                                                        }
 
-                                                       // Apply new width for results box, if any
-                                                       if ( expWidth > context.data.$container.width() ) {
-                                                               maxWidth = context.config.maxExpandFactor * context.data.$textbox.width();
-                                                               context.data.$container.width( Math.min( expWidth, maxWidth ) );
+                                                       if ( context.config.highlightInput ) {
+                                                               $result.highlightText( context.data.prevText, { method: 'prefixHighlight' } );
                                                        }
-                                               }
-                                       }
-                                       break;
-                               case 'maxRows':
-                                       context.config[ property ] = Math.max( 1, Math.min( 100, value ) );
-                                       break;
-                               case 'delay':
-                                       context.config[ property ] = Math.max( 0, Math.min( 1200, value ) );
-                                       break;
-                               case 'cacheMaxAge':
-                                       context.config[ property ] = Math.max( 1, value );
-                                       break;
-                               case 'maxExpandFactor':
-                                       context.config[ property ] = Math.max( 1, value );
-                                       break;
-                               case 'cache':
-                               case 'submitOnClick':
-                               case 'positionFromLeft':
-                               case 'highlightInput':
-                                       context.config[ property ] = !!value;
-                                       break;
-                       }
-               },
-
-               /**
-                * Highlight a result in the results table
-                *
-                * @param {Object} context
-                * @param {jQuery|string} result `<tr>` to highlight, or 'prev' or 'next'
-                * @param {boolean} updateTextbox If true, put the suggestion in the textbox
-                */
-               highlight: function ( context, result, updateTextbox ) {
-                       var selected = context.data.$container.find( '.suggestions-result-current' );
-                       if ( !result.get || selected.get( 0 ) !== result.get( 0 ) ) {
-                               if ( result === 'prev' ) {
-                                       if ( selected.hasClass( 'suggestions-special' ) ) {
-                                               result = context.data.$container.find( '.suggestions-result:last' );
-                                       } else {
-                                               result = selected.prev();
-                                               if ( !( result.length && result.hasClass( 'suggestions-result' ) ) ) {
-                                                       // there is something in the DOM between selected element and the wrapper, bypass it
-                                                       result = selected.parents( '.suggestions-results > *' ).prev().find( '.suggestions-result' ).eq( 0 );
-                                               }
 
-                                               if ( selected.length === 0 ) {
-                                                       // we are at the beginning, so lets jump to the last item
-                                                       if ( context.data.$container.find( '.suggestions-special' ).html() !== '' ) {
-                                                               result = context.data.$container.find( '.suggestions-special' );
-                                                       } else {
-                                                               result = context.data.$container.find( '.suggestions-results .suggestions-result:last' );
+                                                       // Widen results box if needed (new width is only calculated here, applied later).
+
+                                                       // The monstrosity below accomplishes two things:
+                                                       // * Wraps the text contents in a DOM element, so that we can know its width. There is
+                                                       //   no way to directly access the width of a text node, and we can't use the parent
+                                                       //   node width as it has text-overflow: ellipsis; and overflow: hidden; applied to
+                                                       //   it, which trims it to a smaller width.
+                                                       // * Temporarily applies position: absolute; to the wrapper to pull it out of normal
+                                                       //   document flow. Otherwise the CSS text-overflow: ellipsis; and overflow: hidden;
+                                                       //   rules would cause some browsers (at least all versions of IE from 6 to 11) to
+                                                       //   still report the "trimmed" width. This should not be done in regular CSS
+                                                       //   stylesheets as we don't want this rule to apply to other <span> elements, like
+                                                       //   the ones generated by jquery.highlightText.
+                                                       $spanForWidth = $result.wrapInner( '<span>' ).children();
+                                                       childrenWidth = $spanForWidth.css( 'position', 'absolute' ).outerWidth();
+                                                       $spanForWidth.contents().unwrap();
+
+                                                       if ( childrenWidth > $result.width() && childrenWidth > expWidth ) {
+                                                               // factor in any padding, margin, or border space on the parent
+                                                               expWidth = childrenWidth + ( context.data.$container.width() - $result.width() );
                                                        }
                                                }
-                                       }
-                               } else if ( result === 'next' ) {
-                                       if ( selected.length === 0 ) {
-                                               // No item selected, go to the first one
-                                               result = context.data.$container.find( '.suggestions-results .suggestions-result:first' );
-                                               if ( result.length === 0 && context.data.$container.find( '.suggestions-special' ).html() !== '' ) {
-                                                       // No suggestion exists, go to the special one directly
-                                                       result = context.data.$container.find( '.suggestions-special' );
-                                               }
-                                       } else {
-                                               result = selected.next();
-                                               if ( !( result.length && result.hasClass( 'suggestions-result' ) ) ) {
-                                                       // there is something in the DOM between selected element and the wrapper, bypass it
-                                                       result = selected.parents( '.suggestions-results > *' ).next().find( '.suggestions-result' ).eq( 0 );
-                                               }
 
-                                               if ( selected.hasClass( 'suggestions-special' ) ) {
-                                                       result = $( [] );
-                                               } else if (
-                                                       result.length === 0 &&
-                                                       context.data.$container.find( '.suggestions-special' ).html() !== ''
-                                               ) {
-                                                       // We were at the last item, jump to the specials!
-                                                       result = context.data.$container.find( '.suggestions-special' );
+                                               // Apply new width for results box, if any
+                                               if ( expWidth > context.data.$container.width() ) {
+                                                       maxWidth = context.config.maxExpandFactor * context.data.$textbox.width();
+                                                       context.data.$container.width( Math.min( expWidth, maxWidth ) );
                                                }
                                        }
                                }
-                               selected.removeClass( 'suggestions-result-current' );
-                               result.addClass( 'suggestions-result-current' );
-                       }
-                       if ( updateTextbox ) {
-                               if ( result.length === 0 || result.is( '.suggestions-special' ) ) {
-                                       $.suggestions.restore( context );
+                               break;
+                       case 'maxRows':
+                               context.config[ property ] = Math.max( 1, Math.min( 100, value ) );
+                               break;
+                       case 'delay':
+                               context.config[ property ] = Math.max( 0, Math.min( 1200, value ) );
+                               break;
+                       case 'cacheMaxAge':
+                               context.config[ property ] = Math.max( 1, value );
+                               break;
+                       case 'maxExpandFactor':
+                               context.config[ property ] = Math.max( 1, value );
+                               break;
+                       case 'cache':
+                       case 'submitOnClick':
+                       case 'positionFromLeft':
+                       case 'highlightInput':
+                               context.config[ property ] = !!value;
+                               break;
+               }
+       }
+
+       /**
+        * Respond to keypress event
+        *
+        * @param {jQuery.Event} e
+        * @param {Object} context
+        * @param {number} key Code of key pressed
+        */
+       function keypress( e, context, key ) {
+               var selected,
+                       wasVisible = context.data.$container.is( ':visible' ),
+                       preventDefault = false;
+
+               switch ( key ) {
+                       // Arrow down
+                       case 40:
+                               if ( wasVisible ) {
+                                       highlight( context, 'next', true );
+                                       context.data.selectedWithMouse = false;
                                } else {
-                                       context.data.$textbox.val( result.data( 'text' ) );
-                                       // .val() doesn't call any event handlers, so
-                                       // let the world know what happened
-                                       context.data.$textbox.trigger( 'change' );
+                                       update( context, false );
+                               }
+                               preventDefault = true;
+                               break;
+                       // Arrow up
+                       case 38:
+                               if ( wasVisible ) {
+                                       highlight( context, 'prev', true );
+                                       context.data.selectedWithMouse = false;
                                }
+                               preventDefault = wasVisible;
+                               break;
+                       // Escape
+                       case 27:
+                               hide( context );
+                               restore( context );
+                               cancel( context );
                                context.data.$textbox.trigger( 'change' );
-                       }
-               },
-
-               /**
-                * Respond to keypress event
-                *
-                * @param {jQuery.Event} e
-                * @param {Object} context
-                * @param {number} key Code of key pressed
-                */
-               keypress: function ( e, context, key ) {
-                       var selected,
-                               wasVisible = context.data.$container.is( ':visible' ),
-                               preventDefault = false;
-
-                       switch ( key ) {
-                               // Arrow down
-                               case 40:
-                                       if ( wasVisible ) {
-                                               $.suggestions.highlight( context, 'next', true );
-                                               context.data.selectedWithMouse = false;
-                                       } else {
-                                               $.suggestions.update( context, false );
-                                       }
-                                       preventDefault = true;
-                                       break;
-                               // Arrow up
-                               case 38:
-                                       if ( wasVisible ) {
-                                               $.suggestions.highlight( context, 'prev', true );
-                                               context.data.selectedWithMouse = false;
-                                       }
-                                       preventDefault = wasVisible;
-                                       break;
-                               // Escape
-                               case 27:
-                                       $.suggestions.hide( context );
-                                       $.suggestions.restore( context );
-                                       $.suggestions.cancel( context );
-                                       context.data.$textbox.trigger( 'change' );
-                                       preventDefault = wasVisible;
-                                       break;
-                               // Enter
-                               case 13:
-                                       preventDefault = wasVisible;
-                                       selected = context.data.$container.find( '.suggestions-result-current' );
-                                       $.suggestions.hide( context );
-                                       if ( selected.length === 0 || context.data.selectedWithMouse ) {
-                                               // If nothing is selected or if something was selected with the mouse
-                                               // cancel any current requests and allow the form to be submitted
-                                               // (simply don't prevent default behavior).
-                                               $.suggestions.cancel( context );
-                                               preventDefault = false;
-                                       } else if ( selected.is( '.suggestions-special' ) ) {
-                                               if ( typeof context.config.special.select === 'function' ) {
-                                                       // Allow the callback to decide whether to prevent default or not
-                                                       if ( context.config.special.select.call( selected, context.data.$textbox, 'keyboard' ) === true ) {
-                                                               preventDefault = false;
-                                                       }
+                               preventDefault = wasVisible;
+                               break;
+                       // Enter
+                       case 13:
+                               preventDefault = wasVisible;
+                               selected = context.data.$container.find( '.suggestions-result-current' );
+                               hide( context );
+                               if ( selected.length === 0 || context.data.selectedWithMouse ) {
+                                       // If nothing is selected or if something was selected with the mouse
+                                       // cancel any current requests and allow the form to be submitted
+                                       // (simply don't prevent default behavior).
+                                       cancel( context );
+                                       preventDefault = false;
+                               } else if ( selected.is( '.suggestions-special' ) ) {
+                                       if ( typeof context.config.special.select === 'function' ) {
+                                               // Allow the callback to decide whether to prevent default or not
+                                               if ( context.config.special.select.call( selected, context.data.$textbox, 'keyboard' ) === true ) {
+                                                       preventDefault = false;
                                                }
-                                       } else {
-                                               if ( typeof context.config.result.select === 'function' ) {
-                                                       // Allow the callback to decide whether to prevent default or not
-                                                       if ( context.config.result.select.call( selected, context.data.$textbox, 'keyboard' ) === true ) {
-                                                               preventDefault = false;
-                                                       }
+                                       }
+                               } else {
+                                       if ( typeof context.config.result.select === 'function' ) {
+                                               // Allow the callback to decide whether to prevent default or not
+                                               if ( context.config.result.select.call( selected, context.data.$textbox, 'keyboard' ) === true ) {
+                                                       preventDefault = false;
                                                }
                                        }
-                                       break;
-                               default:
-                                       $.suggestions.update( context, true );
-                                       break;
-                       }
-                       if ( preventDefault ) {
-                               e.preventDefault();
-                               e.stopPropagation();
-                       }
+                               }
+                               break;
+                       default:
+                               update( context, true );
+                               break;
                }
-       };
+               if ( preventDefault ) {
+                       e.preventDefault();
+                       e.stopPropagation();
+               }
+       }
 
        // See file header for method documentation
        $.fn.suggestions = function () {
                                if ( typeof args[ 0 ] === 'object' ) {
                                        // Apply set of properties
                                        for ( key in args[ 0 ] ) {
-                                               $.suggestions.configure( context, key, args[ 0 ][ key ] );
+                                               configure( context, key, args[ 0 ][ key ] );
                                        }
                                } else if ( typeof args[ 0 ] === 'string' ) {
                                        if ( args.length > 1 ) {
                                                // Set property values
-                                               $.suggestions.configure( context, args[ 0 ], args[ 1 ] );
+                                               configure( context, args[ 0 ], args[ 1 ] );
                                        }
                                }
                        }
                                                                if ( $result.get( 0 ) !== $other.get( 0 ) ) {
                                                                        return;
                                                                }
-                                                               $.suggestions.highlight( context, $result, true );
+                                                               highlight( context, $result, true );
                                                                if ( typeof context.config.result.select === 'function' ) {
                                                                        context.config.result.select.call( $result, context.data.$textbox, 'mouse' );
                                                                }
                                                                        // This will hide the link we're just clicking on, which causes problems
                                                                        // when done synchronously in at least Firefox 3.6 (T64858).
                                                                        setTimeout( function () {
-                                                                               $.suggestions.hide( context );
+                                                                               hide( context );
                                                                        } );
                                                                }
                                                                // Always bring focus to the textbox, as that's probably where the user expects it
                                                                        // This will hide the link we're just clicking on, which causes problems
                                                                        // when done synchronously in at least Firefox 3.6 (T64858).
                                                                        setTimeout( function () {
-                                                                               $.suggestions.hide( context );
+                                                                               hide( context );
                                                                        } );
                                                                }
                                                                // Always bring focus to the textbox, as that's probably where the user expects it
                                                        } )
                                                        .on( 'mousemove', function ( e ) {
                                                                context.data.selectedWithMouse = true;
-                                                               $.suggestions.highlight(
+                                                               highlight(
                                                                        context, $( e.target ).closest( '.suggestions-special' ), false
                                                                );
                                                        } )
                                        } )
                                        .on( 'keypress', function ( e ) {
                                                context.data.keypressedCount++;
-                                               $.suggestions.keypress( e, context, context.data.keypressed );
+                                               keypress( e, context, context.data.keypressed );
                                        } )
                                        .on( 'keyup', function ( e ) {
                                                // The keypress event is fired when a key is pressed down and that key normally
                                                        e.which === context.data.keypressed &&
                                                        allowed.indexOf( e.which ) !== -1
                                                ) {
-                                                       $.suggestions.keypress( e, context, context.data.keypressed );
+                                                       keypress( e, context, context.data.keypressed );
                                                }
                                        } )
                                        .on( 'blur', function () {
                                                if ( context.data.mouseDownOn.length > 0 ) {
                                                        return;
                                                }
-                                               $.suggestions.hide( context );
-                                               $.suggestions.cancel( context );
+                                               hide( context );
+                                               cancel( context );
                                        } );
                                // Load suggestions if the value is changed because there are already
                                // typed characters before the JavaScript is loaded.
                                if ( this.value !== this.defaultValue ) {
-                                       $.suggestions.update( context, false );
+                                       update( context, false );
                                }
                        }