Commit RELEASE-NOTES line for the wgCategories js variable I added some time ago.
[lhc/web/wiklou.git] / js2 / mwEmbed / jquery / plugins / jquery.suggestions.js
1 /**
2 * This plugin provides a generic way to add suggestions to a text box
3 * Usage:
4 *
5 * Set options
6 * $('#textbox').suggestions({ option1: value1, option2: value2 });
7 * $('#textbox').suggestions( option, value );
8 * Get option:
9 * value = $('#textbox').suggestions( option );
10 * Initialize:
11 * $('#textbox').suggestions();
12 *
13 * Available options:
14 * animationDuration: How long (in ms) the animated growing of the results box
15 * should take (default: 200)
16 * cancelPending(): Function called when any pending asynchronous suggestions
17 * fetches should be canceled (optional). Executed in the context of the
18 * textbox
19 * delay: Number of ms to wait for the user to stop typing (default: 120)
20 * fetch(query): Callback that should fetch suggestions and set the suggestions
21 * property (required). Executed in the context of the textbox
22 * maxGrowFactor: Maximum width of the suggestions box as a factor of the width
23 * of the textbox (default: 2)
24 * maxRows: Maximum number of suggestion rows to show
25 * submitOnClick: If true, submit the form when a suggestion is clicked
26 * (default: false)
27 * suggestions: Array of suggestions to display (default: [])
28 *
29 */
30 (function($) {
31 $.fn.suggestions = function( param, param2 ) {
32 /**
33 * Handle special keypresses (arrow keys and escape)
34 * @param key Key code
35 */
36 function processKey( key ) {
37 switch ( key ) {
38 case 40:
39 // Arrow down
40 if ( conf._data.div.is( ':visible' ) ) {
41 highlightResult( 'next', true );
42 } else {
43 // Load suggestions right now
44 updateSuggestions( false );
45 }
46 break;
47 case 38:
48 // Arrow up
49 if ( conf._data.div.is( ':visible' ) ) {
50 highlightResult( 'prev', true );
51 }
52 break;
53 case 27:
54 // Escape
55 conf._data.div.hide();
56 restoreText();
57 cancelPendingSuggestions();
58 break;
59 default:
60 updateSuggestions( true );
61 }
62 }
63
64 /**
65 * Restore the text the user originally typed in the textbox,
66 * before it was overwritten by highlightResult(). This restores the
67 * value the currently displayed suggestions are based on, rather than
68 * the value just before highlightResult() overwrote it; the former
69 * is arguably slightly more sensible.
70 */
71 function restoreText() {
72 conf._data.textbox.val( conf._data.prevText );
73 }
74
75 /**
76 * Ask the user-specified callback for new suggestions. Any previous
77 * delayed call to this function still pending will be canceled.
78 * If the value in the textbox hasn't changed since the last time
79 * suggestions were fetched, this function does nothing.
80 * @param delayed If true, delay this by the user-specified delay
81 */
82 function updateSuggestions( delayed ) {
83 // Cancel previous call
84 if ( conf._data.timerID != null )
85 clearTimeout( conf._data.timerID );
86 if ( delayed )
87 setTimeout( doUpdateSuggestions, conf.delay );
88 else
89 doUpdateSuggestions();
90 }
91
92 /**
93 * Delayed part of updateSuggestions()
94 * Don't call this, use updateSuggestions( false ) instead
95 */
96 function doUpdateSuggestions() {
97 if ( conf._data.textbox.val() == conf._data.prevText )
98 // Value in textbox didn't change
99 return;
100
101 conf._data.prevText = conf._data.textbox.val();
102 conf.fetch.call ( conf._data.textbox,
103 conf._data.textbox.val() );
104 }
105
106 /**
107 * Called when the user changes the suggestions post-init.
108 * Typically happens asynchronously from conf.fetch()
109 */
110 function suggestionsChanged() {
111 conf._data.div.show();
112 updateSuggestionsTable();
113 fitContainer();
114 trimResultText();
115 }
116
117 /**
118 * Cancel any delayed updateSuggestions() call and inform the user so
119 * they can cancel their result fetching if they use AJAX or something
120 */
121 function cancelPendingSuggestions() {
122 if ( conf._data.timerID != null )
123 clearTimeout( conf._data.timerID );
124 conf.cancelPending.call( this );
125 }
126
127 /**
128 * Rebuild the suggestions table
129 */
130 function updateSuggestionsTable() {
131 // If there are no suggestions, hide the div
132 if ( conf.suggestions.length == 0 ) {
133 conf._data.div.hide();
134 return;
135 }
136
137 var table = conf._data.div.children( 'table' );
138 table.empty();
139 for ( var i = 0; i < conf.suggestions.length; i++ ) {
140 var td = $( '<td />' ) // FIXME: why use a span?
141 .append( $( '<span />' ).text( conf.suggestions[i] ) );
142 //.addClass( 'os-suggest-result' ); //FIXME: use descendant selector
143 $( '<tr />' )
144 .addClass( 'os-suggest-result' ) // FIXME: use descendant selector
145 .attr( 'rel', i )
146 .data( 'text', conf.suggestions[i] )
147 .append( td )
148 .appendTo( table );
149 }
150 }
151
152 /**
153 * Make the container fit into the screen
154 */
155 function fitContainer() {
156 if ( conf._data.div.is( ':hidden' ) )
157 return;
158
159 // FIXME: Mysterious -20 from mwsuggest.js,
160 // presumably to make room for a scrollbar
161 var availableHeight = $( 'body' ).height() - (
162 Math.round( conf._data.div.offset().top ) -
163 $( document ).scrollTop() ) - 20;
164 var rowHeight = conf._data.div.find( 'tr' ).outerHeight();
165 var numRows = Math.floor( availableHeight / rowHeight );
166
167 // Show at least 2 rows if there are multiple results
168 if ( numRows < 2 && conf.suggestions.length >= 2 )
169 numRows = 2;
170 if ( numRows > conf.maxRows )
171 numRows = conf.maxRows;
172
173 var tableHeight = conf._data.div.find( 'table' ).outerHeight();
174 if ( numRows * rowHeight < tableHeight ) {
175 // The container is too small
176 conf._data.div.height( numRows * rowHeight );
177 conf._data.visibleResults = numRows;
178 } else {
179 // The container is possibly too large
180 conf._data.div.height( tableHeight );
181 conf._data.visibleResults = conf.suggestions.length;
182 }
183 }
184
185 /**
186 * If there are results wider than the container, try to grow the
187 * container or trim them to end with "..."
188 */
189 function trimResultText() {
190 if ( conf._data.div.is( ':hidden' ) )
191 return;
192
193 // Try to grow the container so all results fit
194 // Can't use each() here because the inner function can read
195 // but not write maxWidth for some crazy reason
196 var maxWidth = 0;
197 var spans = conf._data.div.find( 'span' ).get();
198 for ( var i = 0; i < spans.length; i++ )
199 if ( $(spans[i]).outerWidth() > maxWidth )
200 maxWidth = $(spans[i]).outerWidth();
201
202 // FIXME: Some mysterious fixing going on here
203 // FIXME: Left out Opera fix for now
204 // FIXME: This doesn't check that the container won't run off the screen
205 // FIXME: This should try growing to the left instead if no space on the right
206 var fix = 0;
207 if ( conf._data.visibleResults < conf.suggestions.length )
208 fix = 20;
209 //else
210 // fix = operaWidthFix();
211 if ( fix < 4 )
212 // FIXME: Make 4px configurable?
213 fix = 4; // Always pad at least 4px
214 maxWidth += fix;
215
216 var textBoxWidth = conf._data.textbox.outerWidth();
217 var factor = maxWidth / textBoxWidth;
218 if ( factor > conf.maxGrowFactor )
219 factor = conf.maxGrowFactor;
220 if ( factor < 1 )
221 // Don't shrink the container to be smaller
222 // than the textbox
223 factor = 1;
224 var newWidth = Math.round( textBoxWidth * factor );
225 if ( newWidth != conf._data.div.outerWidth() )
226 conf._data.div.animate( { width: newWidth },
227 conf.animationDuration );
228 // FIXME: mwsuggest.js has this inside the if != block
229 // but I don't think that's right
230 newWidth -= fix;
231
232 // If necessary, trim and add ...
233 conf._data.div.find( 'tr' ).each( function() {
234 var span = $(this).find( 'span' );
235 if ( span.outerWidth() > newWidth ) {
236 var span = $(this).find( 'span' );
237 span.text( span.text() + '...' );
238
239 // While it's still too wide and the last
240 // iteration shrunk it, remove the character
241 // before '...'
242 while ( span.outerWidth() > newWidth && span.text().length > 3 ) {
243 span.text( span.text().substring( 0,
244 span.text().length - 4 ) + '...' );
245 }
246 $(this).attr( 'title', $(this).data( 'text' ) );
247 }
248 });
249 }
250
251 /**
252 * Get a jQuery object for the currently highlighted row
253 */
254 function getHighlightedRow() {
255 return conf._data.div.find( '.os-suggest-result-hl' );
256 }
257
258 /**
259 * Highlight a result in the results table
260 * @param result <tr> to highlight: jQuery object, or 'prev' or 'next'
261 * @param updateTextbox If true, put the suggestion in the textbox
262 */
263 function highlightResult( result, updateTextbox ) {
264 // TODO: Use our own class here
265 var selected = getHighlightedRow();
266 if ( !result.get || selected.get( 0 ) != result.get( 0 ) ) {
267 if ( result == 'prev' ) {
268 result = selected.prev();
269 } else if ( result == 'next' ) {
270 if ( selected.size() == 0 )
271 // No item selected, go to the first one
272 result = conf._data.div.find( 'tr:first' );
273 else {
274 result = selected.next();
275 if ( result.size() == 0 )
276 // We were at the last item, stay there
277 result = selected;
278 }
279 }
280
281 selected.removeClass( 'os-suggest-result-hl' );
282 result.addClass( 'os-suggest-result-hl' );
283 }
284
285 if ( updateTextbox ) {
286 if ( result.size() == 0 )
287 restoreText();
288 else
289 conf._data.textbox.val( result.data( 'text' ) );
290 }
291
292 if ( result.size() > 0 && conf._data.visibleResults < conf.suggestions.length ) {
293 // Not all suggestions are visible
294 // Scroll if needed
295
296 // height of a result row
297 var rowHeight = result.outerHeight();
298 // index of first visible element
299 var first = conf._data.div.scrollTop() / rowHeight;
300 // index of last visible element
301 var last = first + conf._data.visibleResults - 1;
302 // index of element to scroll to
303 var to = result.attr( 'rel' );
304
305 if ( to < first )
306 // Need to scroll up
307 conf._data.div.scrollTop( to * rowHeight );
308 else if ( result.attr( 'rel' ) > last )
309 // Need to scroll down
310 conf._data.div.scrollTop( ( to - conf._data.visibleResults + 1 ) * rowHeight );
311 }
312 }
313
314 /**
315 * Initialize the widget
316 */
317 function init() {
318 if ( typeof conf != 'object' || typeof conf._data != 'undefined' )
319 // Configuration not set or init already done
320 return;
321
322 // Set defaults
323 if ( typeof conf.animationDuration == 'undefined' )
324 conf.animationDuration = 200;
325 if ( typeof conf.cancelPending != 'function' )
326 conf.cancelPending = function() {};
327 if ( typeof conf.delay == 'undefined' )
328 conf.delay = 250;
329 if ( typeof conf.maxGrowFactor == 'undefined' )
330 conf.maxGrowFactor = 2;
331 if ( typeof conf.maxRows == 'undefined' )
332 conf.maxRows = 7;
333 if ( typeof conf.submitOnClick == 'undefined' )
334 conf.submitOnClick = false;
335 if ( typeof conf.suggestions != 'object' )
336 conf.suggestions = [];
337
338 conf._data = {};
339 conf._data.textbox = $(this);
340 conf._data.timerID = null; // ID of running timer
341 conf._data.prevText = null; // Text in textbox when suggestions were last fetched
342 conf._data.visibleResults = 0; // Number of results visible without scrolling
343 conf._data.mouseDownOn = $( [] ); // Suggestion the last mousedown event occured on
344
345 // Create container div for suggestions
346 conf._data.div = $( '<div />' )
347 .addClass( 'os-suggest' ) //TODO: use own CSS
348 .css( {
349 top: Math.round( $(this).offset().top ) + this.offsetHeight,
350 left: Math.round( $(this).offset().left ),
351 width: $(this).outerWidth()
352 })
353 .hide()
354 .appendTo( $( 'body' ) );
355
356 // Create results table
357 $( '<table />' )
358 .addClass( 'os-suggest-results' ) // TODO: use descendant selector
359 .width( $(this).outerWidth() ) // TODO: see if we need Opera width fix
360 .appendTo( conf._data.div );
361
362 $(this)
363 // Stop browser autocomplete from interfering
364 .attr( 'autocomplete', 'off')
365 .keydown( function( e ) {
366 // Store key pressed to handle later
367 conf._data.keypressed = (e.keyCode == undefined) ? e.which : e.keyCode;
368 conf._data.keypressed_count = 0;
369 })
370 .keypress( function() {
371 conf._data.keypressed_count++;
372 processKey( conf._data.keypressed );
373 })
374 .keyup( function() {
375 // Some browsers won't throw keypress() for
376 // arrow keys. If we got a keydown and a keyup
377 // without a keypress in between, solve that
378 if (conf._data.keypressed_count == 0 )
379 processKey( conf._data.keypressed );
380 })
381 .blur( function() {
382 // When losing focus because of a mousedown
383 // on a suggestion, don't hide the suggestions
384 if ( conf._data.mouseDownOn.size() > 0 )
385 return;
386 conf._data.div.hide();
387 cancelPendingSuggestions();
388 });
389
390 conf._data.div
391 .mouseover( function( e ) {
392 var tr = $( e.target ).closest( '.os-suggest tr' );
393 highlightResult( tr, false );
394 })
395 // Can't use click() because the container div is hidden
396 // when the textbox loses focus. Instead, listen for a
397 // mousedown followed by a mouseup on the same <tr>
398 .mousedown( function( e ) {
399 var tr = $( e.target ).closest( '.os-suggest tr' );
400 conf._data.mouseDownOn = tr;
401 })
402 .mouseup( function( e ) {
403 var tr = $( e.target ).closest( '.os-suggest tr' );
404 var other = conf._data.mouseDownOn;
405 conf._data.mouseDownOn = $( [] );
406 if ( tr.get( 0 ) != other.get( 0 ) )
407 return;
408
409 highlightResult( tr, true );
410 conf._data.div.hide();
411 conf._data.textbox.focus();
412 if ( conf.submitOnClick )
413 conf._data.textbox.closest( 'form' )
414 .submit();
415 });
416 }
417
418 function getProperty( prop ) {
419 return ( param[0] == '_' ? undefined : conf[param] );
420 }
421
422 function setProperty( prop, value ) {
423 if ( typeof conf == 'undefined' ) {
424 $(this).data( 'suggestionsConfiguration', {} );
425 conf = $(this).data( 'suggestionsConfiguration' );
426 }
427 if ( prop[0] != '_' )
428 conf[prop] = value;
429 if ( prop == 'suggestions' && conf._data )
430 // Setting suggestions post-init
431 suggestionsChanged();
432 }
433
434
435 // Body of suggestions() starts here
436 var conf = $(this).data( 'suggestionsConfiguration' );
437 if ( typeof param == 'object' )
438 return this.each( function() {
439 // Bulk-set properties
440 for ( key in param ) {
441 // Make sure that this in setProperty()
442 // is set right
443 setProperty.call( this, key, param[key] );
444 }
445 });
446 else if ( typeof param == 'string' ) {
447 if ( typeof param2 != 'undefined' )
448 return this.each( function() {
449 setProperty( param, param2 );
450 });
451 else
452 return getProperty( param );
453 } else if ( typeof param != 'undefined' )
454 // Incorrect usage, ignore
455 return this;
456
457 // No parameters given, initialize
458 return this.each( init );
459 };})(jQuery);