Knockoutjs – A custom binding for grouped select elements

While building our new site we converted a lot of server-side ASP.NET WebForms code to client-side MVVM scripting, and for these purposes we used Knockoutjs. However, one of the sticking points – something that we almost thought that we’d have to do without – was a <select> element which made use of <optgroup> child elements.

There’s link at the bottom of the page which will download a zipped folder containing the custom binding as well as an HTML file showing some simple implementation examples.

Just to be clear, here’s a plain <select> element:

2014-01-24_1548

And here’s a <select> which contains <optgroup> elements:

2014-01-24_1548

The markup for this is quite simple:


<select>
	<option>- Please select -</option>
	<optgroup label="Advertising">
		<option value="1">Advertisement</option>
		<option value="4">Brochure</option>
	</optgroup>
	<optgroup label="Business">
		<option value="45">Annual report / company report</option>
		<option value="8">Company profile</option>
	</optgroup>
</select>

So what we’re hoping to achieve here is to provide Knockout with a custom binding which will enumerate a collection of collections, rendering <optgroup> elements for items in the outer collection and <option> elements for items in the inner collection.

A simple example

If we simply want to bind an observableArray to a <select> element then the Knockout website already has clear examples of how to achieve this, so we won’t repeat the foundation stuff here.

And if we were prepared to forego having the default “Please select” <option> element then we could simply declare a foreach binding on the <select> to take care of the <optgroup> elements, and a further foreach binding on the template <optgroup> element to take care of the inner <option> elements.

In which case, something like this would suffice.


<select data-bind="foreach: TextTypeGroups">
	<optgroup data-bind="attr: { label: Label }, foreach: Options">
		<option data-bind="text: Text, value: Id"></option>
	</optgroup>
</select>

But because we do wish to have a default “Please select” <option> element we now have the difficulty of where to enumerate the <optgroup> elements.

One apparently simple method of achieving this is to use what Knockout calls virtual elements. Let’s look at an example:


<select>
	<option value="">- Please select -</option>
	<!-- ko foreach: TextTypeGroups -->
		<optgroup data-bind="attr: { label: Label }, foreach: Options">
			<option data-bind="text: Text, value: Id"></option>
		</optgroup>
	<!-- /ko -->
</select>

The above example assumes that you have an observableArray named TextTypeGroups and that each element in TextTypeGroups has an observableArray named Options.

However, the problem with the above example is that it fails for IE8 and IE9. These browsers object to the HTML comment tags ( <!– … –> ) inside the <select> element, so they strip out these tags. Consequently this means that Knockout is then looking for the inner properties, Text and Id, on the outer observableArray, TextTypeGroups, which then causes an exception in the JavaScript.

Because of our support for IE8, and to make our solution portable, the solution we came up with was a custom binding.

Planning the solution

Let’s think first of all about what we would need for such a binding: well, we would need to know the name of the outer collection, and in this collection we’d need to know the name of the property which holds the text which is displayed in the <optgroup> element as well as the name of the collection which holds the <option> elements.

We would also need to know, for this inner collection, which property holds the text and which property holds the value.

So, thus far we have:

For the outermost collection (i.e. the collection which contains the <optgroup> elements)

  • the collection (as a property, observable or otherwise)
  • the name of the property containing the text label for each <optgroup>
  • the name of the property containing the inner collection which contains the <option> elements. And for this inner collection,
    • the name of the property containing the text for each <option>
    • the name of the property containing the value for each <option>

A proposed view-model

At this stage, let’s step aside from the custom binding and look at the view-model(s) with which we might represent this data.


function vmMain() {
	this.TextTypeGroups = ko.observableArray();
}

function vmTextTypeGroup() {
	this.Options = ko.observableArray();
	this.Label = ko.observable();
}

function vmTextType() {
	this.Text = ko.observable();
	this.Value = ko.observable();
}

In actual fact, in our custom binding the elements can be rendered from standard JavaScript properties as well as observables. Therefore, the above example could also be declared as


function vmMain() {
	this.TextTypeGroups = [];
}

function vmTextTypeGroup() {
	this.Options = [];
	this.Label = null;
}

function vmTextType() {
	this.Text = null;
	this.Value = null;
}

Obviously this second example of a view-model will not update when any of the properties are mutated, but our custom binding can still render the <select> when ko.applyBindings() is called.

Depending upon your own use you can choose to make things slightly more efficient (i.e. no observables) if you have no need for the benefits that observables bring.

Implementing the proposed view-model

We can see in this example JavaScript that if we start off with an instance of vmMain, this contains the collection TextTypeGroups which contains the <optgroup> elements, each of which will be represented by an instance of vmTextTypeGroup.

Perhaps the following pseudo-markup will explain things:


<select> <!-- bound to vmMain -->
	<option>- Please select -</option> <!-- bound to the specified defaultOpt -->
	<!-- for each instance of vmTextTypeGroup in vmMain.TextTypeGroups -->
		<optgroup label="[vmTextTypeGroup.Label]">
			<!-- for each instance of vmTextType in TextTypeGroups.Options -->
			<option value="[vmTextType.Value]">
				[vmTextType.Text]
			</option>
			<!-- / for each... -->
		</optgroup>
	<!-- / for each... -->
</select>

So that’s how our idealised view-model will appear. There’s one other consideration that we may wish to make: default/placeholder text. For example, we may wish to have “Please select” appear as the first option in the <select> element. This is something else that we would need to specify to our custom binding.

When all these required properties are then specified in Knockout’s data-bind attribute we have something like


data-bind="groupedSelect: { groups: { 
					coll: TextTypeGroups, 
					label: 'Label', 
					options: {
						coll: 'Options',
						text: 'Text',
						val: 'Value'
					}
				}, 
			defaultOpt: { 
					text: '- Please select -', 
					val: '' 
				} 
			}"

Using conventions

That’s probably the longest binding you’ll ever have to specify for Knockout, but we can reduce the complexity if we’re prepared to follow some conventions. For example, if we were prepared to always use the name Options for the collection of <option> elements then we can build the custom binding in such a way that if we don’t specify this property then we’ll assume that it should look for an array or an observableArray named Options.

And similarly, Text and Value seem to be fairly logical names so let’s offer the same convention for those as well. And while we’re at it, let’s do the same with the Label property on the <optgroup>-level collection.

Of course, if we don’t want to use those property names then that’s fine, you can still use our custom binding. The only downside is that you’ll have to explicitly tell the binding what those properties have been named.

If, on the other hand, you can follow these conventions then the Knockout data-bind attribute may be reduced to


data-bind="groupedSelect: { groups: { 
					coll: TextTypeGroups 
				}, 
			defaultOpt: { 
					text: '- Please select -' 
				} 
			}"

Having got that explanation of what the required properties are and why they’re required, here’s the custom binding. We’ll explain a few points afterwards.

Also, rather than copy the code from this page we suggest that you download the accompanying demo files (at the bottom of this article).


ko.bindingHandlers.groupedSelect = {
    update: function(element, valueAccessor) {

        var h = ko.utils.unwrapObservable(valueAccessor());

        // Get the parameters

        var groups = h["groups"],
            groupsCollection,
            groupsLabel = "Label",
            optionsCollProp = "Options",
            optionsTextProp = "Text",
            optionsValProp = "Value";

        if (typeof (groups) === "undefined" || !groups) {
            throw "The \"groupedSelect\" binding requires a \"groups\" object be specified.";
        } else {
            groupsCollection = groups["coll"];
        }
        if (!groupsCollection) {
            throw "The \"groupedSelect\" binding's \"groups\" object requires that a collection (array or observableArray) be specified.";
        }
        if (typeof (groups["label"]) === "string" && groups["label"].length) {
            groupsLabel = groups["label"];
        }
        if (typeof (groups["options"]) === "object") {
            var options = groups["options"];
            if (typeof (options["coll"]) === "string" && options["coll"].length) {
                optionsCollProp = options["coll"];
            }
            if (typeof (options["text"]) === "string" && options["text"].length) {
                optionsTextProp = options["text"];
            }
            if (typeof (options["val"]) === "string" && options["val"].length) {
                optionsValProp = options["val"];
            }
        }


        var defaultOpt = h["defaultOpt"],
            defaultOptText,
            defaultOptVal;
        if (typeof (defaultOpt) !== "undefined" && defaultOpt) {
            defaultOptText = defaultOpt["text"];
            defaultOptVal = defaultOpt["val"];
        }
        // only specify a default value for 'defaultOptVal' if 'defaultOptText' has been specified
        if ((typeof (defaultOptVal) !== "string" || !defaultOptVal || !defaultOptVal.length)
            && (typeof (defaultOptText) === "string" && defaultOptText && defaultOptText.length)) {
            defaultOptVal = "";
        }

		
        // find how many elements have already been added to 'element'
        var childCount = 0,
            children = element.childNodes,
            childMax = children.length;
        for (var c = 0; c < childMax; c++) {
            if (children[c].nodeType != 3) {
                childCount++;
            }
        }

        // Default <option> element

        // if 'element' is currently empty then add the default <option> element
        if (!childCount) {

            if (typeof(defaultOptText) === "string" && defaultOptText && defaultOptText.length) {
                var defaultOption = document.createElement("option");
                defaultOption.setAttribute("value", defaultOptVal);
                defaultOption.innerHTML = defaultOptText;
                element.appendChild(defaultOption);
            }
        } else {

            // if 'element' is not empty then decrement realChildren by 1, which represents the default <option> element
            childCount--;
        }


        // now it's time to loop through each <optgroup>
        // in this loop, i is set to the the index in the collection which marks the start of the newly-added items, skipping items already added (which were counted above)
        var coll = ko.utils.unwrapObservable(groupsCollection);
        childMax = coll.length;
        for (; childCount < childMax; childCount++) {

            var groupLabel = ko.utils.unwrapObservable(coll[childCount][groupsLabel]);

            // if there is no label for this <optgroup> then don't add the <optgroup>
            if (!groupLabel || !groupLabel.length) {
                continue;
            }

            var optGroup = document.createElement("optgroup");
            optGroup.setAttribute("label", groupLabel);

            // loop through each <option>
            // determine whether the <option>s collection is an array or an observableArray
            var options = ko.utils.unwrapObservable(coll[childCount][optionsCollProp]);
            for (var j = 0, jMax = options.length; j < jMax; j++) {

                var optionText = ko.utils.unwrapObservable(options[j][optionsTextProp]);

                // if there is no text for this <option> then don't add the <option>
                if (!optionText || !optionText.length) {
                    continue;
                }

                var option = document.createElement("option");
                option.innerHTML = optionText;

                // add the 'value' attribute if it exists
                var val = ko.utils.unwrapObservable(options[j][optionsValProp]);
                if (val && val.length) {
                    option.setAttribute("value", val);
                }

                // now add this <option> to the parent <optgroup>
                optGroup.appendChild(option);
            }

            element.appendChild(optGroup);
        }

        return true;
    }
};

Discussion

Yes, there’s quite a lot of code for a custom binding, but almost half is dealing with the parameters.

Something that might cause confusion is why we have to find out how many elements have already been added to the <select> element and start the next loop using this index. This is because of the way that Knockout works when binding observables: this custom binding uses the update handler of Knockout’s binding handler. This means that this binding will be called each time the outer collection is modified. Therefore, and using our above example, each time an instance of vmTextTypeGroup is added to the TextTypeGroups observableArray, this binding will be called. If we were to simply enumerate each element each time this binding would be called then suffice to say that we’d be rendering too many elements, and only the last instance of vmTextTypeGroup in the TextTypeGroups observableArray would appear once.

So what happens instead is that each time that this binding is called, we count how many elements have already been added, then we make the assumption that these elements correspond to elements in our Options parameter and by starting at our counted index we then render only the observableArray items which are new.

There are other ways around this but these would require an assumption on the part of the custom binding that the developer had always implemented them. And to make this custom binding more portable this safety measure has been implemented inside.

So there you have it, a pre-built handler which will parse an observableArray of observableArrays and render these as <optgroup>s containing <option>s.

The groupedSelect custom Knockoutjs binding is made available under the MIT license.

You can download the JavaScript and demo files here.

 


 

UPDATE: Since this blog post was written the custom binding has had a GitHub repository created. There have also been some small changes – including changing the name to knockout-groupedOptions and a couple of the parameters in order to align it with Knockout’s native options binding.

Although this blog posts still serves as a valid discussion of the custom binding, you should reference the GitHub version rather than this version.




Ähnliche Beiträge


Leave a Reply

Your email address will not be published. Required fields are marked *



*