Kill DeviceDetection
[mediawiki.git] / resources / jquery / jquery.suggestions.js
blobedc18a79401194474024de9f09c56fcfa6689d2c
1 /**
2  * This plugin provides a generic way to add suggestions to a text box.
3  *
4  * Usage:
5  *
6  * Set options:
7  *              $( '#textbox' ).suggestions( { option1: value1, option2: value2 } );
8  *              $( '#textbox' ).suggestions( option, value );
9  * Get option:
10  *              value = $( '#textbox' ).suggestions( option );
11  * Initialize:
12  *              $( '#textbox' ).suggestions();
13  *
14  * Options:
15  *
16  * fetch(query): Callback that should fetch suggestions and set the suggestions property.
17  *      Executed in the context of the textbox
18  *              Type: Function
19  * cancel: Callback function to call when any pending asynchronous suggestions fetches
20  *      should be canceled. Executed in the context of the textbox
21  *              Type: Function
22  * special: Set of callbacks for rendering and selecting
23  *              Type: Object of Functions 'render' and 'select'
24  * result: Set of callbacks for rendering and selecting
25  *              Type: Object of Functions 'render' and 'select'
26  * $region: jQuery selection of element to place the suggestions below and match width of
27  *              Type: jQuery Object, Default: $(this)
28  * suggestions: Suggestions to display
29  *              Type: Array of strings
30  * maxRows: Maximum number of suggestions to display at one time
31  *              Type: Number, Range: 1 - 100, Default: 7
32  * delay: Number of ms to wait for the user to stop typing
33  *              Type: Number, Range: 0 - 1200, Default: 120
34  * submitOnClick: Whether to submit the form containing the textbox when a suggestion is clicked
35  *              Type: Boolean, Default: false
36  * maxExpandFactor: Maximum suggestions box width relative to the textbox width. If set
37  *      to e.g. 2, the suggestions box will never be grown beyond 2 times the width of the textbox.
38  *              Type: Number, Range: 1 - infinity, Default: 3
39  * expandFrom: Which direction to offset the suggestion box from.
40  *      Values 'start' and 'end' translate to left and right respectively depending on the
41  *      directionality of the current document, according to $( 'html' ).css( 'direction' ).
42  *      Type: String, default: 'auto', options: 'left', 'right', 'start', 'end', 'auto'.
43  * positionFromLeft: Sets expandFrom=left, for backwards compatibility
44  *              Type: Boolean, Default: true
45  * highlightInput: Whether to hightlight matched portions of the input or not
46  *              Type: Boolean, Default: false
47  */
48 ( function ( $ ) {
50 $.suggestions = {
51         /**
52          * Cancel any delayed updateSuggestions() call and inform the user so
53          * they can cancel their result fetching if they use AJAX or something
54          */
55         cancel: function ( context ) {
56                 if ( context.data.timerID !== null ) {
57                         clearTimeout( context.data.timerID );
58                 }
59                 if ( $.isFunction( context.config.cancel ) ) {
60                         context.config.cancel.call( context.data.$textbox );
61                 }
62         },
64         /**
65          * Restore the text the user originally typed in the textbox, before it
66          * was overwritten by highlight(). This restores the value the currently
67          * displayed suggestions are based on, rather than the value just before
68          * highlight() overwrote it; the former is arguably slightly more sensible.
69          */
70         restore: function ( context ) {
71                 context.data.$textbox.val( context.data.prevText );
72         },
74         /**
75          * Ask the user-specified callback for new suggestions. Any previous delayed
76          * call to this function still pending will be canceled. If the value in the
77          * textbox is empty or hasn't changed since the last time suggestions were fetched,
78          * this function does nothing.
79          * @param {Boolean} delayed Whether or not to delay this by the currently configured amount of time
80          */
81         update: function ( context, delayed ) {
82                 // Only fetch if the value in the textbox changed and is not empty
83                 // if the textbox is empty then clear the result div, but leave other settings intouched
84                 function maybeFetch() {
85                         if ( context.data.$textbox.val().length === 0 ) {
86                                 context.data.$container.hide();
87                                 context.data.prevText = '';
88                         } else if ( context.data.$textbox.val() !== context.data.prevText ) {
89                                 if ( typeof context.config.fetch === 'function' ) {
90                                         context.data.prevText = context.data.$textbox.val();
91                                         context.config.fetch.call( context.data.$textbox, context.data.$textbox.val() );
92                                 }
93                         }
94                 }
96                 // Cancel previous call
97                 if ( context.data.timerID !== null ) {
98                         clearTimeout( context.data.timerID );
99                 }
100                 if ( delayed ) {
101                         // Start a new asynchronous call
102                         context.data.timerID = setTimeout( maybeFetch, context.config.delay );
103                 } else {
104                         maybeFetch();
105                 }
106                 $.suggestions.special( context );
107         },
109         special: function ( context ) {
110                 // Allow custom rendering - but otherwise don't do any rendering
111                 if ( typeof context.config.special.render === 'function' ) {
112                         // Wait for the browser to update the value
113                         setTimeout( function () {
114                                 // Render special
115                                 var $special = context.data.$container.find( '.suggestions-special' );
116                                 context.config.special.render.call( $special, context.data.$textbox.val() );
117                         }, 1 );
118                 }
119         },
121         /**
122          * Sets the value of a property, and updates the widget accordingly
123          * @param property String Name of property
124          * @param value Mixed Value to set property with
125          */
126         configure: function ( context, property, value ) {
127                 var newCSS,
128                         $autoEllipseMe, $result, $results, $span,
129                         i, expWidth, matchedText, maxWidth, text;
131                 // Validate creation using fallback values
132                 switch( property ) {
133                         case 'fetch':
134                         case 'cancel':
135                         case 'special':
136                         case 'result':
137                         case '$region':
138                         case 'expandFrom':
139                                 context.config[property] = value;
140                                 break;
141                         case 'suggestions':
142                                 context.config[property] = value;
143                                 // Update suggestions
144                                 if ( context.data !== undefined ) {
145                                         if ( context.data.$textbox.val().length === 0 ) {
146                                                 // Hide the div when no suggestion exist
147                                                 context.data.$container.hide();
148                                         } else {
149                                                 // Rebuild the suggestions list
150                                                 context.data.$container.show();
151                                                 // Update the size and position of the list
152                                                 newCSS = {
153                                                         top: context.config.$region.offset().top + context.config.$region.outerHeight(),
154                                                         bottom: 'auto',
155                                                         width: context.config.$region.outerWidth(),
156                                                         height: 'auto'
157                                                 };
159                                                 // Process expandFrom, after this it is set to left or right.
160                                                 context.config.expandFrom = ( function ( expandFrom ) {
161                                                         var regionWidth, docWidth, regionCenter, docCenter,
162                                                                 docDir = $( document.documentElement ).css( 'direction' ),
163                                                                 $region = context.config.$region;
165                                                         // Backwards compatible
166                                                         if ( context.config.positionFromLeft ) {
167                                                                 expandFrom = 'left';
169                                                         // Catch invalid values, default to 'auto'
170                                                         } else if ( $.inArray( expandFrom, ['left', 'right', 'start', 'end', 'auto'] ) === -1 ) {
171                                                                 expandFrom = 'auto';
172                                                         }
174                                                         if ( expandFrom === 'auto' ) {
175                                                                 if ( $region.data( 'searchsuggest-expand-dir' ) ) {
176                                                                         // If the markup explicitly contains a direction, use it.
177                                                                         expandFrom = $region.data( 'searchsuggest-expand-dir' );
178                                                                 } else {
179                                                                         regionWidth = $region.outerWidth();
180                                                                         docWidth = $( document ).width();
181                                                                         if ( ( regionWidth / docWidth  ) > 0.85 ) {
182                                                                                 // If the input size takes up more than 85% of the document horizontally
183                                                                                 // expand the suggestions to the writing direction's native end.
184                                                                                 expandFrom = 'start';
185                                                                         } else {
186                                                                                 // Calculate the center points of the input and document
187                                                                                 regionCenter = $region.offset().left + regionWidth / 2;
188                                                                                 docCenter = docWidth / 2;
189                                                                                 if ( Math.abs( regionCenter - docCenter ) / docCenter < 0.10 ) {
190                                                                                         // If the input's center is within 10% of the document center
191                                                                                         // use the writing direction's native end.
192                                                                                         expandFrom = 'start';
193                                                                                 } else {
194                                                                                         // Otherwise expand the input from the closest side of the page,
195                                                                                         // towards the side of the page with the most free open space
196                                                                                         expandFrom = regionCenter > docCenter ? 'right' : 'left';
197                                                                                 }
198                                                                         }
199                                                                 }
200                                                         }
202                                                         if ( expandFrom === 'start' ) {
203                                                                 expandFrom = docDir === 'rtl' ? 'right': 'left';
205                                                         } else if ( expandFrom === 'end' ) {
206                                                                 expandFrom = docDir === 'rtl' ? 'left': 'right';
207                                                         }
209                                                         return expandFrom;
211                                                 }( context.config.expandFrom ) );
213                                                 if ( context.config.expandFrom === 'left' ) {
214                                                         // Expand from left
215                                                         newCSS.left = context.config.$region.offset().left;
216                                                         newCSS.right = 'auto';
217                                                 } else {
218                                                         // Expand from right
219                                                         newCSS.left = 'auto';
220                                                         newCSS.right = $( 'body' ).width() - ( context.config.$region.offset().left + context.config.$region.outerWidth() );
221                                                 }
223                                                 context.data.$container.css( newCSS );
224                                                 $results = context.data.$container.children( '.suggestions-results' );
225                                                 $results.empty();
226                                                 expWidth = -1;
227                                                 $autoEllipseMe = $( [] );
228                                                 matchedText = null;
229                                                 for ( i = 0; i < context.config.suggestions.length; i++ ) {
230                                                         /*jshint loopfunc:true */
231                                                         text = context.config.suggestions[i];
232                                                         $result = $( '<div>' )
233                                                                 .addClass( 'suggestions-result' )
234                                                                 .attr( 'rel', i )
235                                                                 .data( 'text', context.config.suggestions[i] )
236                                                                 .mousemove( function () {
237                                                                         context.data.selectedWithMouse = true;
238                                                                         $.suggestions.highlight(
239                                                                                 context,
240                                                                                 $(this).closest( '.suggestions-results div' ),
241                                                                                 false
242                                                                         );
243                                                                 } )
244                                                                 .appendTo( $results );
245                                                         // Allow custom rendering
246                                                         if ( typeof context.config.result.render === 'function' ) {
247                                                                 context.config.result.render.call( $result, context.config.suggestions[i] );
248                                                         } else {
249                                                                 // Add <span> with text
250                                                                 if( context.config.highlightInput ) {
251                                                                         matchedText = context.data.prevText;
252                                                                 }
253                                                                 $result.append( $( '<span>' )
254                                                                                 .css( 'whiteSpace', 'nowrap' )
255                                                                                 .text( text )
256                                                                         );
258                                                                 // Widen results box if needed
259                                                                 // New width is only calculated here, applied later
260                                                                 $span = $result.children( 'span' );
261                                                                 if ( $span.outerWidth() > $result.width() && $span.outerWidth() > expWidth ) {
262                                                                         // factor in any padding, margin, or border space on the parent
263                                                                         expWidth = $span.outerWidth() + ( context.data.$container.width() - $span.parent().width());
264                                                                 }
265                                                                 $autoEllipseMe = $autoEllipseMe.add( $result );
266                                                         }
267                                                 }
268                                                 // Apply new width for results box, if any
269                                                 if ( expWidth > context.data.$container.width() ) {
270                                                         maxWidth = context.config.maxExpandFactor*context.data.$textbox.width();
271                                                         context.data.$container.width( Math.min( expWidth, maxWidth ) );
272                                                 }
273                                                 // autoEllipse the results. Has to be done after changing the width
274                                                 $autoEllipseMe.autoEllipsis( {
275                                                         hasSpan: true,
276                                                         tooltip: true,
277                                                         matchText: matchedText
278                                                 } );
279                                         }
280                                 }
281                                 break;
282                         case 'maxRows':
283                                 context.config[property] = Math.max( 1, Math.min( 100, value ) );
284                                 break;
285                         case 'delay':
286                                 context.config[property] = Math.max( 0, Math.min( 1200, value ) );
287                                 break;
288                         case 'maxExpandFactor':
289                                 context.config[property] = Math.max( 1, value );
290                                 break;
291                         case 'submitOnClick':
292                         case 'positionFromLeft':
293                         case 'highlightInput':
294                                 context.config[property] = value ? true : false;
295                                 break;
296                 }
297         },
299         /**
300          * Highlight a result in the results table
301          * @param result <tr> to highlight: jQuery object, or 'prev' or 'next'
302          * @param updateTextbox If true, put the suggestion in the textbox
303          */
304         highlight: function ( context, result, updateTextbox ) {
305                 var selected = context.data.$container.find( '.suggestions-result-current' );
306                 if ( !result.get || selected.get( 0 ) !== result.get( 0 ) ) {
307                         if ( result === 'prev' ) {
308                                 if( selected.is( '.suggestions-special' ) ) {
309                                         result = context.data.$container.find( '.suggestions-result:last' );
310                                 } else {
311                                         result = selected.prev();
312                                         if ( selected.length === 0 ) {
313                                                 // we are at the beginning, so lets jump to the last item
314                                                 if ( context.data.$container.find( '.suggestions-special' ).html() !== '' ) {
315                                                         result = context.data.$container.find( '.suggestions-special' );
316                                                 } else {
317                                                         result = context.data.$container.find( '.suggestions-results div:last' );
318                                                 }
319                                         }
320                                 }
321                         } else if ( result === 'next' ) {
322                                 if ( selected.length === 0 ) {
323                                         // No item selected, go to the first one
324                                         result = context.data.$container.find( '.suggestions-results div:first' );
325                                         if ( result.length === 0 && context.data.$container.find( '.suggestions-special' ).html() !== '' ) {
326                                                 // No suggestion exists, go to the special one directly
327                                                 result = context.data.$container.find( '.suggestions-special' );
328                                         }
329                                 } else {
330                                         result = selected.next();
331                                         if ( selected.is( '.suggestions-special' ) ) {
332                                                 result = $( [] );
333                                         } else if (
334                                                 result.length === 0 &&
335                                                 context.data.$container.find( '.suggestions-special' ).html() !== ''
336                                         ) {
337                                                 // We were at the last item, jump to the specials!
338                                                 result = context.data.$container.find( '.suggestions-special' );
339                                         }
340                                 }
341                         }
342                         selected.removeClass( 'suggestions-result-current' );
343                         result.addClass( 'suggestions-result-current' );
344                 }
345                 if ( updateTextbox ) {
346                         if ( result.length === 0 || result.is( '.suggestions-special' ) ) {
347                                 $.suggestions.restore( context );
348                         } else {
349                                 context.data.$textbox.val( result.data( 'text' ) );
350                                 // .val() doesn't call any event handlers, so
351                                 // let the world know what happened
352                                 context.data.$textbox.change();
353                         }
354                         context.data.$textbox.trigger( 'change' );
355                 }
356         },
358         /**
359          * Respond to keypress event
360          * @param key Integer Code of key pressed
361          */
362         keypress: function ( e, context, key ) {
363                 var selected,
364                         wasVisible = context.data.$container.is( ':visible' ),
365                         preventDefault = false;
367                 switch ( key ) {
368                         // Arrow down
369                         case 40:
370                                 if ( wasVisible ) {
371                                         $.suggestions.highlight( context, 'next', true );
372                                         context.data.selectedWithMouse = false;
373                                 } else {
374                                         $.suggestions.update( context, false );
375                                 }
376                                 preventDefault = true;
377                                 break;
378                         // Arrow up
379                         case 38:
380                                 if ( wasVisible ) {
381                                         $.suggestions.highlight( context, 'prev', true );
382                                         context.data.selectedWithMouse = false;
383                                 }
384                                 preventDefault = wasVisible;
385                                 break;
386                         // Escape
387                         case 27:
388                                 context.data.$container.hide();
389                                 $.suggestions.restore( context );
390                                 $.suggestions.cancel( context );
391                                 context.data.$textbox.trigger( 'change' );
392                                 preventDefault = wasVisible;
393                                 break;
394                         // Enter
395                         case 13:
396                                 context.data.$container.hide();
397                                 preventDefault = wasVisible;
398                                 selected = context.data.$container.find( '.suggestions-result-current' );
399                                 if ( selected.length === 0 || context.data.selectedWithMouse ) {
400                                         // if nothing is selected OR if something was selected with the mouse,
401                                         // cancel any current requests and submit the form
402                                         $.suggestions.cancel( context );
403                                         context.config.$region.closest( 'form' ).submit();
404                                 } else if ( selected.is( '.suggestions-special' ) ) {
405                                         if ( typeof context.config.special.select === 'function' ) {
406                                                 context.config.special.select.call( selected, context.data.$textbox );
407                                         }
408                                 } else {
409                                         if ( typeof context.config.result.select === 'function' ) {
410                                                 $.suggestions.highlight( context, selected, true );
411                                                 context.config.result.select.call( selected, context.data.$textbox );
412                                         } else {
413                                                 $.suggestions.highlight( context, selected, true );
414                                         }
415                                 }
416                                 break;
417                         default:
418                                 $.suggestions.update( context, true );
419                                 break;
420                 }
421                 if ( preventDefault ) {
422                         e.preventDefault();
423                         e.stopImmediatePropagation();
424                 }
425         }
427 $.fn.suggestions = function () {
429         // Multi-context fields
430         var returnValue,
431                 args = arguments;
433         $(this).each( function () {
434                 var context, key;
436                 /* Construction / Loading */
438                 context = $(this).data( 'suggestions-context' );
439                 if ( context === undefined || context === null ) {
440                         context = {
441                                 config: {
442                                         fetch: function () {},
443                                         cancel: function () {},
444                                         special: {},
445                                         result: {},
446                                         $region: $(this),
447                                         suggestions: [],
448                                         maxRows: 7,
449                                         delay: 120,
450                                         submitOnClick: false,
451                                         maxExpandFactor: 3,
452                                         expandFrom: 'auto',
453                                         highlightInput: false
454                                 }
455                         };
456                 }
458                 /* API */
460                 // Handle various calling styles
461                 if ( args.length > 0 ) {
462                         if ( typeof args[0] === 'object' ) {
463                                 // Apply set of properties
464                                 for ( key in args[0] ) {
465                                         $.suggestions.configure( context, key, args[0][key] );
466                                 }
467                         } else if ( typeof args[0] === 'string' ) {
468                                 if ( args.length > 1 ) {
469                                         // Set property values
470                                         $.suggestions.configure( context, args[0], args[1] );
471                                 } else if ( returnValue === null || returnValue === undefined ) {
472                                         // Get property values, but don't give access to internal data - returns only the first
473                                         returnValue = ( args[0] in context.config ? undefined : context.config[args[0]] );
474                                 }
475                         }
476                 }
478                 /* Initialization */
480                 if ( context.data === undefined ) {
481                         context.data = {
482                                 // ID of running timer
483                                 timerID: null,
485                                 // Text in textbox when suggestions were last fetched
486                                 prevText: null,
488                                 // Number of results visible without scrolling
489                                 visibleResults: 0,
491                                 // Suggestion the last mousedown event occured on
492                                 mouseDownOn: $( [] ),
493                                 $textbox: $(this),
494                                 selectedWithMouse: false
495                         };
497                         context.data.$container = $( '<div>' )
498                                 .css( 'display', 'none' )
499                                 .addClass( 'suggestions' )
500                                 .append(
501                                         $( '<div>' ).addClass( 'suggestions-results' )
502                                                 // Can't use click() because the container div is hidden when the
503                                                 // textbox loses focus. Instead, listen for a mousedown followed
504                                                 // by a mouseup on the same div.
505                                                 .mousedown( function ( e ) {
506                                                         context.data.mouseDownOn = $( e.target ).closest( '.suggestions-results div' );
507                                                 } )
508                                                 .mouseup( function ( e ) {
509                                                         var $result = $( e.target ).closest( '.suggestions-results div' ),
510                                                                 $other = context.data.mouseDownOn;
512                                                         context.data.mouseDownOn = $( [] );
513                                                         if ( $result.get( 0 ) !== $other.get( 0 ) ) {
514                                                                 return;
515                                                         }
516                                                         $.suggestions.highlight( context, $result, true );
517                                                         context.data.$container.hide();
518                                                         if ( typeof context.config.result.select === 'function' ) {
519                                                                 context.config.result.select.call( $result, context.data.$textbox );
520                                                         }
521                                                         context.data.$textbox.focus();
522                                                 } )
523                                 )
524                                 .append(
525                                         $( '<div>' ).addClass( 'suggestions-special' )
526                                                 // Can't use click() because the container div is hidden when the
527                                                 // textbox loses focus. Instead, listen for a mousedown followed
528                                                 // by a mouseup on the same div.
529                                                 .mousedown( function ( e ) {
530                                                         context.data.mouseDownOn = $( e.target ).closest( '.suggestions-special' );
531                                                 } )
532                                                 .mouseup( function ( e ) {
533                                                         var $special = $( e.target ).closest( '.suggestions-special' ),
534                                                                 $other = context.data.mouseDownOn;
536                                                         context.data.mouseDownOn = $( [] );
537                                                         if ( $special.get( 0 ) !== $other.get( 0 ) ) {
538                                                                 return;
539                                                         }
540                                                         context.data.$container.hide();
541                                                         if ( typeof context.config.special.select === 'function' ) {
542                                                                 context.config.special.select.call( $special, context.data.$textbox );
543                                                         }
544                                                         context.data.$textbox.focus();
545                                                 } )
546                                                 .mousemove( function ( e ) {
547                                                         context.data.selectedWithMouse = true;
548                                                         $.suggestions.highlight(
549                                                                 context, $( e.target ).closest( '.suggestions-special' ), false
550                                                         );
551                                                 } )
552                                 )
553                                 .appendTo( $( 'body' ) );
555                         $(this)
556                                 // Stop browser autocomplete from interfering
557                                 .attr( 'autocomplete', 'off')
558                                 .keydown( function ( e ) {
559                                         // Store key pressed to handle later
560                                         context.data.keypressed = e.which;
561                                         context.data.keypressedCount = 0;
563                                         switch ( context.data.keypressed ) {
564                                                 // This preventDefault logic is duplicated from
565                                                 // $.suggestions.keypress(), which sucks
566                                                 case 40:
567                                                         e.preventDefault();
568                                                         e.stopImmediatePropagation();
569                                                         break;
570                                                 case 38:
571                                                 case 27:
572                                                 case 13:
573                                                         if ( context.data.$container.is( ':visible' ) ) {
574                                                                 e.preventDefault();
575                                                                 e.stopImmediatePropagation();
576                                                         }
577                                         }
578                                 } )
579                                 .keypress( function ( e ) {
580                                         context.data.keypressedCount++;
581                                         $.suggestions.keypress( e, context, context.data.keypressed );
582                                 } )
583                                 .keyup( function ( e ) {
584                                         // Some browsers won't throw keypress() for arrow keys. If we got a keydown and a keyup without a
585                                         // keypress in between, solve it
586                                         if ( context.data.keypressedCount === 0 ) {
587                                                 $.suggestions.keypress( e, context, context.data.keypressed );
588                                         }
589                                 } )
590                                 .blur( function () {
591                                         // When losing focus because of a mousedown
592                                         // on a suggestion, don't hide the suggestions
593                                         if ( context.data.mouseDownOn.length > 0 ) {
594                                                 return;
595                                         }
596                                         context.data.$container.hide();
597                                         $.suggestions.cancel( context );
598                                 } );
599                 }
601                 // Store the context for next time
602                 $(this).data( 'suggestions-context', context );
603         } );
604         return returnValue !== undefined ? returnValue : $(this);
607 }( jQuery ) );