Tri-State Check Box in HTML with AngularJS and TypeScript

26 February 2015 - AngularJS, HTML, TypeScript

HTML does not support tri-state checkboxes by default. There is an indeterminate attribute to indicate that the value is undefined but there is no way to set a checkbox back to indeterminate through the user interface once it has been checked or unchecked. The attribute can only be changed with JavaScript.

Current State in HTML and JavaScript

The following screenshot shows how the three states of a checkbox are visualized in Internet Explorer 11. Other browsers visualize the states slightly different.

This is how the HTML for the tree checkboxes looks like:

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title>Check Box States</title>
    <script>
        window.onload = function (e) {
            document.getElementById("indeterminateCheckBox").indeterminate = true;
        }
    </script>
</head>
<body>
    <input type="checkbox" id="indeterminateCheckBox" /> Indeterminate checkbox</p>
    <p><input type="checkbox" checked="checked" /> Checked checkbox</p>
    <p><input type="checkbox" /> Unchecked checkbox</p>
</body>
</html>

As the indeterminate state cannot be set in HTML, it has to be set in the window.onload function with JavaScript and it cannot be set back to indeterminate by a user once it has lost the indeterminate state.

Building the AngularJS TriStateCheckBox Directive

The directive offers two properties to bind against: isThreeState and isChecked. The CheckBoxScopeDeclaration class specifies these properties.

The $scope of the directive of type ICheckBoxScope holds the current values for these two properties. Additionally it offers a function updateState, which is used in the template of the directive to update the isChecked property whenever the checkbox is clicked.

The linking function of the directive finds the HTMLInputElement for the checkbox. This is required as the indeterminate state of the checkbox can only be set from JavaScript. So it is not possible to do this via data binding. Additionally, it defines the updateState function and adds a $watch listener for the isChecked property. Whenever the value is set to null or undefined and the isThreeState property is set to true, the checkbox is set back to indeterminate state.

/// <reference path="../../Scripts/typings/jquery/jquery.d.ts" />
/// <reference path="../../Scripts/typings/angularjs/angular.d.ts" /> 

module Samples.Controls {
    /** 
    * Scope declaration for CheckBox.
    */
    export class CheckBoxScopeDeclaration {
        isChecked: string;
        isThreeState: string;
    }

    /** 
    * Interface for CheckBox scope.
    */
    export interface ICheckBoxScope extends ng.IScope {
        isChecked: boolean;
        isThreeState: boolean;
        updateState: () => void;
    }

    /**
    * HTML checkbox with three states: true, false and null.
    * @class
    */
    export class CheckBox implements ng.IDirective {
        public template: any;
        public link: (scope: ng.IScope, instanceElement: ng.IAugmentedJQuery, instanceAttributes: ng.IAttributes, controller: any, transclude: ng.ITranscludeFunction) => void;
        public restrict: string;
        public transclude: boolean;
        public scope: CheckBoxScopeDeclaration;

        /**
        * Creates a new CheckBox directive.
        */
        public static Create(): CheckBox {
            var checkBox = new CheckBox();

            checkBox.restrict = "EA";
            checkBox.template = "<input type=\"checkbox\" ng-click=\"updateState()\" /><span ng-transclude></span>";
            checkBox.transclude = true;

            checkBox.scope = new CheckBoxScopeDeclaration();
            checkBox.scope.isChecked = "=";
            checkBox.scope.isThreeState = "=";

            // Initialize component
            checkBox.link = ($scope: ICheckBoxScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes) => {
                var checkBoxInputElement = <HTMLInputElement>element[0].childNodes[0];
                checkBoxInputElement.indeterminate = $scope.isThreeState && $scope.isChecked == null;
                checkBoxInputElement.checked = $scope.isChecked;

                // Update the scope values when the checkbox is clicked.
                $scope.updateState = () => checkBox.UpdateState($scope);

                // Update the checked and indeterminate attribute of the checkbox control.
                $scope.$watch("isChecked",
                    (newValue, oldValue) => {
                        if (oldValue != newValue) {
                            checkBoxInputElement.indeterminate = $scope.isThreeState && newValue == null;
                            checkBoxInputElement.checked = newValue;
                        }
                    },
                    true);
            };

            return checkBox;
        }

        /** 
        * Change state of isChecked in scope if attribute checked on checkbox has changed.
        * @param {ICheckBoxScope} scope - The scope of the CheckBox directive.
        */
        private UpdateState($scope): void {
            if ($scope.isChecked === false) {
                $scope.isChecked = true;
            } else if ($scope.isChecked === true && $scope.isThreeState) {
                $scope.isChecked = null;
            } else {
                $scope.isChecked = false;
            }
        }
    }
}

How to Use the Directive in HTML

The goal for the directive built with AngularJS and TypeScript was to:

  • be able to set all three states directly in HTML without the need for JavaScript
  • allow data binding in AngularJS

The first HTML snippet shows how the directive can be used to set the values in HTML:

<div class="col-md-4">
    <p><span tri-state-check-box is-three-state="true" is-checked="null">Tri-state check box 1</span></p>
</div>
<div class="col-md-4">
    <p><span tri-state-check-box is-three-state="true" is-checked="true">Tri-state check box 2</span></p>
</div>
<div class="col-md-4">
    <p><span tri-state-check-box is-three-state="true" is-checked="false">Tri-state check box 3</span></p>
</div>

The next snippet shows how data binding works. For each state there is a property in the controller: myBooleanValue1, myBooleanValue2 and myBooleanValue3. Each is bound to a checkbox to change the value and a span tag to display the current value.

<div class="col-md-4">
    <p><span tri-state-check-box is-three-state="true" is-checked="myBooleanValue1">Tri-state check box 1</span></p>
    <p>Check box value: <span ng-bind="myBooleanValue1 == null ? '-' : myBooleanValue1" /></p>
</div>
<div class="col-md-4">
    <p><span tri-state-check-box is-three-state="true" is-checked="myBooleanValue2">Tri-state check box 2</span></p>
    <p>Check box value: <span ng-bind="myBooleanValue2 == null ? '-' : myBooleanValue2" /></p>
</div>
<div class="col-md-4">
    <p><span tri-state-check-box is-three-state="true" is-checked="myBooleanValue3">Tri-state check box 3</span></p>
    <p>Check box value: <span ng-bind="myBooleanValue3 == null ? '-' : myBooleanValue3" /></p>
</div>

The controller for the view contains the tree boolean values:

sampleApp.controller("testCheckBoxController", ['$scope', function ($scope) {
    $scope.myBooleanValue1 = null;
    $scope.myBooleanValue2 = true;
    $scope.myBooleanValue3 = false;
}]);

Here is the result for the view and the controller:

Limitations

The indeterminate state of the checkbox is not displayed correctly in Safari. There is no visual difference between an unchecked checkbox and an indeterminate checkbox. To make it work in Safari too, you can replace the checkbox visualization of the browser with your own. There is a simple and nice implementation at http://w3facility.org/question/custom-html-checkbox-symbols-with-keyboard-navigation-support/.

Try in JSFiddle

Download Source Code

You can get the complete source code for the directive and the HTML sample pages at https://github.com/karin112358/Samples/tree/master/AngularJS/TriStateCheckBox. There you will find both the TypeScript file and the automatically generated JavaScript file for the directive.