2 * This plugin provides a generic way to add suggestions to a text box
6 * $('#textbox').suggestions({ option1: value1, option2: value2 });
7 * $('#textbox').suggestions( option, value );
9 * value = $('#textbox').suggestions( option );
11 * $('#textbox').suggestions();
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
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
27 * suggestions: Array of suggestions to display (default: [])
31 $.fn
.suggestions = function( param
, param2
) {
33 * Handle special keypresses (arrow keys and escape)
36 function processKey( key
) {
40 if ( conf
._data
.div
.is( ':visible' ) ) {
41 highlightResult( 'next', true );
43 // Load suggestions right now
44 updateSuggestions( false );
49 if ( conf
._data
.div
.is( ':visible' ) ) {
50 highlightResult( 'prev', true );
55 conf
._data
.div
.hide();
57 cancelPendingSuggestions();
60 updateSuggestions( true );
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.
71 function restoreText() {
72 conf
._data
.textbox
.val( conf
._data
.prevText
);
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
82 function updateSuggestions( delayed
) {
83 // Cancel previous call
84 if ( conf
._data
.timerID
!= null )
85 clearTimeout( conf
._data
.timerID
);
87 setTimeout( doUpdateSuggestions
, conf
.delay
);
89 doUpdateSuggestions();
93 * Delayed part of updateSuggestions()
94 * Don't call this, use updateSuggestions( false ) instead
96 function doUpdateSuggestions() {
97 if ( conf
._data
.textbox
.val() == conf
._data
.prevText
)
98 // Value in textbox didn't change
101 conf
._data
.prevText
= conf
._data
.textbox
.val();
102 conf
.fetch
.call ( conf
._data
.textbox
,
103 conf
._data
.textbox
.val() );
107 * Called when the user changes the suggestions post-init.
108 * Typically happens asynchronously from conf.fetch()
110 function suggestionsChanged() {
111 conf
._data
.div
.show();
112 updateSuggestionsTable();
118 * Cancel any delayed updateSuggestions() call and inform the user so
119 * they can cancel their result fetching if they use AJAX or something
121 function cancelPendingSuggestions() {
122 if ( conf
._data
.timerID
!= null )
123 clearTimeout( conf
._data
.timerID
);
124 conf
.cancelPending
.call( this );
128 * Rebuild the suggestions table
130 function updateSuggestionsTable() {
131 // If there are no suggestions, hide the div
132 if ( conf
.suggestions
.length
== 0 ) {
133 conf
._data
.div
.hide();
137 var table
= conf
._data
.div
.children( 'table' );
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
144 .addClass( 'os-suggest-result' ) // FIXME: use descendant selector
146 .data( 'text', conf
.suggestions
[i
] )
153 * Make the container fit into the screen
155 function fitContainer() {
156 if ( conf
._data
.div
.is( ':hidden' ) )
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
);
167 // Show at least 2 rows if there are multiple results
168 if ( numRows
< 2 && conf
.suggestions
.length
>= 2 )
170 if ( numRows
> conf
.maxRows
)
171 numRows
= conf
.maxRows
;
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
;
179 // The container is possibly too large
180 conf
._data
.div
.height( tableHeight
);
181 conf
._data
.visibleResults
= conf
.suggestions
.length
;
186 * If there are results wider than the container, try to grow the
187 * container or trim them to end with "..."
189 function trimResultText() {
190 if ( conf
._data
.div
.is( ':hidden' ) )
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
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();
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
207 if ( conf
._data
.visibleResults
< conf
.suggestions
.length
)
210 // fix = operaWidthFix();
212 // FIXME: Make 4px configurable?
213 fix
= 4; // Always pad at least 4px
216 var textBoxWidth
= conf
._data
.textbox
.outerWidth();
217 var factor
= maxWidth
/ textBoxWidth
;
218 if ( factor
> conf
.maxGrowFactor
)
219 factor
= conf
.maxGrowFactor
;
221 // Don't shrink the container to be smaller
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
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() + '...' );
239 // While it's still too wide and the last
240 // iteration shrunk it, remove the character
242 while ( span
.outerWidth() > newWidth
&& span
.text().length
> 3 ) {
243 span
.text( span
.text().substring( 0,
244 span
.text().length
- 4 ) + '...' );
246 $(this).attr( 'title', $(this).data( 'text' ) );
252 * Get a jQuery object for the currently highlighted row
254 function getHighlightedRow() {
255 return conf
._data
.div
.find( '.os-suggest-result-hl' );
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
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' );
274 result
= selected
.next();
275 if ( result
.size() == 0 )
276 // We were at the last item, stay there
281 selected
.removeClass( 'os-suggest-result-hl' );
282 result
.addClass( 'os-suggest-result-hl' );
285 if ( updateTextbox
) {
286 if ( result
.size() == 0 )
289 conf
._data
.textbox
.val( result
.data( 'text' ) );
292 if ( result
.size() > 0 && conf
._data
.visibleResults
< conf
.suggestions
.length
) {
293 // Not all suggestions are visible
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' );
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
);
315 * Initialize the widget
318 if ( typeof conf
!= 'object' || typeof conf
._data
!= 'undefined' )
319 // Configuration not set or init already done
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' )
329 if ( typeof conf
.maxGrowFactor
== 'undefined' )
330 conf
.maxGrowFactor
= 2;
331 if ( typeof conf
.maxRows
== 'undefined' )
333 if ( typeof conf
.submitOnClick
== 'undefined' )
334 conf
.submitOnClick
= false;
335 if ( typeof conf
.suggestions
!= 'object' )
336 conf
.suggestions
= [];
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
345 // Create container div for suggestions
346 conf
._data
.div
= $( '<div />' )
347 .addClass( 'os-suggest' ) //TODO: use own CSS
349 top
: Math
.round( $(this).offset().top
) + this.offsetHeight
,
350 left
: Math
.round( $(this).offset().left
),
351 width
: $(this).outerWidth()
354 .appendTo( $( 'body' ) );
356 // Create results 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
);
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;
370 .keypress( function() {
371 conf
._data
.keypressed_count
++;
372 processKey( conf
._data
.keypressed
);
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
);
382 // When losing focus because of a mousedown
383 // on a suggestion, don't hide the suggestions
384 if ( conf
._data
.mouseDownOn
.size() > 0 )
386 conf
._data
.div
.hide();
387 cancelPendingSuggestions();
391 .mouseover( function( e
) {
392 var tr
= $( e
.target
).closest( '.os-suggest tr' );
393 highlightResult( tr
, false );
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
;
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 ) )
409 highlightResult( tr
, true );
410 conf
._data
.div
.hide();
411 conf
._data
.textbox
.focus();
412 if ( conf
.submitOnClick
)
413 conf
._data
.textbox
.closest( 'form' )
418 function getProperty( prop
) {
419 return ( param
[0] == '_' ? undefined : conf
[param
] );
422 function setProperty( prop
, value
) {
423 if ( typeof conf
== 'undefined' ) {
424 $(this).data( 'suggestionsConfiguration', {} );
425 conf
= $(this).data( 'suggestionsConfiguration' );
427 if ( prop
[0] != '_' )
429 if ( prop
== 'suggestions' && conf
._data
)
430 // Setting suggestions post-init
431 suggestionsChanged();
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()
443 setProperty
.call( this, key
, param
[key
] );
446 else if ( typeof param
== 'string' ) {
447 if ( typeof param2
!= 'undefined' )
448 return this.each( function() {
449 setProperty( param
, param2
);
452 return getProperty( param
);
453 } else if ( typeof param
!= 'undefined' )
454 // Incorrect usage, ignore
457 // No parameters given, initialize
458 return this.each( init
);