If you ever used autocomplete.ui plugin from jQuery UI and have an array with lots of suggestions you'll see that the solutions provided by the plugin are not always satisfactory. You can add a scrollbar to the suggestions box by using a CSS hack, but even with that, you'll have to render a big HTML that can be annoying on slow machines (mobile devices, for example).
I was working on a web site recently that had to be displayed on mobile devices and needed an autocomplete. But the suggestions array was big. A lot. This caused some problems on the mobile devices as they were behaving very slow, and the default plugin configuration doesn't allow you to specify a maximum number of items to show in the suggesstions box.
So I decided to do a dirty hack into the plugin code to add this behaviour, adding a max property to the options to be able to limit the number of suggestions to show.
It's not a perfect solution, because it should be implemented as a subclass or something, but if you need a fast solution, this is the way to go. You can find the code in my GitHub repository fork of jQueryUI . You can check the commit to see the changes I made.
Here's the resulting code file:
/*
* jQuery UI Autocomplete 1.8.10
*
* Copyright 2011, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Autocomplete
*
* Depends:
* jquery.ui.core.js
* jquery.ui.widget.js
* jquery.ui.position.js
*/
( function ( $ , undefined ) {
// used to prevent race conditions with remote data sources
var requestIndex = 0 ;
$ . widget ( " ui.autocomplete " , {
options : {
appendTo : " body " ,
delay : 300 ,
minLength : 1 ,
position : {
my : " left top " ,
at : " left bottom " ,
collision : " none "
},
source : null ,
max : 0
},
pending : 0 ,
_create : function () {
var self = this ,
doc = this . element [ 0 ]. ownerDocument ,
suppressKeyPress ;
this . element
. addClass ( " ui-autocomplete-input " )
. attr ( " autocomplete " , " off " )
// TODO verify these actually work as intended
. attr ({
role : " textbox " ,
" aria-autocomplete " : " list " ,
" aria-haspopup " : " true "
})
. bind ( " keydown.autocomplete " , function ( event ) {
if ( self . options . disabled || self . element . attr ( " readonly " ) ) {
return ;
}
suppressKeyPress = false ;
var keyCode = $ . ui . keyCode ;
switch ( event . keyCode ) {
case keyCode . PAGE_UP :
self . _move ( " previousPage " , event );
break ;
case keyCode . PAGE_DOWN :
self . _move ( " nextPage " , event );
break ;
case keyCode . UP :
self . _move ( " previous " , event );
// prevent moving cursor to beginning of text field in some browsers
event . preventDefault ();
break ;
case keyCode . DOWN :
self . _move ( " next " , event );
// prevent moving cursor to end of text field in some browsers
event . preventDefault ();
break ;
case keyCode . ENTER :
case keyCode . NUMPAD_ENTER :
// when menu is open and has focus
if ( self . menu . active ) {
// #6055 - Opera still allows the keypress to occur
// which causes forms to submit
suppressKeyPress = true ;
event . preventDefault ();
}
//passthrough - ENTER and TAB both select the current element
case keyCode . TAB :
if ( ! self . menu . active ) {
return ;
}
self . menu . select ( event );
break ;
case keyCode . ESCAPE :
self . element . val ( self . term );
self . close ( event );
break ;
default :
// keypress is triggered before the input value is changed
clearTimeout ( self . searching );
self . searching = setTimeout ( function () {
// only search if the value has changed
if ( self . term != self . element . val () ) {
self . selectedItem = null ;
self . search ( null , event );
}
}, self . options . delay );
break ;
}
})
. bind ( " keypress.autocomplete " , function ( event ) {
if ( suppressKeyPress ) {
suppressKeyPress = false ;
event . preventDefault ();
}
})
. bind ( " focus.autocomplete " , function () {
if ( self . options . disabled ) {
return ;
}
self . selectedItem = null ;
self . previous = self . element . val ();
})
. bind ( " blur.autocomplete " , function ( event ) {
if ( self . options . disabled ) {
return ;
}
clearTimeout ( self . searching );
// clicks on the menu (or a button to trigger a search) will cause a blur event
self . closing = setTimeout ( function () {
self . close ( event );
self . _change ( event );
}, 150 );
});
this . _initSource ();
this . response = function () {
return self . _response . apply ( self , arguments );
};
this . menu = $ ( " <ul></ul> " )
. addClass ( " ui-autocomplete " )
. appendTo ( $ ( this . options . appendTo || " body " , doc )[ 0 ] )
// prevent the close-on-blur in case of a "slow" click on the menu (long mousedown)
. mousedown ( function ( event ) {
// clicking on the scrollbar causes focus to shift to the body
// but we can't detect a mouseup or a click immediately afterward
// so we have to track the next mousedown and close the menu if
// the user clicks somewhere outside of the autocomplete
var menuElement = self . menu . element [ 0 ];
if ( ! $ ( event . target ). closest ( " .ui-menu-item " ). length ) {
setTimeout ( function () {
$ ( document ). one ( ' mousedown ' , function ( event ) {
if ( event . target !== self . element [ 0 ] &&
event . target !== menuElement &&
! $ . ui . contains ( menuElement , event . target ) ) {
self . close ();
}
});
}, 1 );
}
// use another timeout to make sure the blur-event-handler on the input was already triggered
setTimeout ( function () {
clearTimeout ( self . closing );
}, 13 );
})
. menu ({
focus : function ( event , ui ) {
var item = ui . item . data ( " item.autocomplete " );
if ( false !== self . _trigger ( " focus " , event , { item : item } ) ) {
// use value to match what will end up in the input, if it was a key event
if ( /^key/ . test ( event . originalEvent . type ) ) {
self . element . val ( item . value );
}
}
},
selected : function ( event , ui ) {
var item = ui . item . data ( " item.autocomplete " ),
previous = self . previous ;
// only trigger when focus was lost (click on menu)
if ( self . element [ 0 ] !== doc . activeElement ) {
self . element . focus ();
self . previous = previous ;
// #6109 - IE triggers two focus events and the second
// is asynchronous, so we need to reset the previous
// term synchronously and asynchronously :-(
setTimeout ( function () {
self . previous = previous ;
self . selectedItem = item ;
}, 1 );
}
if ( false !== self . _trigger ( " select " , event , { item : item } ) ) {
self . element . val ( item . value );
}
// reset the term after the select event
// this allows custom select handling to work properly
self . term = self . element . val ();
self . close ( event );
self . selectedItem = item ;
},
blur : function ( event , ui ) {
// don't set the value of the text field if it's already correct
// this prevents moving the cursor unnecessarily
if ( self . menu . element . is ( " :visible " ) &&
( self . element . val () !== self . term ) ) {
self . element . val ( self . term );
}
}
})
. zIndex ( this . element . zIndex () + 1 )
// workaround for jQuery bug #5781 http://dev.jquery.com/ticket/5781
. css ({ top : 0 , left : 0 })
. hide ()
. data ( " menu " );
if ( $ . fn . bgiframe ) {
this . menu . element . bgiframe ();
}
},
destroy : function () {
this . element
. removeClass ( " ui-autocomplete-input " )
. removeAttr ( " autocomplete " )
. removeAttr ( " role " )
. removeAttr ( " aria-autocomplete " )
. removeAttr ( " aria-haspopup " );
this . menu . element . remove ();
$ . Widget . prototype . destroy . call ( this );
},
_setOption : function ( key , value ) {
$ . Widget . prototype . _setOption . apply ( this , arguments );
if ( key === " source " ) {
this . _initSource ();
}
if ( key === " appendTo " ) {
this . menu . element . appendTo ( $ ( value || " body " , this . element [ 0 ]. ownerDocument )[ 0 ] )
}
if ( key === " disabled " && value && this . xhr ) {
this . xhr . abort ();
}
},
_initSource : function () {
var self = this ,
array ,
url ;
if ( $ . isArray ( this . options . source ) ) {
array = this . options . source ;
this . source = function ( request , response ) {
response ( $ . ui . autocomplete . filter ( array , request . term ) );
};
} else if ( typeof this . options . source === " string " ) {
url = this . options . source ;
this . source = function ( request , response ) {
if ( self . xhr ) {
self . xhr . abort ();
}
self . xhr = $ . ajax ({
url : url ,
data : request ,
dataType : " json " ,
autocompleteRequest : ++ requestIndex ,
success : function ( data , status ) {
if ( this . autocompleteRequest === requestIndex ) {
response ( data );
}
},
error : function () {
if ( this . autocompleteRequest === requestIndex ) {
response ( [] );
}
}
});
};
} else {
ehis . source = this . options . source ;
}
},
search : function ( value , event ) {
value = value != null ? value : this . element . val ();
// always save the actual value, not the one passed as an argument
this . term = this . element . val ();
if ( value . length < this . options . minLength ) {
return this . close ( event );
}
clearTimeout ( this . closing );
if ( this . _trigger ( " search " , event ) === false ) {
return ;
}
return this . _search ( value );
},
_search : function ( value ) {
this . pending ++ ;
this . element . addClass ( " ui-autocomplete-loading " );
this . source ( { term : value }, this . response );
},
_response : function ( content ) {
if ( ! this . options . disabled && content && content . length ) {
content = this . _normalize ( content );
this . _suggest ( content );
this . _trigger ( " open " );
} else {
this . close ();
}
this . pending -- ;
if ( ! this . pending ) {
this . element . removeClass ( " ui-autocomplete-loading " );
}
},
close : function ( event ) {
clearTimeout ( this . closing );
if ( this . menu . element . is ( " :visible " ) ) {
this . menu . element . hide ();
this . menu . deactivate ();
this . _trigger ( " close " , event );
}
},
_change : function ( event ) {
if ( this . previous !== this . element . val () ) {
this . _trigger ( " change " , event , { item : this . selectedItem } );
}
},
_normalize : function ( items ) {
// assume all items have the right format when the first item is complete
if ( items . length && items [ 0 ]. label && items [ 0 ]. value ) {
return items ;
}
return $ . map ( items , function ( item ) {
if ( typeof item === " string " ) {
return {
label : item ,
value : item
};
}
return $ . extend ({
label : item . label || item . value ,
value : item . value || item . label
}, item );
});
},
_suggest : function ( items ) {
var ul = this . menu . element
. empty ()
. zIndex ( this . element . zIndex () + 1 );
this . _renderMenu ( ul , items , this . options . max );
// TODO refresh should check if the active item is still in the dom, removing the need for a manual deactivate
this . menu . deactivate ();
this . menu . refresh ();
// size and position menu
ul . show ();
this . _resizeMenu ();
ul . position ( $ . extend ({
of : this . element
}, this . options . position ));
},
_resizeMenu : function () {
var ul = this . menu . element ;
ul . outerWidth ( Math . max (
ul . width ( "" ). outerWidth (),
this . element . outerWidth ()
) );
},
_renderMenu : function ( ul , items , max ) {
var self = this ;
$ . each ( items , function ( index , item ) {
if ( max == 0 || index < max ) {
self . _renderItem ( ul , item );
}
});
},
_renderItem : function ( ul , item ) {
return $ ( " <li></li> " )
. data ( " item.autocomplete " , item )
. append ( $ ( " <a></a> " ). text ( item . label ) )
. appendTo ( ul );
},
_move : function ( direction , event ) {
if ( ! this . menu . element . is ( " :visible " ) ) {
this . search ( null , event );
return ;
}
if ( this . menu . first () && /^previous/ . test ( direction ) ||
this . menu . last () && /^next/ . test ( direction ) ) {
this . element . val ( this . term );
this . menu . deactivate ();
return ;
}
this . menu [ direction ]( event );
},
widget : function () {
return this . menu . element ;
}
});
$ . extend ( $ . ui . autocomplete , {
escapeRegex : function ( value ) {
return value . replace ( / [ -[ \] {}()*+?., \\ ^$|# \s] /g , " \\ $& " );
},
filter : function ( array , term ) {
var matcher = new RegExp ( $ . ui . autocomplete . escapeRegex ( term ), " i " );
return $ . grep ( array , function ( value ) {
return matcher . test ( value . label || value . value || value );
});
}
});
}( jQuery ));
/*
* jQuery UI Menu (not officially released)
*
* This widget isn't yet finished and the API is subject to change. We plan to finish
* it for the next release. You're welcome to give it a try anyway and give us feedback,
* as long as you're okay with migrating your code later on. We can help with that, too.
*
* Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Menu
*
* Depends:
* jquery.ui.core.js
* jquery.ui.widget.js
*/
( function ( $ ) {
$ . widget ( " ui.menu " , {
_create : function () {
var self = this ;
this . element
. addClass ( " ui-menu ui-widget ui-widget-content ui-corner-all " )
. attr ({
role : " listbox " ,
" aria-activedescendant " : " ui-active-menuitem "
})
. click ( function ( event ) {
if ( ! $ ( event . target ). closest ( " .ui-menu-item a " ). length ) {
return ;
}
// temporary
event . preventDefault ();
self . select ( event );
});
this . refresh ();
},
refresh : function () {
var self = this ;
// don't refresh list items that are already adapted
var items = this . element . children ( " li:not(.ui-menu-item):has(a) " )
. addClass ( " ui-menu-item " )
. attr ( " role " , " menuitem " );
items . children ( " a " )
. addClass ( " ui-corner-all " )
. attr ( " tabindex " , - 1 )
// mouseenter doesn't work with event delegation
. mouseenter ( function ( event ) {
self . activate ( event , $ ( this ). parent () );
})
. mouseleave ( function () {
self . deactivate ();
});
},
activate : function ( event , item ) {
this . deactivate ();
if ( this . hasScroll ()) {
var offset = item . offset (). top - this . element . offset (). top ,
scroll = this . element . attr ( " scrollTop " ),
elementHeight = this . element . height ();
if ( offset < 0 ) {
this . element . attr ( " scrollTop " , scroll + offset );
} else if ( offset >= elementHeight ) {
this . element . attr ( " scrollTop " , scroll + offset - elementHeight + item . height ());
}
}
this . active = item . eq ( 0 )
. children ( " a " )
. addClass ( " ui-state-hover " )
. attr ( " id " , " ui-active-menuitem " )
. end ();
this . _trigger ( " focus " , event , { item : item });
},
deactivate : function () {
if ( ! this . active ) { return ; }
this . active . children ( " a " )
. removeClass ( " ui-state-hover " )
. removeAttr ( " id " );
this . _trigger ( " blur " );
this . active = null ;
},
next : function ( event ) {
this . move ( " next " , " .ui-menu-item:first " , event );
},
previous : function ( event ) {
this . move ( " prev " , " .ui-menu-item:last " , event );
},
first : function () {
return this . active && ! this . active . prevAll ( " .ui-menu-item " ). length ;
},
last : function () {
return this . active && ! this . active . nextAll ( " .ui-menu-item " ). length ;
},
move : function ( direction , edge , event ) {
if ( ! this . active ) {
this . activate ( event , this . element . children ( edge ));
return ;
}
var next = this . active [ direction + " All " ]( " .ui-menu-item " ). eq ( 0 );
if ( next . length ) {
this . activate ( event , next );
} else {
this . activate ( event , this . element . children ( edge ));
}
},
// TODO merge with previousPage
nextPage : function ( event ) {
if ( this . hasScroll ()) {
// TODO merge with no-scroll-else
if ( ! this . active || this . last ()) {
this . activate ( event , this . element . children ( " .ui-menu-item:first " ));
return ;
}
var base = this . active . offset (). top ,
height = this . element . height (),
result = this . element . children ( " .ui-menu-item " ). filter ( function () {
var close = $ ( this ). offset (). top - base - height + $ ( this ). height ();
// TODO improve approximation
return close < 10 && close > - 10 ;
});
// TODO try to catch this earlier when scrollTop indicates the last page anyway
if ( ! result . length ) {
result = this . element . children ( " .ui-menu-item:last " );
}
this . activate ( event , result );
} else {
this . activate ( event , this . element . children ( " .ui-menu-item " )
. filter ( ! this . active || this . last () ? " :first " : " :last " ));
}
},
// TODO merge with nextPage
previousPage : function ( event ) {
if ( this . hasScroll ()) {
// TODO merge with no-scroll-else
if ( ! this . active || this . first ()) {
this . activate ( event , this . element . children ( " .ui-menu-item:last " ));
return ;
}
var base = this . active . offset (). top ,
height = this . element . height ();
result = this . element . children ( " .ui-menu-item " ). filter ( function () {
var close = $ ( this ). offset (). top - base + height - $ ( this ). height ();
// TODO improve approximation
return close < 10 && close > - 10 ;
});
// TODO try to catch this earlier when scrollTop indicates the last page anyway
if ( ! result . length ) {
result = this . element . children ( " .ui-menu-item:first " );
}
this . activate ( event , result );
} else {
this . activate ( event , this . element . children ( " .ui-menu-item " )
. filter ( ! this . active || this . first () ? " :last " : " :first " ));
}
},
hasScroll : function () {
return this . element . height () < this . element . attr ( " scrollHeight " );
},
select : function ( event ) {
this . _trigger ( " selected " , event , { item : this . active });
}
});
}( jQuery ));