1
votes

I'm trying to create a custom KO binding that takes an observable array, and adds a nested element to the DOM to contain a filtered subset of the elements in the observable array.

On initialisation of my custom binding I think I need to do two things. Firstly extend the binding context adding a second observable array to hold a filtered subset of the observable array that this binds to. Secondly, add add the DOM elements I want after the bound element.

Then on update of the observable array that this binding binds to, populate the observable array added to the binding context during init.

So far I have the following, non working, vastly simplified experiment.

ko.bindingHandlers.suggester = {
    init: function ( element, valueAccessor, allBindings, viewModel, bindingContext ) {
        var innerBindingContext = bindingContext.extend(valueAccessor);
        innerBindingContext.suggestions = ko.observableArray();
        var ul_element = jQuery(
            '<ul data-bind="foreach: suggestions">' +
            '<li data-bind="text: suggestionText"></li>' +
            '</ul>'
        );
        jQuery(element).after(ul_element);
        ko.applyBindingsToDescendants(innerBindingContext, element);
        return { controlsDescendantBindings: true };
    },
    update: function ( element, valueAccessor, allBindings, viewModel, bindingContext ) {
        var self = this;

        jQuery.each(ko.unwrap(valueAccessor), function (index,value) {
            if (/*do some filtering*/) {
                bindingContext.suggestions.push({suggestionText: value});
            }
        });
    }
};

I'm well aware the above is very wrong, but I'm bouncing from one very wrong idea to the next and really need some help.

======== EDIT ========

I've been playing around and I have something near what I'm after, but which still doesn't work.

ko.bindingHandlers.autocomplete = {
    init: function ( element, valueAccessor, allBindings, viewModel, bindingContext ) {

        bindingContext.suggestions = ko.observableArray([{suggestionText: 'fred'}]);

        var ul_element = jQuery(
            '<ul data-bind="foreach: suggestions">' +
            '<li data-bind="text: suggestionText"></li>' +
            '</ul>'
        );

        jQuery(element).append(ul_element);

    },
    update: function ( element, valueAccessor, allBindings, viewModel, bindingContext ) {
        var self = this;
        bindingContext.suggestions.push({suggestionText: "another1"});
        bindingContext.suggestions.push({suggestionText: "another2"});
    }
};

This adds the observable array suggestions to the binding context, adds the ul/li elements to the DOM and updates them correctly. The problem is that I want to add the <ul> after the node I'm using this binding on, not within it. When I change jQuery(element).append(ul_element); to jQuery(element).after(ul_element); it doesn't work an nothing is displayed.

Additionally, I'm not sure if adding an observable directly to the binding context within my custom binding is the 'right' thing to do.

2

2 Answers

2
votes

Part of me wants to say: "If it works, it works", but I also feel you're slightly misusing a custom binding...

Custom bindings are generally used to do DOM modifications required for, for example, advanced user-interaction. For more advanced reusable patterns that combine UI code with viewmodels, there are knockout components.

You're using a custom binding as an advanced template or foreach binding, with a small chunk of custom behavior. Personally, I'd rewrite the custom binding in to a component. For example:

ko.components.register('suggestionWidget', {
  viewModel: function(params) {
    // Component requires two params:
    //  - suggestions: an (observable) array of "things"
    //  - filter: a (wrapped) filter function to go from 
    //      `thing -> bool`
    this.suggestions = ko.pureComputed(
      () => ko.unwrap(params.suggestions)
        .filter(ko.unwrap(params.filter))
    );
  },
  template: `
    <ul data-bind="foreach: suggestions">
      <li data-bind="text: suggestionValue"></li>
    </ul>`
});


const App = function() {
  this.searchValue = ko.observable("");
  this.filter = ko.pureComputed(() =>
    this.searchValue()
      ? fruitSuggestion => fruitSuggestion
          .suggestionValue
          .includes(this.searchValue().toLowerCase())
      : () => false
  );
  
  this.fruitSuggestions = ko.observableArray(
    ["apple", "banana", "orange", "mango", "pineapple"].map(suggestionValue => ({ suggestionValue }))
  );
}

ko.applyBindings(new App());
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.2/knockout-min.js"></script>
<input type="text" data-bind="textInput: searchValue" placeholder="type 'apple' to get suggestions">

<div data-bind="component: {
                  name: 'suggestionWidget',
                  params: { 
                    filter: filter, 
                    suggestions: fruitSuggestions
                  }
                }"></div>

If the suggestions and suggestion logic is "more generic", you could bake those in to the component. I chose to let the viewmodel provide both filter and content, but it's up to you.


This answer completely bypasses your current code, and I can imagine you'd want something a bit closer to that approach... However, you asked for some insights, so I thought I'd chip in with a completely different view :)

0
votes

I have a solution which is working, but it feels as though I'm using Knockout in ways that are not intended or are outright wrong. I'm not going to accept my own answer as I'd like others to point out the inevitable pitfalls in this solution.

ko.bindingHandlers.autocomplete = {
    init: function ( element, valueAccessor, allBindings, viewModel, bindingContext ) {

        bindingContext.$data.suggestions = ko.observableArray();

        var ul_element = jQuery(
            '<ul>' +
            '<li data-bind="text: suggestionText"></li>' +
            '</ul>'
        );

    },
    update: function ( element, valueAccessor, allBindings, viewModel, bindingContext ) {
        var self = this;
        //console.log(valueAccessor());
        jQuery.each(ko.unwrap(valueAccessor()), function (index, suggestion) {
            console.log(suggestion());
            bindingContext.$data.suggestions.push({suggestionText: suggestion()});
        });

    }
};

This is working in this fiddle

There are two immediate issues with this solution that concern me. Firstly I have not used return { controlsDescendantBindings: true }; in the init and I'm surprised that its working without it. Secondly I have not used any of Knockout's custom disposal logic and I'm concerned that that is also a mistake.