Search code examples
knockout.jscustom-binding

knockout.js augment context for each render in template binding


What I want to do, I think requires you to augment the individual bindingContexts of each rendered item in a foreach or template binding.

It can be done by instantiating a custom template engine extends the bindingContext in renderTemplateSource. But that seems hacky. There must be a better way.

What is this better way? That is my primary question.

I also want to add some bindings automatically to nodes in the anonymous templates of foreach bindings. This too, I can do with my custom template engine, but afaik that forces the automatic bindings to be added in the form of "data-bind" attribute strings. Also very funky.

Is there a cleaner way to let your custom bindings sneak in other bindings, on stuff that comes out of a foreach? That is my secondary question.

Below is a more detailed explanation of what I am trying to do. If you can suggest something cooler and cleaner, I thank you.


I am trying to write a custom binding called "uiList", to be used as such:

<ul data-bind="uiList: people">
  <li>
    <span data-bind="text: name"></span> 
    ...other stuff ...
  </li>
<ul>

Basically, an alias to "foreach". But I also want some typical list-widget-interactivity like "selecting" an item by clicking on it. And keeping only one item at a time selected.

To elaborate on my example, I would like to make people's names editable like so:

<ul data-bind="uiList: people">
  <li>
    <span data-bind="text: name, visible: !$selected()"></span>
    <input type="text" data-bind="value: name, visible: $selected()"> 
  </li>
<ul>

In other words, information like selectedness, disabledness et c, that relates to the ui of the list as a whole, but not to the data, should be made available in a clean and simple way on the binding context, as with the $selected property above.

For complete functionality my html could look like this:

<ul class="ui-list" data-bind="uiList: people">
  <li class="ui-list-item" data-bind="click: function () {$selected(true)}, css: $selected() ? 'ui-state-selected' : ''">
    <span data-bind="text: name, visible: !$selected()"></span>
    <input type="text" data-bind="value: name, visible: $selected()"> 
  </li>
<ul>

... but that's the stuff I want uiList to automate. A user should only need to write as in the previous example.

Here's how my custom binding handler code looks:

var ExtendedForeachTemplateEngine = function (contextExtender, bindings) {
    this.allowTemplateRewriting = false;
    this.contextExtender = contextExtender;

    //prep a data-bind="..." string from the given bindings to
    //sneak in on rendered anon templates
    this.bindingStr = [];
    for (var bindingName in bindings) {
        this.bindingStr.push(bindingName + ':' + bindings[bindingName]);
    }
    this.bindingStr = this.bindingStr.join(', ');
};

ExtendedForeachTemplateEngine.prototype = new ko.templateEngine();

ExtendedForeachTemplateEngine.prototype.renderTemplateSource = function (templateSource, bindingContext) {
    //extend the binding context
    this.contextExtender(bindingContext);

    //sneak in bindings rendered anon templates. Only on the first
    //Element node.
    var nodes = templateSource.nodes().cloneNode(true).childNodes;
    for (var i = 0; i < nodes.length; i ++) {
        if (nodes[i].nodeType !== 1) continue;
        var nodeBindings = nodes[i].getAttribute('data-bind');
        if (nodeBindings) { 
            nodeBindings += ', ' + this.bindingStr;
        } else {
            nodeBindings = this.bindingStr; 
        }
        nodes[i].setAttribute('data-bind', nodeBindings);
        break;
    }
    return nodes;
}

/*
Takes data like a regular foreach. also takes a binding context-extender
callback function, and some bindings to add to each rendered template.
*/
ko.bindingHandlers['extendedForeach'] = {
    init: function (el, va, al, vm, bc) {
        var opts = va();
        ko.applyBindingsToNode(el, {
            template: {
                foreach: opts.data,
                templateEngine: new ExtendedForeachTemplateEngine(opts.contextExtender, opts.bindings)
            }
        }, bc);
    }
};

/*

uiList
-----------

Classes:
- adds class ui-list to the bound element
- renders the inner html for each given item.
- inner html gets ui-list-item class

Selection
- click on one of the rendered item and it is selected.
- context property $selected holds selectedness.
- selected items have class ui-state-selected,
- Only one item can be selected at a time.

*/
ko.bindingHandlers['uiList'] = {
    init: function (el, va, al, vm, bc) {
        var items = va();
        console.log(items);
        var selectedItem = ko.observable(null);
        ko.applyBindingsToNode(el, {
            extendedForeach: {
                data: items,
                bindings: {
                    css: "'ui-list-item' + ($selected() ? ' ui-state-selected' : '')",
                    click: "function () { $selected(true); }"
                },
                contextExtender:  function (context) {
                    context.$selected = ko.computed({
                        read: function () {
                            return selectedItem() === context.$index();
                        },
                        write: function (val) {
                            selectedItem(val ? context.$index() : null);
                        }
                    });
                }
            }
        }, bc);
        $(el).addClass('ui-list');
        return {controlsDescendantBindings: true};
    }
};

Solution

  • Create a reusable viewmodel. That's the short answer.

    The longer one is that you need a nice way of injecting the template for it otherwise you need to do that everywhere you use the model. One way is to use a library like my binding convention library, that way it will automatically render the correct template/view depending on the view model.

    Example,
    https://github.com/AndersMalmgren/Knockout.BindingConventions/wiki/Template-convention

    Another way is to encapsulate the reusable viewmodel in a custom binding and render the template for it using a custom template source. You can look at my combobox for an example of that

    https://github.com/AndersMalmgren/Knockout.Combobox/blob/master/src/knockout.combobox.js#L345