jquery-ui/ui/jquery.ui.autocomplete.js
Trey Hunner bdfc6d532c Autocomplete: Fix list traversal bug. Fixes #7269 - autocomplete: holding down arrow keys in Firefox does not traverse list
Use keypress event for listening for arrow keys because Firefox
and Opera do not repeat keydown events for these keys.
2011-04-20 09:03:11 -07:00

466 lines
12 KiB
JavaScript

/*
* jQuery UI Autocomplete @VERSION
*
* 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
* jquery.ui.menu.js
*/
(function( $, undefined ) {
// used to prevent race conditions with remote data sources
var requestIndex = 0;
$.widget( "ui.autocomplete", {
defaultElement: "<input>",
options: {
appendTo: "body",
autoFocus: false,
delay: 300,
minLength: 1,
position: {
my: "left top",
at: "left bottom",
collision: "none"
},
source: null
},
pending: 0,
_create: function() {
var self = this,
doc = this.element[ 0 ].ownerDocument,
suppressKeyPress;
this.valueMethod = this.element[ this.element.is( "input" ) ? "val" : "text" ];
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" ) ) {
suppressKeyPress = true;
return;
}
suppressKeyPress = false;
var keyCode = $.ui.keyCode;
switch( event.keyCode ) {
case keyCode.PAGE_UP:
suppressKeyPress = true;
self._move( "previousPage", event );
break;
case keyCode.PAGE_DOWN:
suppressKeyPress = true;
self._move( "nextPage", event );
break;
case keyCode.UP:
suppressKeyPress = true;
self._move( "previous", event );
// prevent moving cursor to beginning of text field in some browsers
event.preventDefault();
break;
case keyCode.DOWN:
suppressKeyPress = true;
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._value( 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._value() ) {
self.selectedItem = null;
self.search( null, event );
}
}, self.options.delay );
break;
}
})
.bind( "keypress.autocomplete", function( event ) {
if ( suppressKeyPress ) {
suppressKeyPress = false;
event.preventDefault();
return;
}
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;
}
})
.bind( "focus.autocomplete", function() {
if ( self.options.disabled ) {
return;
}
self.selectedItem = null;
self.previous = self._value();
})
.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 &&
!$.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({
// custom key handling for now
input: $(),
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._value( item.value );
}
}
},
select: 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._value( item.value );
}
// reset the term after the select event
// this allows custom select handling to work properly
self.term = self._value();
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._value() !== self.term ) ) {
self._value( self.term );
}
}
})
.zIndex( this.element.zIndex() + 1 )
.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();
},
_setOption: function( key, value ) {
this._super( "_setOption", key, value );
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 {
this.source = this.options.source;
}
},
search: function( value, event ) {
value = value != null ? value : this._value();
// always save the actual value, not the one passed as an argument
this.term = this._value();
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 ( content ) {
content = this._normalize( content );
}
this._trigger( "response", null, { content: content } );
if ( !this.options.disabled && content && content.length ) {
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.blur();
this._trigger( "close", event );
}
},
_change: function( event ) {
if ( this.previous !== this._value() ) {
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 );
// TODO refresh should check if the active item is still in the dom, removing the need for a manual blur
this.menu.blur();
this.menu.refresh();
// size and position menu
ul.show();
this._resizeMenu();
ul.position( $.extend({
of: this.element
}, this.options.position ));
if ( this.options.autoFocus ) {
this.menu.next( new $.Event("mouseover") );
}
},
_resizeMenu: function() {
var ul = this.menu.element;
ul.outerWidth( Math.max(
ul.width( "" ).outerWidth(),
this.element.outerWidth()
) );
},
_renderMenu: function( ul, items ) {
var self = this;
$.each( items, function( index, item ) {
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._value( this.term );
this.menu.blur();
return;
}
this.menu[ direction ]( event );
},
widget: function() {
return this.menu.element;
},
_value: function( value ) {
return this.valueMethod.apply( this.element, arguments );
}
});
$.extend( $.ui.autocomplete, {
version: "@VERSION",
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 ));