follow up patch to r89950 r89952 r89953 r89955 : moved div clearing inside call back...
[lhc/web/wiklou.git] / resources / jquery / jquery.suggestions.js
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. Executed in the context of the
17 * textbox
18 * Type: Function
19 * cancel: Callback function to call when any pending asynchronous suggestions fetches should be canceled.
20 * 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 to e.g. 2, the suggestions box
37 * will never be grown beyond 2 times the width of the textbox.
38 * Type: Number, Range: 1 - infinity, Default: 3
39 * positionFromLeft: Whether to position the suggestion box with the left attribute or the right
40 * Type: Boolean, Default: true
41 * highlightInput: Whether to hightlight matched portions of the input or not
42 * Type: Boolean, Default: false
43 */
44 ( function( $ ) {
45
46 $.suggestions = {
47 /**
48 * Cancel any delayed updateSuggestions() call and inform the user so
49 * they can cancel their result fetching if they use AJAX or something
50 */
51 cancel: function( context ) {
52 if ( context.data.timerID != null ) {
53 clearTimeout( context.data.timerID );
54 }
55 if ( typeof context.config.cancel == 'function' ) {
56 context.config.cancel.call( context.data.$textbox );
57 }
58 },
59 /**
60 * Restore the text the user originally typed in the textbox, before it was overwritten by highlight(). This
61 * restores the value the currently displayed suggestions are based on, rather than the value just before
62 * highlight() overwrote it; the former is arguably slightly more sensible.
63 */
64 restore: function( context ) {
65 context.data.$textbox.val( context.data.prevText );
66 },
67 /**
68 * Ask the user-specified callback for new suggestions. Any previous delayed call to this function still pending
69 * will be canceled. If the value in the textbox is empty or hasn't changed since the last time suggestions were fetched, this
70 * function does nothing.
71 * @param {Boolean} delayed Whether or not to delay this by the currently configured amount of time
72 */
73 update: function( context, delayed ) {
74 // Only fetch if the value in the textbox changed and is not empty
75 // if the textbox is empty then clear the result div, but leave other settings intouched
76 function maybeFetch() {
77 if ( context.data.$textbox.val().length == 0 ) {
78 context.data.$container.hide();
79 context.data.prevText = '';
80 } else if ( context.data.$textbox.val() !== context.data.prevText ) {
81 if ( typeof context.config.fetch == 'function' ) {
82 context.config.fetch.call( context.data.$textbox, context.data.$textbox.val() );
83 }
84 }
85 }
86
87 // Cancel previous call
88 if ( context.data.timerID != null ) {
89 clearTimeout( context.data.timerID );
90 }
91 if ( delayed ) {
92 // Start a new asynchronous call
93 context.data.timerID = setTimeout( maybeFetch, context.config.delay );
94 } else {
95 maybeFetch();
96 }
97 $.suggestions.special( context );
98 },
99 special: function( context ) {
100 // Allow custom rendering - but otherwise don't do any rendering
101 if ( typeof context.config.special.render == 'function' ) {
102 // Wait for the browser to update the value
103 setTimeout( function() {
104 // Render special
105 $special = context.data.$container.find( '.suggestions-special' );
106 context.config.special.render.call( $special, context.data.$textbox.val() );
107 }, 1 );
108 }
109 },
110 /**
111 * Sets the value of a property, and updates the widget accordingly
112 * @param property String Name of property
113 * @param value Mixed Value to set property with
114 */
115 configure: function( context, property, value ) {
116 // Validate creation using fallback values
117 switch( property ) {
118 case 'fetch':
119 case 'cancel':
120 case 'special':
121 case 'result':
122 case '$region':
123 context.config[property] = value;
124 break;
125 case 'suggestions':
126 context.config[property] = value;
127 // Update suggestions
128 if ( typeof context.data !== 'undefined' ) {
129 if ( context.data.$textbox.val().length == 0 ) {
130 // Hide the div when no suggestion exist
131 context.data.$container.hide();
132 } else {
133 // Rebuild the suggestions list
134 context.data.$container.show();
135 // Update the size and position of the list
136 var newCSS = {
137 'top': context.config.$region.offset().top + context.config.$region.outerHeight(),
138 'bottom': 'auto',
139 'width': context.config.$region.outerWidth(),
140 'height': 'auto'
141 };
142 if ( context.config.positionFromLeft ) {
143 newCSS['left'] = context.config.$region.offset().left;
144 newCSS['right'] = 'auto';
145 } else {
146 newCSS['left'] = 'auto';
147 newCSS['right'] = $( 'body' ).width() - ( context.config.$region.offset().left + context.config.$region.outerWidth() );
148 }
149 context.data.$container.css( newCSS );
150 var $results = context.data.$container.children( '.suggestions-results' );
151 $results.empty();
152 var expWidth = -1;
153 var $autoEllipseMe = $( [] );
154 var matchedText = null;
155 for ( var i = 0; i < context.config.suggestions.length; i++ ) {
156 var text = context.config.suggestions[i];
157 var $result = $( '<div />' )
158 .addClass( 'suggestions-result' )
159 .attr( 'rel', i )
160 .data( 'text', context.config.suggestions[i] )
161 .mousemove( function( e ) {
162 context.data.selectedWithMouse = true;
163 $.suggestions.highlight(
164 context, $(this).closest( '.suggestions-results div' ), false
165 );
166 } )
167 .appendTo( $results );
168 // Allow custom rendering
169 if ( typeof context.config.result.render == 'function' ) {
170 context.config.result.render.call( $result, context.config.suggestions[i] );
171 } else {
172 // Add <span> with text
173 if( context.config.highlightInput ) {
174 matchedText = context.data.prevText;
175 }
176 $result.append( $( '<span />' )
177 .css( 'whiteSpace', 'nowrap' )
178 .text( text )
179 );
180
181 // Widen results box if needed
182 // New width is only calculated here, applied later
183 var $span = $result.children( 'span' );
184 if ( $span.outerWidth() > $result.width() && $span.outerWidth() > expWidth ) {
185 // factor in any padding, margin, or border space on the parent
186 expWidth = $span.outerWidth() + ( context.data.$container.width() - $span.parent().width());
187 }
188 $autoEllipseMe = $autoEllipseMe.add( $result );
189 }
190 }
191 // Apply new width for results box, if any
192 if ( expWidth > context.data.$container.width() ) {
193 var maxWidth = context.config.maxExpandFactor*context.data.$textbox.width();
194 context.data.$container.width( Math.min( expWidth, maxWidth ) );
195 }
196 // autoEllipse the results. Has to be done after changing the width
197 $autoEllipseMe.autoEllipsis( { hasSpan: true, tooltip: true, matchText: matchedText } );
198 }
199 }
200 break;
201 case 'maxRows':
202 context.config[property] = Math.max( 1, Math.min( 100, value ) );
203 break;
204 case 'delay':
205 context.config[property] = Math.max( 0, Math.min( 1200, value ) );
206 break;
207 case 'maxExpandFactor':
208 context.config[property] = Math.max( 1, value );
209 break;
210 case 'submitOnClick':
211 case 'positionFromLeft':
212 case 'highlightInput':
213 context.config[property] = value ? true : false;
214 break;
215 }
216 },
217 /**
218 * Highlight a result in the results table
219 * @param result <tr> to highlight: jQuery object, or 'prev' or 'next'
220 * @param updateTextbox If true, put the suggestion in the textbox
221 */
222 highlight: function( context, result, updateTextbox ) {
223 var selected = context.data.$container.find( '.suggestions-result-current' );
224 if ( !result.get || selected.get( 0 ) != result.get( 0 ) ) {
225 if ( result == 'prev' ) {
226 if( selected.is( '.suggestions-special' ) ) {
227 result = context.data.$container.find( '.suggestions-result:last' )
228 } else {
229 result = selected.prev();
230 if ( selected.length == 0 ) {
231 // we are at the begginning, so lets jump to the last item
232 if ( context.data.$container.find( '.suggestions-special' ).html() != "" ) {
233 result = context.data.$container.find( '.suggestions-special' );
234 } else {
235 result = context.data.$container.find( '.suggestions-results div:last' );
236 }
237 }
238 }
239 } else if ( result == 'next' ) {
240 if ( selected.length == 0 ) {
241 // No item selected, go to the first one
242 result = context.data.$container.find( '.suggestions-results div:first' );
243 if ( result.length == 0 && context.data.$container.find( '.suggestions-special' ).html() != "" ) {
244 // No suggestion exists, go to the special one directly
245 result = context.data.$container.find( '.suggestions-special' );
246 }
247 } else {
248 result = selected.next();
249 if ( selected.is( '.suggestions-special' ) ) {
250 result = $( [] );
251 } else if (
252 result.length == 0 &&
253 context.data.$container.find( '.suggestions-special' ).html() != ""
254 ) {
255 // We were at the last item, jump to the specials!
256 result = context.data.$container.find( '.suggestions-special' );
257 }
258 }
259 }
260 selected.removeClass( 'suggestions-result-current' );
261 result.addClass( 'suggestions-result-current' );
262 }
263 if ( updateTextbox ) {
264 if ( result.length == 0 || result.is( '.suggestions-special' ) ) {
265 $.suggestions.restore( context );
266 } else {
267 context.data.$textbox.val( result.data( 'text' ) );
268 // .val() doesn't call any event handlers, so
269 // let the world know what happened
270 context.data.$textbox.change();
271 }
272 context.data.$textbox.trigger( 'change' );
273 }
274 },
275 /**
276 * Respond to keypress event
277 * @param key Integer Code of key pressed
278 */
279 keypress: function( e, context, key ) {
280 var wasVisible = context.data.$container.is( ':visible' );
281 var preventDefault = false;
282 switch ( key ) {
283 // Arrow down
284 case 40:
285 if ( wasVisible ) {
286 $.suggestions.highlight( context, 'next', true );
287 context.data.selectedWithMouse = false;
288 } else {
289 $.suggestions.update( context, false );
290 }
291 preventDefault = true;
292 break;
293 // Arrow up
294 case 38:
295 if ( wasVisible ) {
296 $.suggestions.highlight( context, 'prev', true );
297 context.data.selectedWithMouse = false;
298 }
299 preventDefault = wasVisible;
300 break;
301 // Escape
302 case 27:
303 context.data.$container.hide();
304 $.suggestions.restore( context );
305 $.suggestions.cancel( context );
306 context.data.$textbox.trigger( 'change' );
307 preventDefault = wasVisible;
308 break;
309 // Enter
310 case 13:
311 context.data.$container.hide();
312 preventDefault = wasVisible;
313 selected = context.data.$container.find( '.suggestions-result-current' );
314 if ( selected.size() == 0 || context.data.selectedWithMouse ) {
315 // if nothing is selected OR if something was selected with the mouse,
316 // cancel any current requests and submit the form
317 $.suggestions.cancel( context );
318 context.config.$region.closest( 'form' ).submit();
319 } else if ( selected.is( '.suggestions-special' ) ) {
320 if ( typeof context.config.special.select == 'function' ) {
321 context.config.special.select.call( selected, context.data.$textbox );
322 }
323 } else {
324 if ( typeof context.config.result.select == 'function' ) {
325 $.suggestions.highlight( context, selected, true );
326 context.config.result.select.call( selected, context.data.$textbox );
327 } else {
328 $.suggestions.highlight( context, selected, true );
329 }
330 }
331 break;
332 default:
333 $.suggestions.update( context, true );
334 break;
335 }
336 if ( preventDefault ) {
337 e.preventDefault();
338 e.stopImmediatePropagation();
339 }
340 }
341 };
342 $.fn.suggestions = function() {
343
344 // Multi-context fields
345 var returnValue = null;
346 var args = arguments;
347
348 $(this).each( function() {
349
350 /* Construction / Loading */
351
352 var context = $(this).data( 'suggestions-context' );
353 if ( typeof context == 'undefined' || context == null ) {
354 context = {
355 config: {
356 'fetch' : function() {},
357 'cancel': function() {},
358 'special': {},
359 'result': {},
360 '$region': $(this),
361 'suggestions': [],
362 'maxRows': 7,
363 'delay': 120,
364 'submitOnClick': false,
365 'maxExpandFactor': 3,
366 'positionFromLeft': true,
367 'highlightInput': false
368 }
369 };
370 }
371
372 /* API */
373
374 // Handle various calling styles
375 if ( args.length > 0 ) {
376 if ( typeof args[0] == 'object' ) {
377 // Apply set of properties
378 for ( var key in args[0] ) {
379 $.suggestions.configure( context, key, args[0][key] );
380 }
381 } else if ( typeof args[0] == 'string' ) {
382 if ( args.length > 1 ) {
383 // Set property values
384 $.suggestions.configure( context, args[0], args[1] );
385 } else if ( returnValue == null ) {
386 // Get property values, but don't give access to internal data - returns only the first
387 returnValue = ( args[0] in context.config ? undefined : context.config[args[0]] );
388 }
389 }
390 }
391
392 /* Initialization */
393
394 if ( typeof context.data == 'undefined' ) {
395 context.data = {
396 // ID of running timer
397 'timerID': null,
398 // Text in textbox when suggestions were last fetched
399 'prevText': null,
400 // Number of results visible without scrolling
401 'visibleResults': 0,
402 // Suggestion the last mousedown event occured on
403 'mouseDownOn': $( [] ),
404 '$textbox': $(this),
405 'selectedWithMouse': false
406 };
407 // Setup the css for positioning the results box
408 var newCSS = {
409 'top': Math.round( context.data.$textbox.offset().top + context.data.$textbox.outerHeight() ),
410 'width': context.data.$textbox.outerWidth(),
411 'display': 'none'
412 };
413 if ( context.config.positionFromLeft ) {
414 newCSS['left'] = context.config.$region.offset().left;
415 newCSS['right'] = 'auto';
416 } else {
417 newCSS['left'] = 'auto';
418 newCSS['right'] = $( 'body' ).width() - ( context.config.$region.offset().left + context.config.$region.outerWidth() );
419 }
420
421 context.data.$container = $( '<div />' )
422 .css( newCSS )
423 .addClass( 'suggestions' )
424 .append(
425 $( '<div />' ).addClass( 'suggestions-results' )
426 // Can't use click() because the container div is hidden when the textbox loses focus. Instead,
427 // listen for a mousedown followed by a mouseup on the same div
428 .mousedown( function( e ) {
429 context.data.mouseDownOn = $( e.target ).closest( '.suggestions-results div' );
430 } )
431 .mouseup( function( e ) {
432 var $result = $( e.target ).closest( '.suggestions-results div' );
433 var $other = context.data.mouseDownOn;
434 context.data.mouseDownOn = $( [] );
435 if ( $result.get( 0 ) != $other.get( 0 ) ) {
436 return;
437 }
438 $.suggestions.highlight( context, $result, true );
439 context.data.$container.hide();
440 if ( typeof context.config.result.select == 'function' ) {
441 context.config.result.select.call( $result, context.data.$textbox );
442 }
443 context.data.$textbox.focus();
444 } )
445 )
446 .append(
447 $( '<div />' ).addClass( 'suggestions-special' )
448 // Can't use click() because the container div is hidden when the textbox loses focus. Instead,
449 // listen for a mousedown followed by a mouseup on the same div
450 .mousedown( function( e ) {
451 context.data.mouseDownOn = $( e.target ).closest( '.suggestions-special' );
452 } )
453 .mouseup( function( e ) {
454 var $special = $( e.target ).closest( '.suggestions-special' );
455 var $other = context.data.mouseDownOn;
456 context.data.mouseDownOn = $( [] );
457 if ( $special.get( 0 ) != $other.get( 0 ) ) {
458 return;
459 }
460 context.data.$container.hide();
461 if ( typeof context.config.special.select == 'function' ) {
462 context.config.special.select.call( $special, context.data.$textbox );
463 }
464 context.data.$textbox.focus();
465 } )
466 .mousemove( function( e ) {
467 context.data.selectedWithMouse = true;
468 $.suggestions.highlight(
469 context, $( e.target ).closest( '.suggestions-special' ), false
470 );
471 } )
472 )
473 .appendTo( $( 'body' ) );
474 $(this)
475 // Stop browser autocomplete from interfering
476 .attr( 'autocomplete', 'off')
477 .keydown( function( e ) {
478 // Store key pressed to handle later
479 context.data.keypressed = ( e.keyCode == undefined ) ? e.which : e.keyCode;
480 context.data.keypressedCount = 0;
481
482 switch ( context.data.keypressed ) {
483 // This preventDefault logic is duplicated from
484 // $.suggestions.keypress(), which sucks
485 case 40:
486 e.preventDefault();
487 e.stopImmediatePropagation();
488 break;
489 case 38:
490 case 27:
491 case 13:
492 if ( context.data.$container.is( ':visible' ) ) {
493 e.preventDefault();
494 e.stopImmediatePropagation();
495 }
496 }
497 } )
498 .keypress( function( e ) {
499 context.data.keypressedCount++;
500 $.suggestions.keypress( e, context, context.data.keypressed );
501 } )
502 .keyup( function( e ) {
503 // Some browsers won't throw keypress() for arrow keys. If we got a keydown and a keyup without a
504 // keypress in between, solve it
505 if ( context.data.keypressedCount == 0 ) {
506 $.suggestions.keypress( e, context, context.data.keypressed );
507 }
508 } )
509 .blur( function() {
510 // When losing focus because of a mousedown
511 // on a suggestion, don't hide the suggestions
512 if ( context.data.mouseDownOn.length > 0 ) {
513 return;
514 }
515 context.data.$container.hide();
516 $.suggestions.cancel( context );
517 } );
518 }
519 // Store the context for next time
520 $(this).data( 'suggestions-context', context );
521 } );
522 return returnValue !== null ? returnValue : $(this);
523 };
524 } )( jQuery );