Following on from the discussion
Knockout with JQuery UI Autocomplete (Resurrected) and my post there I thought I'd start a new thread with the code I've written to enable use of the jQuery Auto-complete widget with the ability to use a function for the array source, without a selection affecting same, along a custom auto-complete matcher function and adding a drop-down button whose icon changes in the JS vs. the markup which Ryan used in his example. I've tried to model the UI behaviour on that used by the Chosen.js <select> widget with options provided using jqAutoNewItem and added a Sort option.
Overall this works well. One issue I've yet to solve is that it doesn't highlight the current selection when the list drops-down. jQuery Auto-complete doesn't include this capability however I think it may still be possible.
Another point to note is in cases where you are using this in a foreach template a large number of iterations you will want to extract the: var mapped = ko.utils.arrayMap( .. ) code and create the source array just once for each different source as this can take a serious performance hit. It would in fact be even better if the auto-complete widget was only created when the user clicks on the <input> element.
Hopefully this is of use to someone.
/** jqAuto -- jQuery UI Auto-complete Binding. The main binding should contain additional options to pass to autocomplete.
* jqAutoSource -- the array of choices. Can be an array of strings or objects.
* jqAutoSourceFunc -- function which supplies array of choices. NF 14/01/2012
* jqAutoValue -- the model property to write the selected value to.
*
* Optional params used when the source is an array of objects.
* jqAutoSourceValue -- the model property to use for the value.
* ex. 'id' for { id: xx, name: yy } - where source is an array of objects. n/u where source is a plain array of strings.
* This is optionsValue in the KO "options" binding used for <select> impl.
* jqAutoSourceInputValue -- the model property that should be displayed in the input box. Uses jqAutoSourceValue if not spec'd.
* ex. 'name' for { id: xx, name: yy } - where source is an array of objects. n/u where source is a plain array of strings.
* This is optionsText in the KO "options" binding used for <select> impl.
* jqAutoSourceLabel -- the property that should be displayed in the possible choices list. Uses jqAutoSourceInputValue if not spec'd.
* jqAutoSort -- 'a - ascending or d - descending' to sort array of objects by jqAutoSourceInputValue.
* jqAutoNewItem -- 't' to allow new items to be created, 'r' to restore the item to it's orginal value if it doesn't exist,
* 'c' to clear the item if the value doesn't exist
*/
ko.bindingHandlers.jqAuto = {
init: function( element, valueAccessor, allBindingsAccessor, viewModel ){
// console.profile();
var options = valueAccessor() || {},
allBindings = allBindingsAccessor(),
unwrap = ko.utils.unwrapObservable,
modelValue = allBindings.jqAutoValue,
source = allBindings.jqAutoSource,
sourceFunc = allBindings.jqAutoSourceFunc,
valueProp = allBindings.jqAutoSourceValue, // 18/01/2012
inputValueProp = allBindings.jqAutoSourceInputValue || valueProp, // 18/01/2012
labelProp = allBindings.jqAutoSourceLabel || inputValueProp, // 18/01/2012
newItem = allBindings.jqAutoNewItem, // 19/01/2012
onUpdate = allBindings.jqonUpdate,
$elem = $(element);
if ( !modelValue ){ // There were problems where button_target and image_target were missing from various items. Presumeably from older versions! I've fixed these in addtosection_items(). 19/01/2012
// console.log( 'NH: !modelValue - jqAutoValueItem', allBindings.jqAutoValueItem, ', jqAutoValueItemName:', allBindings.jqAutoValueItemName, ', elem:', $elem );
console.log( 'NH: !modelValue - jqAutoValueItem', ', elem:', $elem );
debugger; // TODO: temp
}
// function that is shared by both select and change event handlers
function writeValueToModel( valueToWrite ){
console.assert( valueToWrite || valueToWrite == '' );
console.assert( modelValue );
if ( !modelValue) // see other test & cmt above.
debugger; // TODO: temp
if ( ko.isWriteableObservable( modelValue ) ) {
modelValue( valueToWrite );
} else { // write to non-observable
if ( allBindings['_ko_property_writers'] && allBindings['_ko_property_writers']['jqAutoValue'] )
allBindings['_ko_property_writers']['jqAutoValue']( valueToWrite );
else
console.assert( false );
}
// If a jqonUpdate function has been spec'd call same. Used to display the image when auto-complete value changes.
if ( onUpdate && valueToWrite.length > 0 ) // could test newItem = 'c' here. rem: Don't want 'There is no selected image to show'
onUpdate( valueToWrite );
}
// Returns the matching source[ item ] for the current input value.
// @param value value to check of null for element value.
// @param newItem == 'r' to restore orgiginal element value if spec'd / current value doesn't exist,
// newItem == 'c' to clear element value if spec'd / current value doesn't exist.
var value_exists = function( value, newItem ){
var currentValue = value || $.trim( $elem.val() );
var matchingItem = null;
if ( currentValue.length ){
matchingItem = ko.utils.arrayFirst( unwrap( source ), function( item ){
return unwrap( inputValueProp ? ( $.isFunction( inputValueProp ) ? inputValueProp( item ) : item[inputValueProp] ) : item ) === currentValue; // 18/01/2012
});
}
// console.log( 'In ko.bindingHandlers.jqAuto() value_exists() - value:', currentValue, ', matchingItem:', matchingItem );
if ( !matchingItem ){
if ( newItem == 'r' ){
var originalValue = $elem.data( 'originalValue' );
$elem.val( originalValue );
// writeValueToModel( originalValue ); // doesn't update DOM if property unchanged. And no need as VM property value hasn't changed.
}
else
if ( newItem == 'c' ){ // We could add another option 'C' that does: if ( currentValue.length == 0 ) clear else Restore.
writeValueToModel( '' ); // We must clear the vm property. ie. User wants it cleared.
$elem.val( '' ); // see cmt re. writeValueToModel() above.
}
}
return matchingItem;
}
// If source is an array of objects then convert same to a DO array.
var matcher_source;
if ( valueProp ){
var mappedSource = ko.dependentObservable( function() {
var mapped = ko.utils.arrayMap( unwrap( source ), function( item ){
var result = {};
// text to display in auto-complete list
result.label = labelProp ? unwrap( $.isFunction( labelProp ) ? labelProp( item ) : item[labelProp] ) : unwrap( item ).toString();
// text to display in input box
result.value = inputValueProp ? unwrap( $.isFunction( inputValueProp ) ? inputValueProp( item ) : item[inputValueProp] ) : unwrap( item ).toString();
// store in model
result.actualValue = valueProp ? unwrap( item[valueProp] ) : item;
return result;
});
if ( allBindings.jqAutoSort ){
mapped.sort( function( a, b ){
var auc = a.value.toUpperCase(), buc = b.value.toUpperCase();
var res = ( allBindings.jqAutoSort == 'a' ) ? ( auc < buc ? -1 : auc === buc ? 0 : 1 ) : ( auc < buc ? 1 : auc === buc ? 0 : -1 );
return res;
});
}
return mapped;
});
// whenever the items that make up the source are updated, make sure that autocomplete knows it
mappedSource.subscribe( function( newValue ){
console.log( 'In mappedSource.subscribe() newValue:', newValue ); // is this ever called. TODO: TEST:
$elem.autocomplete( "option", "source", newValue );
});
matcher_source = mappedSource; // mustn't touch 'source'. It's used by options.change().
}
else
matcher_source = source;
options.source = sourceFunc || source;
$elem.data( 'source', matcher_source ); // use jq .data to pass source to ac_matcher()
// We add a drop-down open/close button when the auto-complete is created.
// Orig implementation did this in markup, but this is better.
options.create = function( event, ui ){
$( "<button> </button>" )
.attr( "tabIndex", -1 )
.insertAfter( element )
.button({
icons: { primary: "ui-icon-triangle-1-s" },
text: false
})
.removeClass( "ui-corner-all" )
.addClass( "ui-corner-right ui-button-icon jqui_ac_btn" )
.click( function( event ){
ac_open();
return false; // stop event bubble/propogation.
})
.hover( function(){
$(this).removeAttr('title'); // otherwise hover displays an empty tip.
});
}
// flip the button icon.
options.open = function( event, ui ){
var $el_button = $( $elem.next()[0] );
$el_button.button( { icons: { primary: "ui-icon-triangle-1-n" } } );
}
options.close = function( event, ui ){
var $el_button = $( $elem.next()[0] );
$el_button.button( { icons: { primary: "ui-icon-triangle-1-s" } } );
}
// on a selection write the proper value to the model
options.select = function( event, ui ){
writeValueToModel( ui.item.actualValue ? ui.item.actualValue : ui.item ? ui.item.value : null ); // .actualValue is used when the source is an array of objects. See mappedSource() above.
};
// on a change, make sure that it is a valid value or clear out the model value
options.change = function( event, ui ){
if ( newItem == 'r' || newItem == 'c' )
value_exists( null, newItem );
else
if ( newItem == 't' ){ // add new value. note: where source is an array of objects this probably doesn't make much sense as we don't add a complete item. jQ-UI Auto-complete doesn't allow adding new items.
writeValueToModel( $.trim( $elem.val() ) );
}
}
$elem.on( 'keydown', function( event ){
if ( event.keyCode == '13' ){
// Do zip when Enter is pressed. ref: <A href="sulkb://kb=Javascript,Fid=5869,Rid=5880">Auto-complete requirements</A>
return false; // prevent bubble etc.
}
});
$elem.on( 'keyup', function( event ){
if ( event.keyCode == '8' || event.keyCode == 46 ){ // Del keyCode is 46.
var currentValue = $.trim( $elem.val() ); // rem: we have value after BS with keyup.
if ( currentValue.length == 0 ){ // if input is now empty open the ac list.
$elem.qtip( 'destroy' );
ac_open( $elem );
}
}
});
// Called by ac_matcher() when auto-complete list has been built.
$elem.on( 'matches', function( event, value, matches ){
if ( matches )
$elem.qtip( 'destroy' ); // Clear any 'no_matches' qtip
else // entered value doesn't exist in the auto-complete list.
$elem.qtip({
content: 'No results match "' + value + '"',
position: { my: 'bottom center', at: 'top center' },
show: { event: false, ready: true }
});
return false;
});
// initialize autocomplete. When element gets focus drop down the auto-complete list.
$elem.autocomplete( options ).click( function(){ ac_open( $(this), 'click' ); return false; } );
// console.profileEnd();
},
update: function(element, valueAccessor, allBindingsAccessor, viewModel) {
// update value based on a model change
var allBindings = allBindingsAccessor(),
unwrap = ko.utils.unwrapObservable,
modelValue = unwrap( allBindings.jqAutoValue ) || '',
valueProp = allBindings.jqAutoSourceValue, // model property to use for the value. ex. 'id' for { id: xx, name: yy }
inputValueProp = allBindings.jqAutoSourceInputValue || valueProp; // model property that should be displayed in the input box. ex. 'name' for { id: xx, name: yy }
// if we are writing a different property to the input than we are writing to the model, then locate the object to get it's property value
if ( valueProp && inputValueProp !== valueProp ){
var source = unwrap( allBindings.jqAutoSource ) || [];
// search source arrary for the spec'd item. ex. item.id == current_item.id. rem: modelValue = null if no match. modelValue = ko.utils.arrayFirst( source, function( item ){
return unwrap( item[valueProp] ) === modelValue;
});
}
// update the element with the value that should be shown in the input.
// rem: For our use inputValueProp is: function(item){ return item.pagename().value; } vs. a property name.
var value = !modelValue ? '' : ( modelValue && inputValueProp !== valueProp ? unwrap( $.isFunction( inputValueProp ) ? inputValueProp( modelValue ) : modelValue[inputValueProp] ) : modelValue.toString() );
$(element).data( 'originalValue', value ); // use jq .data to save originalValue for value_exists().
$(element).val( value );
}
}
// Give the jQueryUI Auto-complete the items we want it to display and complete against.
var ac_matcher = function( req, response ){
var $elem = $(this.element[0]);
var source = $elem.data('source'); // Get the auto-complete source. set in ko.bindingHandlers.jqAuto() init() above.
// console.log( 'ac_matcher req: ', req, ', this:', this, ', source:', source() );
var re = $.ui.autocomplete.escapeRegex( req.term ); // req.term is the text in the <input> element.
var matcher = new RegExp( "^" + re, "i" );
// run the re over the source array returning a new array with items that match
// and passing that to the response() callback. This is what get's displayed
// in the auto-complete list.
var results = $.grep( source(), function( item ){
return matcher.test( item.value ? item.value : item ); // if source is an array of objects then we match against item.value. See mappedSource() above.
});
$elem.trigger( 'matches', [req.term, results.length] ); // inform user as req'd.
response( results );
}
// Open the auto-complete drop down list.
// @param $input is the input element when called for how = click etc. (see above) or undefined for drop-down button click.
var ac_open = function( $input, how ){
var drop_down_btn_click = false;
if ( !$input ){
var $el_button = $( event.srcElement ).parent();
$input = $( $el_button.prev()[0] );
drop_down_btn_click = true; // called for drop down button click.
}
// console.log( "In ac_open() $input: ", $input, ' how:', how );
// close if already visible
if ( $input.autocomplete("widget").is(":visible") ){
// console.log("close, drop_down_btn_click:", drop_down_btn_click);
if ( drop_down_btn_click || how == 'click' ){ // don't close for onfocus() triggered by 'drop-down' button click $input.focus() call below.
$input.autocomplete( "close" );
}
}else{
var minlength = $input.autocomplete( "option", "minLength" ); // save current minLength
$input.autocomplete( "option", { minLength: 0 } ).autocomplete( "search", "" ); // set minLength to 0 and open drop-down. If we don't set it to 0 and input is blank then button does zip.
// TODO: highlight current item (if any). 'search' alone shows current item. 'search + ""' shows entire list. 'search + " "' displays zip?
if ( drop_down_btn_click )
$input.focus();
$input.autocomplete( "option", { minLength: minlength } ); // restore
}
}
---------------------