57

Does any one know the best way to create an autocomplete combobox with Knockout JS templates?

I have the following template:

<script type="text/html" id="row-template">
<tr>
...
    <td>         
        <select class="list" data-bind="options: SomeViewModelArray, 
                                        value: SelectedItem">
        </select>
    </td>
...        
<tr>
</script>

Sometimes this list is long and I'd like to have Knockout play nicely with perhaps jQuery autocomplete or some straight JavaScript code, but have had little success.

In addition, jQuery.Autocomplete requires an input field. Any ideas?

Jeroen
  • 60,696
  • 40
  • 206
  • 339
Craig Bruce
  • 674
  • 1
  • 8
  • 18

9 Answers9

123

Here is a jQuery UI Autocomplete binding that I wrote. It is intended to mirror the options, optionsText, optionsValue, value binding paradigm used with select elements with a couple of additions (you can query for options via AJAX and you can differentiate what is displayed in the input box vs. what is displayed in the selection box that pops up.

You do not need to provide all of the options. It will choose defaults for you.

Here is a sample without the AJAX functionality: http://jsfiddle.net/rniemeyer/YNCTY/

Here is the same sample with a button that makes it behave more like a combo box: http://jsfiddle.net/rniemeyer/PPsRC/

Here is a sample with the options retrieved via AJAX: http://jsfiddle.net/rniemeyer/MJQ6g/

//jqAuto -- main binding (should contain additional options to pass to autocomplete)
//jqAutoSource -- the array to populate with choices (needs to be an observableArray)
//jqAutoQuery -- function to return choices (if you need to return via AJAX)
//jqAutoValue -- where to write the selected value
//jqAutoSourceLabel -- the property that should be displayed in the possible choices
//jqAutoSourceInputValue -- the property that should be displayed in the input box
//jqAutoSourceValue -- the property to use for the value
ko.bindingHandlers.jqAuto = {
    init: function(element, valueAccessor, allBindingsAccessor, viewModel) {
        var options = valueAccessor() || {},
            allBindings = allBindingsAccessor(),
            unwrap = ko.utils.unwrapObservable,
            modelValue = allBindings.jqAutoValue,
            source = allBindings.jqAutoSource,
            query = allBindings.jqAutoQuery,
            valueProp = allBindings.jqAutoSourceValue,
            inputValueProp = allBindings.jqAutoSourceInputValue || valueProp,
            labelProp = allBindings.jqAutoSourceLabel || inputValueProp;

        //function that is shared by both select and change event handlers
        function writeValueToModel(valueToWrite) {
            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 );    
            }
        }

        //on a selection write the proper value to the model
        options.select = function(event, ui) {
            writeValueToModel(ui.item ? ui.item.actualValue : null);
        };

        //on a change, make sure that it is a valid value or clear out the model value
        options.change = function(event, ui) {
            var currentValue = $(element).val();
            var matchingItem =  ko.utils.arrayFirst(unwrap(source), function(item) {
               return unwrap(item[inputValueProp]) === currentValue;  
            });

            if (!matchingItem) {
               writeValueToModel(null);
            }    
        }

        //hold the autocomplete current response
        var currentResponse = null;

        //handle the choices being updated in a DO, to decouple value updates from source (options) updates
        var mappedSource = ko.dependentObservable({
            read: function() {
                    mapped = ko.utils.arrayMap(unwrap(source), function(item) {
                        var result = {};
                        result.label = labelProp ? unwrap(item[labelProp]) : unwrap(item).toString();  //show in pop-up choices
                        result.value = inputValueProp ? unwrap(item[inputValueProp]) : unwrap(item).toString();  //show in input box
                        result.actualValue = valueProp ? unwrap(item[valueProp]) : item;  //store in model
                        return result;
                });
                return mapped;                
            },
            write: function(newValue) {
                source(newValue);  //update the source observableArray, so our mapped value (above) is correct
                if (currentResponse) {
                    currentResponse(mappedSource());
                }
            }
        });

        if (query) {
            options.source = function(request, response) {  
                currentResponse = response;
                query.call(this, request.term, mappedSource);
            }
        } else {
            //whenever the items that make up the source are updated, make sure that autocomplete knows it
            mappedSource.subscribe(function(newValue) {
               $(element).autocomplete("option", "source", newValue); 
            });

            options.source = mappedSource();
        }

        ko.utils.domNodeDisposal.addDisposeCallback(element, function () {
            $(element).autocomplete("destroy");
        });


        //initialize autocomplete
        $(element).autocomplete(options);
    },
    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,
           inputValueProp = allBindings.jqAutoSourceInputValue || valueProp;

       //if we are writing a different property to the input than we are writing to the model, then locate the object
       if (valueProp && inputValueProp !== valueProp) {
           var source = unwrap(allBindings.jqAutoSource) || [];
           var 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
       $(element).val(modelValue && inputValueProp !== valueProp ? unwrap(modelValue[inputValueProp]) : modelValue.toString());    
    }
};

You would use it like:

<input data-bind="jqAuto: { autoFocus: true }, jqAutoSource: myPeople, jqAutoValue: mySelectedGuid, jqAutoSourceLabel: 'displayName', jqAutoSourceInputValue: 'name', jqAutoSourceValue: 'guid'" />

UPDATE: I am maintaining a version of this binding here: https://github.com/rniemeyer/knockout-jqAutocomplete

RP Niemeyer
  • 114,592
  • 18
  • 291
  • 211
  • Updated answer with a sample that uses it more like a combo box: http://jsfiddle.net/rniemeyer/PPsRC/. Uses a quick custom binding to simplify adding the click binding to the button. A sample with better styling is here: http://jqueryui.com/demos/autocomplete/#combobox – RP Niemeyer Sep 24 '11 at 13:25
  • Thanks RP .. had to park this feature until later in the sprint. Will let you know how I go when I get to it... – Craig Bruce Sep 26 '11 at 11:00
  • Thanks Ryan. Great looking solution. I've been playing with others over the weekend but not come up with anything that was 'quirk free'. Great stuff. – Damien Sawyer Nov 20 '11 at 03:02
  • 7
    Correction, Knockout Validation doesn't seem to work with this – Homer Sep 13 '12 at 13:06
  • Here is an updated fiddle that will do a "show all" when the combo button is clicked: http://jsfiddle.net/8qkJP/ – Homer Sep 13 '12 at 14:41
  • 1
    shame that none of these jsFiddle links work now, unless you edit the external links, if only they had built-in knockoutjs support – eselk May 24 '13 at 19:35
  • I updated all of the fiddles. Github removed the "Downloads" section from repositories and finally stopped serving the files. – RP Niemeyer May 24 '13 at 20:41
  • 3
    I am trying to use this code but its not working with knockout.validation.js. Is there an example that can this? – jmogera Sep 11 '13 at 19:23
  • Loving the non-AJAX code, but just wondering why you clear out the search term in the AJAX version as it makes it very difficult to use. – phuzi Dec 03 '13 at 11:10
  • @phuzi looks like a bug, I never intended for it to clear it like that. – RP Niemeyer Dec 03 '13 at 15:03
  • I tried the 'combo box' code. This is a *brilliant* solution. Many thanks! Unfortunately if user clicks on a button, only entries which have a white space are shown. I have fixed it (used idea from http://stackoverflow.com/a/3415929/1131855). Updated version is http://jsfiddle.net/Df8ee/. – Maxim Eliseev Jan 24 '14 at 12:45
  • what if i need to call an event when value on input changes. knockout event binding not working with it – Muhammad Raheel Apr 25 '14 at 10:52
  • Awesome. I was wondering if you solved the issue reported by @phuzi, seems like its this line: "source(newValue);" (mappedSource/write method) that triggers the update method that clears the search term. – Andrea May 09 '14 at 18:46
  • Unfortunately this has some limitations and I'm not sure you can work around them; I think they may apply to all custom binding handlers. First, the update doesn't seem to get called if the underlying observable changes while the input is hidden. Second, the if the underlying value is tied to a sub-observable (e.g. `vm.anObs().aSubObs`) this seems to only update a copy (e.g. changes aren't shown on `vm.anObs`). The default bindings (e.g. to a `select`) don't seem to have these problems :-/ – Rawling Nov 07 '14 at 10:23
  • The answer should be updated. it doesn't work with the last version of knockoutjs – Oleksandr G Mar 22 '15 at 16:47
  • @alga - can you be more specific? I updated all of the fiddles to 3.3 and I don't see any issues. Also take a look at: https://github.com/rniemeyer/knockout-jqAutocomplete – RP Niemeyer Mar 23 '15 at 17:56
  • @RPNiemeyer it works fine, just checked, you're right. I had troubles with 3.3, but can't see it now. It's my fault, I will delete both of my comments tomorrow – Oleksandr G Mar 24 '15 at 11:46
  • 1
    I've just come across a small issue with this implementation - the button will only show items in the autocomplete list that contain spaces. A workaround is to add the option `minLength: 0` to the Autocomplete and then change the search line (line103) to `autoEl.autocomplete("search", "");` – Tim Barclay Dec 01 '15 at 09:41
45

Here is my solution:

ko.bindingHandlers.ko_autocomplete = {
    init: function (element, params) {
        $(element).autocomplete(params());
    },
    update: function (element, params) {
        $(element).autocomplete("option", "source", params().source);
    }
};

Usage:

<input type="text" id="name-search" data-bind="value: langName, 
ko_autocomplete: { source: getLangs(), select: addLang }"/>

http://jsfiddle.net/7bRVH/214/ Compared to RP's it is very basic but maybe fills your needs.

Epstone
  • 990
  • 8
  • 20
  • 1
    Indeed, this worked for me. Here's a fiddle, where each keypress generates an ajax request to a mock server: http://jsfiddle.net/wmaurer/WgPpq/ – Wayne Maurer Dec 05 '12 at 14:44
  • Love this one, decouple the custom binder and autocomplete, and achieve all in the model, make the code maintainable. Thanks. – anIBMer Jun 19 '13 at 13:49
  • Does it work with jQuery UI 1.10.3? I'm trying it here but the autocomplete doesn't show up. http://jsfiddle.net/7bRVH/327/ – Mario Duarte Oct 28 '13 at 20:26
  • This works for me: elegant succinctness. I could not get the more complicated versions to work. I added the addDisposeCallback() to the init: as suggested by others. – subsci Jan 29 '14 at 05:25
  • I am trying to use this in a table created from ko.observableArray where the edit fields then use autocomplete. I have been able to get the dropdown working but when it calls the select function, how do I find the observable in my model for that row to put the value into? The data binding above just calls a function it seems and doesnt actually bind the edit field into an observable value? – Johncl Aug 05 '15 at 13:10
  • How can I make it workable for multiple autocomplete textboxes? I need to do autocomplete for "GiftName" here http://jsfiddle.net/bhagirathip/x6H8s/4/ – Keval Gangani Oct 27 '17 at 07:17
14

Disposal needed....

Both of those solutions are great (with Niemeyer's being much more fine grained) but they both forget the disposal handling!

They should handle disposals by destroying jquery autocomplete (prevent memory leakages) with this:

init: function (element, valueAccessor, allBindingsAccessor) {  
....  
    //handle disposal (if KO removes by the template binding)
    ko.utils.domNodeDisposal.addDisposeCallback(element, function () {
        $(element).autocomplete("destroy");
    });
}
George Mavritsakis
  • 6,829
  • 2
  • 35
  • 42
4

Minor improvements,

First of all these are some very useful tips, thank you all for sharing.

I'm using the version posted by Epstone with the following improvements:

  1. Display the label (instead of the value) when pressing up or down - apparently this can be done by handling the focus event

  2. Using an observable array as the data source (instead of an array)

  3. Added the disposable handler as suggested by George

http://jsfiddle.net/PpSfR/

...
conf.focus = function (event, ui) {
  $(element).val(ui.item.label);
  return false;
}
...

Btw, specifying minLength as 0 allows displaying the alternatives by just moving the arrow keys without having to enter any text.

2

I tried Niemeyer's solution with JQuery UI 1.10.x, but the autocomplete box simply didn't show up, after some searching i found a simple workaround here. Adding the following rule to the end of your jquery-ui.css file fixes the problem:

ul.ui-autocomplete.ui-menu {
  z-index: 1000;
}

I also used Knockout-3.1.0, so I had to replace ko.dependentObservable(...) with ko.computed(...)

In addition, if your KO View model contains some numeric value make sure you change the comparison operators: from === to == and !== to != , so that type conversion is performed.

I hope this helps others

Community
  • 1
  • 1
chomba
  • 1,390
  • 11
  • 13
2

Fixed the clearing of input on load problem for RP's Solution. Even though it's kind of an indirect solution, I changed this at the end of the function:

$(element).val(modelValue && inputValueProp !== valueProp ?
unwrap(modelValue[inputValueProp]) : modelValue.toString());

to this:

var savedValue = $(element).val();
$(element).val(modelValue && inputValueProp !== valueProp ?  unwrap(modelValue[inputValueProp]) : modelValue.toString());
if ($(element).val() == '') {
   $(element).val(savedValue);
}
0

Niemeyer's solution is great, however I run into an issue when trying to use autocomplete inside a modal. Autocomplete was destroyed on modal close event (Uncaught Error: cannot call methods on autocomplete prior to initialization; attempted to call method 'option' ) I fixed it by adding two lines to the binding's subscribe method:

mappedSource.subscribe(function (newValue) {
    if (!$(element).hasClass('ui-autocomplete-input'))
         $(element).autocomplete(options);
    $(element).autocomplete("option", "source", newValue);
});
Jerry
  • 1,762
  • 5
  • 28
  • 42
0

I know this question is old, but I was also looking for a really simple solution for our team using this in a form, and found out that jQuery autocomplete raises an 'autocompleteselect' event.

This gave me this idea.

<input data-bind="value: text, valueUpdate:['blur','autocompleteselect'], jqAutocomplete: autocompleteUrl" />

With the handler simply being:

ko.bindingHandlers.jqAutocomplete = {
   update: function(element, valueAccessor) {
      var value = valueAccessor();

      $(element).autocomplete({
         source: value,
      });
   }    
}

I liked this approach because it keeps the handler simple, and it doesn't attach jQuery events into my viewmodel. Here is a fiddle with an array instead of a url as the source. This works if you click the textbox and also if you press enter.

https://jsfiddle.net/fbt1772L/3/

avid
  • 61
  • 2
0

Another variation on Epstone's original solution.

I tried to use it but also found that the view model was only being updated when a value was typed manually. Selecting an autocomplete entry left the view model with the old value, which is a bit of a worry because validation still passes - it's only when you look in the database you see the problem!

The method I used is to hook the select handler of the jquery UI component in the knockout binding init, which simply updates the knockout model when a value is chosen. This code also incorporates the dispose plumbing from George's useful answer above.

init: function (element, valueAccessor, allBindingsAccessor) {

        valueAccessor.select = function(event, ui) {
            var va = allBindingsAccessor();
            va.value(ui.item.value);
        }

        $(element).autocomplete(valueAccessor);

        //handle disposal (if KO removes by the template binding)
        ko.utils.domNodeDisposal.addDisposeCallback(element, function () {
            $(element).autocomplete("destroy");
        });

    }
...
                    <input class="form-control" type="text" data-bind="value: ModelProperty, ko_autocomplete: { source: $root.getAutocompleteValues() }" />

This is now working pretty well. It is intended to work against a preloaded array of values on the page rather than querying an api.

Dorian Farrimond
  • 413
  • 5
  • 13