How can I get angular.js checkboxes with select/unselect all functionality and indeterminate values?

51,000

Solution 1

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>

Solution 2

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.

Solution 3

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

Solution 4

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..

Solution 5

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.
Share:
51,000

Related videos on Youtube

Janus Troelsen
Author by

Janus Troelsen

I'm Danish and I studied computer science. I like Rust, Idris, Lamdu and Haskell.

Updated on March 30, 2020

Comments

  • Janus Troelsen
    Janus Troelsen about 4 years

    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.
  • Mark Rajcok
    Mark Rajcok about 11 years
    +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
    Mark Rajcok about 11 years
    <input checkbox-all ...> 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 <input type="text" ...>. 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
    Mark Rajcok about 11 years
    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.
  • Piran
    Piran about 11 years
    for the "list.isSelected" how about "list[].isSelected" or "list[*].isSelected" which indicate it's a magical value?
  • Piran
    Piran about 11 years
    By leaving the <input> in the HTML, the html can add class or id or or tab order or events on the checkbox, allowing things like setting up <label for=""> for the checkbox; putting the checkbox inside the template wouldn't allow this. The trouble with elements other than <input type=checkbox> is that you'd loose checkboxyness: we still need to allow users to click, and to display the checked or not-checked value.
  • Mark Rajcok
    Mark Rajcok about 11 years
    I still prefer two attributes, but this is just my opinion. Using replace: true and template: '<input type="checkbox">' 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.
  • Janus Troelsen
    Janus Troelsen about 11 years
    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
    Janus Troelsen about 11 years
    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
    Janus Troelsen about 11 years
    I now added a Plunker link to my answer. Please tell me if you don't see the difference.
  • Janus Troelsen
    Janus Troelsen about 11 years
    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.
  • Piran
    Piran about 11 years
    I never knew about indeterminate (ain't stackoverflow great!). Here's a plunker with the intermediate property being set on the checkbox: plnkr.co/edit/tgLWZurGdkrW2PqLWhMb?p=preview
  • Hayden
    Hayden almost 11 years
    You can do what is in your plunker with the standard ng-model and ng-checked directives. docs.angularjs.org/api/ng.directive:ngChecked
  • Janus Troelsen
    Janus Troelsen about 10 years
    this doesn't support indeterminate values
  • Rishul Matta
    Rishul Matta about 10 years
    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?
  • Amir Ali Akbari
    Amir Ali Akbari almost 10 years
    Another version, for cases when the listelemts is an object (mapping checkbox keys to true/false).