23

Suppose I have knockout.js template like this:

<script type="text/html" id="mytemplate">
    <label for="inputId">Label for input</label>
    <input type="text" id="inputId" data-bind="value: inputValue"/>
</script>

If I render this template in several places on the page I end up with several inputs with the same id (and several labels with the same for value), which has bad consequences. In particular, all code that depends on ids may not work properly (in my case I use jquery.infieldlabel plugin that gets confused by multiple inputs with the same id). The way I solve this issue now is I add unique id attribute to the model that I bind to the template:

<script type="text/html" id="mytemplate">
    <label data-bind="attr: {for: id}>Label for input</label>
    <input type="text" data-bind="attr: {id: id}, value: inputValue"/>
</script>

This works, but it's not very elegant since I have to have this artificial id attribute in my models that is not used for anything else. I wonder if there is a better solution here.

Roman Bataev
  • 9,287
  • 2
  • 21
  • 15

3 Answers3

64

An alternative that does not rely on the order that the fields are bound is to have the binding set an id property on the data itself, which would need to be an observable.

ko.bindingHandlers.uniqueId = {
  init: function(element, valueAccessor) {
    var value = valueAccessor();
    value.id = value.id || ko.bindingHandlers.uniqueId.prefix + (++ko.bindingHandlers.uniqueId.counter);

    element.id = value.id;
  },
  counter: 0,
  prefix: "unique"
};

ko.bindingHandlers.uniqueFor = {
  init: function(element, valueAccessor) {
    var value = valueAccessor();
    value.id = value.id || ko.bindingHandlers.uniqueId.prefix + (++ko.bindingHandlers.uniqueId.counter);

    element.setAttribute("for", value.id);
  }
};

var viewModel = {
  items: [{
      name: ko.observable("one")
    },
    {
      name: ko.observable("two")
    },
    {
      name: ko.observable("three")
    }
  ]
};

ko.applyBindings(viewModel);
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.2/knockout-min.js"></script>

<ul data-bind="foreach: items">
  <li>
    <label data-bind="uniqueFor: name">Before</label>
    <input data-bind="uniqueId: name, value: name" />
    <label data-bind="uniqueFor: name">After</label>
  </li>
</ul>

(a JSFiddle sample is also available)

The nice thing about adding a property to the observable function is that when you turn it into JSON to send back to the server, then it will just naturally disappear as the observable will just turn into its unwrapped value.

Cristian Ciupitu
  • 20,270
  • 7
  • 50
  • 76
RP Niemeyer
  • 114,592
  • 18
  • 291
  • 211
  • 4
    I second that. But can I be irritating and ask for advice on a slightly more complex variant of the problem - where you have a pair of radios with e.g. "Yes" and "No" as the labels, each binding back to a single boolean observable, e.g. "IsActive". At the moment all 4 elements - the 2 radios and the 2 labels - all get the same ID. The pairs of radios themselves appear multiple times so I am generating a unique name attribute for each pair with a Knockout binding, so I need to be able to prefix that specific name of the current pair to the IDs. – Tom W Hall Jul 16 '12 at 03:55
  • You could also use data-bind="attr: { for: 'status_' + $index }" and data-bind="attr: { id: 'status_' + $index }" for unique IDs – viperguynaz Jul 26 '13 at 19:14
  • You could also use data-bind="attr: { for: 'status_' + $index }" and data-bind="attr: { id: 'status_' + $index }" for unique IDs. $index to refers to the zero-based index of the current array item. $index is an observable and is updated whenever the index of the item changes (e.g., if items are added to or removed from the array). – viperguynaz Jul 26 '13 at 19:27
  • 1
    `$index` did not exist when this question was asked, but could be used if you do not need the id to always be unique (if you remove the last item and add a new item they would have the same id). Also, in your binding, you will need to call `$index` as a function if you are using it in an expression like: `'status_' + $index()` – RP Niemeyer Jul 26 '13 at 21:36
  • By the way, the alternative to `element.setAttribute("for", value.id);` is `element.htmlFor = value.id;`. – Tomalak Oct 25 '13 at 16:42
  • 1
    One of the troubling issue I find here is that id is changed *after* the element have been added to the DOM. I wonder how many libraries/software are OK with this considering many of them would consider ID as immutable attribute once element is in DOM. For example, can screen readers listen to ID changes of elements and adapt to it? Also does this work in IE6+? Currently I'm using Handlebars to populate IDs and "for" labels before handling over to ko but not sure if that's overkill. – Shital Shah Dec 30 '13 at 06:37
7

I have done something like this in the past:

ko.bindingHandlers.uniqueId = {
    init: function(element) {
        element.id = ko.bindingHandlers.uniqueId.prefix + (++ko.bindingHandlers.uniqueId.counter);
    },
    counter: 0,
    prefix: "unique"
};

ko.bindingHandlers.uniqueFor = {
    init: function(element, valueAccessor) {
        var after = ko.bindingHandlers.uniqueId.counter + (ko.utils.unwrapObservable(valueAccessor()) === "after" ? 0 : 1);
          element.setAttribute("for", ko.bindingHandlers.uniqueId.prefix + after);
    } 
};

You would use them like:

<ul data-bind="foreach: items">
    <li>
        <label data-bind="uniqueFor: 'before'">Before</label>
        <input data-bind="uniqueId: true, value: name" />
        <label data-bind="uniqueFor: 'after'">After</label>
    </li>
</ul>

So, it just keeps state on the binding itself incrementing ko.bindingHandlers.uniqueId.counter. Then, the uniqueFor binding just needs to know whether it is before or after the field to know how to get the correct id.

Sample here: http://jsfiddle.net/rniemeyer/8KJD3/

If your labels were not near their fields (multiple inputs bound before each label perhaps in separate rows of a table), then you would need to look at a different strategy.

RP Niemeyer
  • 114,592
  • 18
  • 291
  • 211
  • I was thinking something along those lines too, and it will definitely work in my case, but what I don't like about this solution is that it depends on the order in which tags are rendered. Thank you for the code though, it's definitely one of the options. – Roman Bataev Feb 10 '12 at 20:07
  • If the order is a concern, then here is another option: http://jsfiddle.net/rniemeyer/JjBhY/. This is similar, but would have the binding set an "id" property on what is presumably an observable. Whichever binding hit it first would update the id. The nice thing about setting an "id" property on the observable function is that it will just disappear when you turn it into JSON, as you would only be left with the observable's unwrapped value. – RP Niemeyer Feb 10 '12 at 20:23
  • Thank you, this is exactly what I was looking for! Do you mind posting this as an answer so I could accept it? – Roman Bataev Feb 10 '12 at 20:50
3

I can't reply to the selected answer, but I have an enhanced version of the code that supports multiple unique ids per input value. It's on my blog at http://drewp.quickwitretort.com/2012/09/18/0 and repeated here:

ko.bindingHandlers.uniqueId = {
    /*
      data-bind="uniqueId: $data" to stick a new id on $data and
      use it as the html id of the element. 

      data-which="foo" (optional) adds foo to the id, to separate
      it from other ids made from this same $data.
    */
    counter: 0,
    _ensureId: function (value, element) {

    if (value.id === undefined) {
        value.id = "elem" + (++ko.bindingHandlers.uniqueId.counter);
    }

    var id = value.id, which = element.getAttribute("data-which");
    if (which) {
        id += "-" + which;
    }
    return id;
    },
    init: function(element, valueAccessor) {
        var value = valueAccessor();
        element.id = ko.bindingHandlers.uniqueId._ensureId(value, element);
    },
};

ko.bindingHandlers.uniqueFor = {
    /*
      data-bind="uniqueFor: $data" works like uniqueId above, and
      adds a for="the-new-id" attr to this element.

      data-which="foo" (optional) works like it does with uniqueId.
    */
    init: function(element, valueAccessor) {
        element.setAttribute(
        "for", ko.bindingHandlers.uniqueId._ensureId(valueAccessor(), element));
    } 
};

Now you can have multiple labeled checkboxes for one record with automatic ids:

<li data-bind="foreach: channel">
  <input type="checkbox" data-which="mute" data-bind="uniqueId: $data, checked: mute"> 
     <label data-which="mute" data-bind="uniqueFor: $data">Mute</label>

  <input type="checkbox" data-which="solo" data-bind="uniqueId: $data, checked: solo"> 
     <label data-which="solo" data-bind="uniqueFor: $data">Solo</label>
</li>
drewp
  • 316
  • 2
  • 5