28

I am looking for something exactly like these (tri-state checkboxes with "parents"). But using that solution wouldn't be elegant, as I do not depend on jQuery right now, and I would need to call $scope.$apply to get the model to recognize the automatically (un)checked checkboxed jQuery clicked.

Here's a bug for angular.js that requests ng-indeterminate-value implemented. But that still wouldn't give me the synchronization to all the children, which is something I don't think should be a part of my controller.

What I am looking for would be something like this:

  • A "ng-children-model" directive with syntax like: <input type="checkbox" ng-children-model="child.isSelected for child in listelements">. The list of booleans would be computed, and if 0 selected -> checkbox false. If all selected -> checkbox true. Else -> checkbox indeterminate.
  • In my controller, I would have something like this: $scope.listelements = [{isSelected: true, desc: "Donkey"},{isSelected: false, desc: "Horse"}]
  • The checkboxes would be made as usual with <tr ng-repeat="elem in listelements"><td><input type="checkbox" ng-model="elem.isSelected"></td><td>{{elem.desc}}</td></tr>.
  • As I understand it, the browser will determine which state a clicked indeterminate checkbox goes into.
Janus Troelsen
  • 20,267
  • 14
  • 135
  • 196

8 Answers8

20

I think the sample solution you give puts too much code into the controller. The controller should really only be worry about the list, and the HTML/Directives should be handling the display (including displaying the Select All checkbox). Also, all state changes are through the model, not by writing functions.

I've put together a solution on Plunker: http://plnkr.co/edit/gSeQL6XPaMsNSnlXwgHt?p=preview

Now, the controller just sets up the list:

app.controller('MainCtrl', function($scope) {
    $scope.list = [{
        isSelected: true,
        desc: "Donkey"
    }, {
        isSelected: false,
        desc: "Horse"
    }];
});

and the view simply renders those out:

<div ng-repeat="elem in list">
  <input type="checkbox" ng-model="elem.isSelected" /> {{elem.desc}}
</div>

For the Select All checkbox, I've created a new directive called checkbox-all:

  <input checkbox-all="list.isSelected" /> Select All

And that's it as far as use goes, which is hopefully simple... apart from writing that new directive:

app.directive('checkboxAll', function () {
  return function(scope, iElement, iAttrs) {
    var parts = iAttrs.checkboxAll.split('.');
    iElement.attr('type','checkbox');
    iElement.bind('change', function (evt) {
      scope.$apply(function () {
        var setValue = iElement.prop('checked');
        angular.forEach(scope.$eval(parts[0]), function (v) {
          v[parts[1]] = setValue;
        });
      });
    });
    scope.$watch(parts[0], function (newVal) {
      var hasTrue, hasFalse;
      angular.forEach(newVal, function (v) {
        if (v[parts[1]]) {
          hasTrue = true;
        } else {
          hasFalse = true;
        }
      });
      if (hasTrue && hasFalse) {
        iElement.attr('checked', false);
        iElement.addClass('greyed');
      } else {
        iElement.attr('checked', hasTrue);
        iElement.removeClass('greyed');
      }
    }, true);
  };
});

The parts variable breaks down the list.isSelected into its two parts, so I can get the value of list from the scope, an the isSelected property in each object.

I add the type="checkbox" property to the input element, making it a real checkbox for the browser. That means that the user can click on it, tab to it, etc.

I bind on the onchange event rather than onclick, as the checkbox can be changed in many ways, including via the keyboard. The onchange event runs inside a scope.$apply() to ensure that the model changes get digested at the end.

Finally, I $watch the input model for changes to the checkbox (the last true allows me to watch complex objects). That means if the checkboxes are changed by the user or for some other reason, then the Select All checkbox is always kept in sync. That's much better than writing lots of ng-click handlers.

If the checkboxes are both checked and unchecked, then I set the master checkbox to unchecked and add the style 'greyed' (see style.css). That CSS style basically sets the opacity to 30%, causing the checkbox to appear greyed, but it's still clickable; you can also tab to it and use spacebar to change its value.

I've tested in Firefox, Chrome and Safari, but I don't have IE to hand. Hopefully this works for you.

Piran
  • 7,180
  • 1
  • 24
  • 37
  • +1. Instead of passing "list.isSelected" (which looks like a reference to something, but it isn't), I suggest two separate attributes. Or, only pass "list" and let the directive assume there is an "isSelected" property on the array objects (that's what I did in my answer). – Mark Rajcok Feb 11 '13 at 21:16
  • 1
    `` and adding the type as 'checkbox' in the directive -- this seems like the user has to remember to set up half of the checkbox in the HTML, and then the other half gets set up by the directive. I would suggest replacing the HTML with a template that contained ``. This would allow the user to specify any element (div, span, input, etc.) and it would always work. Or the directive could be a new element, rather than an attribute. – Mark Rajcok Feb 11 '13 at 21:21
  • for the "list.isSelected" how about "list[].isSelected" or "list[*].isSelected" which indicate it's a magical value? – Piran Feb 12 '13 at 00:17
  • By leaving the in the HTML, the html can add class or id or or tab order or events on the checkbox, allowing things like setting up – Piran Feb 12 '13 at 00:19
  • I still prefer two attributes, but this is just my opinion. Using `replace: true` and `template: ''` will result in all of the attributes you specified in the HTML being migrated to the new `input` element (this is a feature of Angular directives). So, using replace and template will ensure that the element becomes an `input` if it is not, and it will ensure the type is `checkbox`. This way, the user doesn't have to use `input` in the HTML, and it will still work. What you have is fine, this is just a bit more foolproof. – Mark Rajcok Feb 12 '13 at 02:11
  • 1
    The indeterminate state isn't being triggered in this example either, as far as I can see (testing in Opera). Is it even possible to trigger from CSS? I don't know how to to that. I'd appreciate it if the code was updated, since I think the indeterminate state demonstrates better than CSS styling, since it uses the native indeterminate state. – Janus Troelsen Feb 12 '13 at 13:59
  • I added a Plunker to my answer so it is easier to see the difference the indeterminate state induces. In any case, thanks very much for helping. – Janus Troelsen Feb 12 '13 at 14:08
  • 1
    I never knew about indeterminate (ain't stackoverflow great!). Here's a plunker with the intermediate property being set on the checkbox: http://plnkr.co/edit/tgLWZurGdkrW2PqLWhMb?p=preview – Piran Feb 12 '13 at 18:37
20

Since you want a new type/kind of component, this sounds like a good case for a custom directive.
Since the parent/master/tri-stated checkbox and the individual dual-state checkboxes need to interact with each other, I suggest a single directive, with its own controller, to handle the logic.

<tri-state-checkbox checkboxes="listelements"></tri-state-checkbox>

Directive:

app.directive('triStateCheckbox', function() {
  return {
    replace: true,
    restrict: 'E',
    scope: { checkboxes: '=' },
    template: '<div><input type="checkbox" ng-model="master" ng-change="masterChange()">'
      + '<div ng-repeat="cb in checkboxes">'
      + '<input type="checkbox" ng-model="cb.isSelected" ng-change="cbChange()">{{cb.desc}}'
      + '</div>'
      + '</div>',
    controller: function($scope, $element) {
      $scope.masterChange = function() {
        if($scope.master) {
          angular.forEach($scope.checkboxes, function(cb, index){
            cb.isSelected = true;
          });
        } else {
          angular.forEach($scope.checkboxes, function(cb, index){
            cb.isSelected = false;
          });
        }
      };
      var masterCb = $element.children()[0];
      $scope.cbChange = function() {
        var allSet = true, allClear = true;
        angular.forEach($scope.checkboxes, function(cb, index){
          if(cb.isSelected) {
            allClear = false;
          } else {
            allSet = false;
          }
        });
        if(allSet)        { 
          $scope.master = true; 
          masterCb.indeterminate = false;
        }
        else if(allClear) { 
          $scope.master = false; 
          masterCb.indeterminate = false;
        }
        else { 
          $scope.master = false;
          masterCb.indeterminate = true;
        }
      };
      $scope.cbChange();  // initialize
    },
  };
});

Change the template to suit your needs, or use an external template with templateUrl.

The directive assumes that the checkboxes array contains objects that have an isSelected property and a desc property.

Plunker.

Update: If you prefer to have the directive only render the tri-stated checkbox, hence the individual checkboxes are in the HTML (like @Piran's solution), here's another plunker variation for that. For this plunker, the HTML would be:

<tri-state-checkbox checkboxes="listelements" class="select-all-cb">
</tri-state-checkbox>select all
<div ng-repeat="item in listelements">
   <input type="checkbox" ng-model="item.isSelected"> {{item.desc}}
</div>
Mark Rajcok
  • 362,217
  • 114
  • 495
  • 492
  • You can do what is in your plunker with the standard ng-model and ng-checked directives. http://docs.angularjs.org/api/ng.directive:ngChecked – Hayden Jul 30 '13 at 15:55
  • [Another version](http://plnkr.co/edit/w2zuiTboZsuU1pCj9F6n?p=preview), for cases when the `listelemts` is an object (mapping checkbox keys to true/false). – Amir Ali Akbari May 29 '14 at 21:15
5

Here's a refined version of Piran's solution. Using .prop() instead of .attr() fixes the checked issue.

Usage:

<div ng-repeat="elem in list">
    <input type="checkbox" ng-model="elem.isSelected" /> {{elem.desc}}
</div>
<ui-select-all items="list" prop="isSelected"></ui-select-all> Select all
wldaunfr
  • 589
  • 7
  • 8
2

I believe that you should only be creating a directive if you only need to do some kind of a DOM manipulation or want to abstract away a lot of DOM manipulative behaviour into a "re-usable" component.

Here is a solution which achieves the same thing that you were attempting, but, this does only the logic in the controllers... If you want to keep the controllers lean, then you could push away all this logic into service...A service would also be a good place to do this, if you want to re-use this in multiple places..

http://plnkr.co/edit/hNTeZ8Tuht3T9NuY7HRi?p=preview

Note that there is no DOM manipulation in the controller. We are achieving the effect we require using a bunch of directives that are provided with Angular. No new directive required.. I really dont think you should use a directive to abstract away logic..

Hope this helps..

ganaraj
  • 26,841
  • 6
  • 63
  • 59
  • Setting the `indeterminate` property on the "master" checkbox makes me want to put this in a directive. Also, since services are singletons, I think it would be difficult to use that approach, especially if there were multiple instances of this tri-stated checkbox in the app... the service would need to keep track of them all somehow. I opted for a directive with its own controller, for the logic. – Mark Rajcok Feb 11 '13 at 21:36
  • I don't see any indeterminate state getting triggered at all in your example on Opera. If horse XOR donkey I expect "Select all" to be in the indeterminate state. But thanks very much for contributing. – Janus Troelsen Feb 12 '13 at 13:50
  • I now added a Plunker link to my answer. Please tell me if you don't see the difference. – Janus Troelsen Feb 12 '13 at 14:06
1

If you can't assume that ng-model is assigned to a boolean model (e.g. Y/N, '0'/'1') and/or you prefer to have your own markup, an approach that leverages ngModel capabilities, and makes no assumption on HTML structure is better, IMHO.

Example: http://plnkr.co/edit/mZQBizF72pxp4BvmNjmj?p=preview

Sample usage:

  <fieldset indeterminate-group>
    <legend>Checkbox Group</legend>
    <input type="checkbox" name="c0" indeterminate-cue> Todos <br>
    <input type="checkbox" name="c1" ng-model="data.c1" ng-true-value="'Y'" ng-false-value="'F'" indeterminate-item> Item 1 <br>
    <input type="checkbox" name="c2" ng-model="data.c2" ng-true-value="'Y'" ng-false-value="'F'" indeterminate-item> Item 2 <br>
    <input type="checkbox" name="c3" ng-model="data.c3" ng-true-value="'Y'" ng-false-value="'F'" indeterminate-item> Item 3 <br>
  </fieldset>

Directive (main parts):

angular.module('app', [])
  .directive('indeterminateGroup', function() {
    function IndeterminateGroupController() {
      this.items = [];
      this.cueElement = null;
    }
    ...
    function setAllValues(value) {
      if (this.inChangeEvent) return;

      this.inChangeEvent = true;
      try {
        this.items.forEach(function(item) {
          item.$setViewValue(value);
          item.$render();
        });
      } finally {
        this.inChangeEvent = false;
      }
    }

    return {
      restrict: "A",
      controller: IndeterminateGroupController,
      link: function(scope, element, attrs, ctrl) {
        ctrl.inputChanged = function() {
          var anyChecked = false;
          var anyUnchecked = false;
          this.items.forEach(function(item) {
            var value = item.$viewValue;
            if (value === true) {
              anyChecked = true;
            } else if (value === false) {
              anyUnchecked = true;
            }
          });

          if (this.cueElement) {
            this.cueElement.prop('indeterminate', anyChecked && anyUnchecked);
            this.cueElement.prop('checked', anyChecked && !anyUnchecked);
          }
        };
      }
    };
  })
  .directive('indeterminateCue', function() {
    return {
      restrict: "A",
      require: '^^indeterminateGroup',
      link: function(scope, element, attrs, indeterminateGroup) {
        indeterminateGroup.addCueElement(element);
        var inChangeEvent = false;
        element.on('change', function(event) {
          if (event.target.checked) {
            indeterminateGroup.checkAll();
          } else {
            indeterminateGroup.uncheckAll();
          }
        });
      }
    };
  })
  .directive('indeterminateItem', function() {
    return {
      restrict: "A",
      require: ['^^indeterminateGroup', 'ngModel'],
      link: function(scope, element, attrs, ctrls) {
        var indeterminateGroup = ctrls[0];
        var ngModel = ctrls[1];
        indeterminateGroup.addItem(ngModel);
        ngModel.$viewChangeListeners.push(function() {
          indeterminateGroup.inputChanged();
        });
      }
    };
  });

Model:

// Bring your own model

TODO:

  • get rid of item.$render() inside main directive controller;
  • give a better name to the directive;
  • make easy to use this directive in more than one table column.
André Werlang
  • 5,839
  • 1
  • 35
  • 49
0

Plunker

"use strict";

var module = angular.module("myapp", []);

function Ctrl($scope) {
    var element = $("#select_all");
    $scope.$watch("$scope.isgreyed", $scope.fun = function() {
        element.prop("indeterminate", $scope.isgreyed);
    });
    $scope.list = [{
        isSelected: true,
        desc: "Donkey"
    }, {
        isSelected: false,
        desc: "Horse"
    }]
    $scope.isgreyed = true;
    $scope.master = false;
    $scope.onmasterclick = function() {
        $scope.list.map(function(v) {
            v.isSelected = $scope.master
        })
    }

    $scope.oncheckboxclick = function() {     
        if ($('.select_one:checked').length === 0) {
            $scope.isgreyed = false;
            $scope.master = false;     
        } else if ($('.select_one:not(:checked)').length === 0) {
            $scope.isgreyed = false;
            $scope.master = true;     
        } else {
            $scope.isgreyed = true;     
        }
        $scope.fun();
    }      
}

HTML:

<div ng-controller="Ctrl">
<table>
<tr>
  <td>
     <input type="checkbox" id="select_all" ng-model="master" ng-click="onmasterclick()">
  </td>
</tr>
<tr ng-repeat="elem in list">
  <td>
    <input ng-click="oncheckboxclick(elem)" class="select_one" type="checkbox" ng-model="elem.isSelected">
  </td>
  <td>{{elem.desc}}</td>
</tr>
</table>
</div>

Yes, it's ugly.

Janus Troelsen
  • 20,267
  • 14
  • 135
  • 196
0

Rewritten using Plnker to a bit better code without resource-consuming ForEach's and some other complicated stuff:

var app = angular.module('angularjs-starter', []);

app.controller('MainCtrl', function($scope) {
  $scope.listelements = [{
    isSelected: true,
    desc: "Donkey"
  }, {
    isSelected: false,
    desc: "Horse"
  }];
});

app.directive('triStateCheckbox', function() {
  return {
    replace: true,
    restrict: 'E',
    scope: {
      checkboxes: '='
    },
    template: '<input type="checkbox" ng-model="master" ng-change="masterChange()">',
    controller: function($scope, $element) {
      $scope.masterChange = function() {
        for(i=0;i<$scope.checkboxes.length; i++)
          $scope.checkboxes[i].isSelected=$scope.master;
      };
      $scope.$watch('checkboxes', function() {
        var set=0;
        for (i=0;i<$scope.checkboxes.length;i++)
          set += $scope.checkboxes[i].isSelected?1:0;
        $element.prop('indeterminate', false);
        $scope.master = (set === 0) ? false : true;
        if (set > 0 && set < i) {
          $scope.master = false;
          $element.prop('indeterminate', true);
        }
      }, true);
    }
  };
});
0

i guess it can be solved by combining angular with javascript:

<div>

<input type="checkbox" id="select-all" name="selectAll" value="" ng-click="checkAll($event)" />

<div >
  <input type="checkbox"  name="childCheckbox" value=""  />

  <input type="checkbox"  name="childCheckbox" value=""  />

  <input type="checkbox"  name="childCheckbox" value=""  />

  <input type="checkbox"  name="childCheckbox" value=""  />

  <input type="checkbox"  name="childCheckbox" value=""  />

  <input type="checkbox"  name="childCheckbox" value=""  />
 </div>

</div>

in checkAll() the following logic will do the job

  $scope.checkAll = function (source) {
   checkboxes = document.getElementsByName('childCheckbox');                                                 
   for (var i = 0, n = checkboxes.length; i < n; i++)   {
     checkboxes[i].checked = source.originalEvent.srcElement.checked;
   }
Rishul Matta
  • 3,383
  • 5
  • 23
  • 29
  • this doesn't support indeterminate values – Janus Troelsen Feb 13 '14 at 11:11
  • you mean changing the values of child and un checking the select all check box? This can be done by calling a function on click of child check boxes and which would uncheck the parent... is that fine? – Rishul Matta Feb 13 '14 at 12:12