Search code examples
javascriptknockout.jscustom-bindingko-custom-binding

Knockoutjs Radio Button Group Custom Binding Not Updating on Selection


I'm trying to create a custom binding in knockout that functions like the options default binding handler but uses radio buttons instead of a dropdown.

Adding an item to the array will notify the update but selecting a different radio button will not.

Note: I stripped the custom binding handler to the bare minimum.

Binding Handler

// LOOK FOR MY COMMENT //<------------------------------ LOOK FOR IT
ko.bindingHandlers.radioButtons = {
    update: function (element, valueAccessor, allBindings) {
        var unwrappedArray = ko.utils.unwrapObservable(valueAccessor());
        var previousSelectedValue = ko.utils.unwrapObservable(allBindings().value);


        // Helper function
        var applyToObject = function (object, predicate, defaultValue) {
            var predicateType = typeof predicate;
            if (predicateType == "function")    // Given a function; run it against the data value
                return predicate(object);
            else if (predicateType == "string") // Given a string; treat it as a property name on the data value
                return object[predicate];
            else                                // Given no optionsText arg; use the data value itself
                return defaultValue;
        };

        // Map the array to a newly created label and input.
        var isFirstPass = true;
        var radioButtonForArrayItem = function (arrayEntry, index, oldOptions) {
            if (oldOptions.length) {
                var item = oldOptions[0];
                if ($(item).is(":checked"))
                    previousSelectedValue = item.value;

                isFirstPass = false;
            }

            var input = element.ownerDocument.createElement("input");
            input.type = "radio";

            var radioButtonGroupName = allBindings.get("groupName");
            input.name = radioButtonGroupName;

            if (isFirstPass) {
                var selectedValue = ko.utils.unwrapObservable(allBindings.get("value"));
                var itemValue = ko.utils.unwrapObservable(arrayEntry.value);
                if (selectedValue === itemValue) {
                    input.checked = true;
                }
            } else if ($(oldOptions[0].firstElementChild).is(":checked")) {
                input.checked = true;
            }

            var radioButtonValue = applyToObject(arrayEntry, allBindings.get("radioButtonValue"), arrayEntry);
            ko.selectExtensions.writeValue(input, ko.utils.unwrapObservable(radioButtonValue));

            var label = element.ownerDocument.createElement("label");
            label.appendChild(input);
            var radioButtonText = applyToObject(arrayEntry, allBindings.get("radioButtonText"), arrayEntry);
            label.append(" " + radioButtonText);

            return [label];
        };

        var setSelectionCallback = function (arrayEntry, newOptions) {
            var inputElement = newOptions[0].firstElementChild;
            if ($(inputElement).is(":checked")) {
                var newValue = inputElement.value;
                if (previousSelectedValue !== newValue) {
                    var value = allBindings.get("value");
                    value(newValue); //<------------------------- Get observable value and set here. Shouldn't this notify the update?
                }
            }
        };

        ko.utils.setDomNodeChildrenFromArrayMapping(element, unwrappedArray, radioButtonForArrayItem, {}, setSelectionCallback);
    },
};

How to use it...

// Html
<div data-bind="
    radioButtons: letters, 
    groupName: 'letters', 
    radioButtonText: 'text', 
    radioButtonValue: 'value',   
    value: value,      
"></div>

// Javascript
var vm = {
    letters: ko.observableArray([
        {
            text: "A",
            value: 1,
        },
        {
            text: "B",
            value: 2,
        },
    ]),
    value: ko.observable(2),
};

ko.applyBindings(vm);

So, adding a new item to vm.letters({ text: "C", value: 2 }) will notify the update. However, clicking on a different radio button will not notify the update.

What do I need to do so that clicking on the radio button will notify the update?

DEMO HERE


Solution

  • It doesn't look like your binding has a way to update the observable when the selection changes. The two-way binding isn't automatic with custom bindings unless you're just passing parameters through to another existing binding. Event handlers like your "hack" are precisely what the "init" function of a custom binding is usually used for.

    The “init” callback

    Knockout will call your init function once for each DOM element that you use > the binding on. There are two main uses for init:

    • To set any initial state for the DOM element
    • To register any event handlers so that, for example, when the user clicks on or modifies the DOM element, you can change the state of the associated observable

    Try adding something like the following init function to your radioButtons binding:

    ko.bindingHandlers.radioButtons = {
      init: function(element, valueAccessor, allBindings){
        var unwrappedArray = ko.utils.unwrapObservable(valueAccessor());
        var value = allBindings().value;
    
        ko.utils.registerEventHandler(element, "click", function(event){
            var target = event.target;
            if(target.nodeName.toLowerCase() == "input"){
                value($(target).attr("value"));
            }
        });
      },
      update: function (element, valueAccessor, allBindings) {
          ...
      }
    

    There are probably safer ways to handle the click event than checking to see if the target is an "input" element; that's just a quick example. You'll have to modify that if you ever have nested radio buttons or other types of input elements as children within your radiobutton element. If in doubt you can always copy from the knockout "checked" binding source code.

    EDIT: Some things to fix and how I fixed them.

    1. Update the viewmodel's value property.
    2. Update the view when the value property is programmatically changed (two-way binding).
    3. If the selected radio button is removed, I need to set the value property to undefined.

    There is probably a better way to achieve all of this...

    ko.bindingHandlers.radioButtons = {
      init: function(element, valueAccessor, allBindings){
        //... error checks ... Remove all child elements to "element ... 
    
        var value = allBindings.get("value");
    
        // 1. Update viewmodel
        ko.utils.registerEventHandler(element, "click", function(event){
            var target = event.target;
            if(target.nodeName.toLowerCase() == "input"){
                value(target.value);
            }
        });
    
        // 2. Update view 
        value.subscribe(function (newValue) {
            var inputs = element.getElementsByTagName("input")
            $.each(inputs, function (i, input) {
                input.checked = input.value === newValue;
            });
        };
      },
      update: function (element, valueAccessor, allBindings) {
          ...
    
          var value = allBindings.get("value");
    
          // 3. Edge case: remove radio button of the value selected.
          var selectedRadioButton = unwrappedArray.find(function (item) { 
              return item.value === value(); 
          });
          if (selectedRadioButton == null) {
              value(undefined);
          }
      }
    }