Angular tri-state checkbox

Home / Angular tri-state checkbox

angular_small
While working with an Angular application that involved selecting rows in a table with checkboxes, I found that the requirements necessitated having a ‘select all’ checkbox.

Selecting all elements is a common, and usually pretty easy, mechanism to incorporate. But, if you want an indeterminate, or tri, state, for your top-level checkbox selector, it becomes a little more involved.


Indeterminate state simply means that we’re somewhere in-between all and none. In most browsers, this denoted as a horizontal bar within the checkbox.

At any rate, I decided this would be nice to design as a reusable directive. The basic premise would be that each bound item in the table (rows) would have an ‘isSelected’ parameter. There would be a watcher on each of these elements such that if any one of them is selected/deselected, then the state of the top-level selector would change accordingly. Conversely, if the top-level selector is selected/deselected each of the items in the rows would be selected/deselected accordingly. You’ve probably seem jQuery plug-ins that do this by manipulating DOM elements.

Additionally, we’ll want to watch our items to see if they are modified outside of the directive to adjust properly. The directive code would look something like this:

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

With just that bit of code, and the mark-up below, things worked pretty well.

<th colspan="2" class="header-check">
    <tri-state-checkbox class="toggle-all" items="vm.records"></tri-state-checkbox>
</th>

However, when displaying many rows (1000 or so), performance really becomes sluggish due to the number of watchers involved. To work around this issue, I turned to events. I added an attribute to the directive that will allow the directive to performance actions $on a particular broadcast.

var triStateCheckbox = function () {
    var directive = {
        replace: true,
        restrict: 'E',
        scope: {
            items: '=',
            topLevelClick: '=',
            childClick: '@childClick'
        },
        template: '<input type="checkbox" ng-model="topLevel" ng-change="topLevelChange()">',
        controller: ['$scope', '$element', function ($scope, $element) {

            $scope.setState = function () {
                var count = 0;
                for (i = 0; i < $scope.items.length; i++)
                    count+= $scope.items[i].isSelected ? 1 : 0;
                $element.prop('indeterminate', false);
                $scope.topLevel = (count === 0) ? false : true;
                if (count > 0 && count < i) {
                    $scope.topLevel = false;
                    $element.prop('indeterminate', true);
                }
            };

            $scope.topLevelChange = function () {
                for (i = 0; i < $scope.items.length; i++) {
                    $scope.items[i].isSelected = $scope.topLevel;
                }
                if ($scope.topLevelClick) {
                    $scope.topLevelClick();
                }
            };

            if (!$scope.childClick) {
                $scope.$watch('items', function () {
                    $scope.setState();
                }, true);
            } else {
                $scope.$on($scope.childClick, function () {
                    $scope.setState();
                });
                $scope.setState();
            }
        }]
    };

    return directive;
};

The HTML changes to this:

<table>
    <thead>
        <tr>
            <th colspan="2" class="header-check">
                <tri-state-checkbox class="toggle-all" items="vm.items" child-click="childClick"></tri-state-checkbox>
            </th>
        </tr>
    </thead>
    <tbody>
        <tr ng-repeat="i in vm.items track by i.id">
            <td class="td-checkbox"><input type="checkbox" ng-model="i.isSelected" ng-click="vm.selectionChanged(i)"></td>
        </tr>
    </tbody>
</table>

And in our controller, we simply broadcast the change event:

vm.selectionChanged = function (item) {
    $scope.$broadcast("childClick", item);
};

Effectively, this eliminates the need for the watcher yet keeps everything in sync and performing well.

Below is a working fiddle showing the directive in action.

Leave a Reply