<!DOCTYPE html>
<html ng-app="app">

  <head>
    <script data-require="jquery@*" data-semver="2.1.4" src="http://code.jquery.com/jquery-2.1.4.min.js"></script>
    <link data-require="bootstrap@*" data-semver="3.3.5" rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" />
    <script data-require="angular.js@1.4.5" data-semver="1.4.5" src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.5/angular.js"></script>
    <link rel="stylesheet" href="style.css" />
    
    <script src="checkboxDirectives.js"></script>
    <script src="script.js"></script>
  </head>

  <body ng-controller="mainCtrl" class="container">
    
    <h2><input type="checkbox" tri-state ng-model="data.root" /> Store </h2>
    
    <div ng-repeat="division in data.divisions">
      
      <h3><input type="checkbox" tri-state ng-model="division.included" parent-model="data.root" /> {{division.name}}</h3>
    
      <ul>
        <li ng-repeat="cat in division.categories">
          
          <h3><input type="checkbox" tri-state ng-model="cat.included" parent-model="division.included" /> {{cat.name}} </h3>
          
          <ul>
            <li ng-repeat="prod in cat.products">
              <h4><input type="checkbox" tri-state ng-model="prod.included" parent-model="cat.included" /> {{prod.name}}</h4>
            </li>
          </ul>
          
        </li>
      </ul>
      
    </div>
    
  </body>

</html>

angular
  .module('app', ['checkboxDirectives'])
  .run(function(){
    console.clear();
  })
  .controller('mainCtrl', function($scope) {
    
    $scope.data =
    {
      root : false,
      
      // two divisions ('Inside' + 'Outside')
      divisions :
        [{
          included : false,
          name : 'Inside',
          
          categories : [
          {
            name : 'Home Improvement',
            products : [
              {name : 'Boxcutter', included : true},
              {name : 'Hammer', included : null},
              {name : 'Screwdriver', included : false}
            ]
          },
          
          {
            name : 'Painting',
            products : [
              {name : 'Red Paint', included : false},
              {name : 'Green Paint', included : true},
              {name : 'Blue Paint', included : null},
              {name : 'Coarse Brush', included : true}
            ]
          }]
        },
        
        {
          included : false,
          name : 'Outside',
          
          categories : [
          {
            name : 'Garage Improvement',
            products : [
              {name : 'Axe', included : true},
              {name : 'Chainsaw', included : true},
              {name : 'Leaf Blower', included : false}
            ]
          },
          
          {
            name : 'Car',
            products : [
              {name : 'Spare Tires', included : false},
              {name : 'Exhaustion Pipe', included : true},
              {name : 'Gearbox', included : null},
              {name : 'First Aid Kit', included : false}
            ]
          }]
        }]
    }
  })
  
  
  
  
  
  
  
/* Styles go here */

angular.module('checkboxDirectives', [])

  // this directive enables fourway-binding for checkboxes
  // true > checked
  // false > unchecked
  // undefined > tristate / inditerminate
  // null > disabled checkbox
  .directive('triState', function($parse){
    return {
      restrict : 'A',
      link : function(scope, element, attrs) {
        scope.$watch(attrs.ngModel, function(newVal, oldVal) {
          if (newVal === null) {
            element.prop('checked', false);
            element.prop('disabled', true);
          }
          else if (newVal === undefined) {
            element.prop('checked', false);
            element.prop('indeterminate', true);
          } else {
            element.prop('checked', newVal);
            element.prop('indeterminate', false);
          }
        });
      }
    }
  })
  
  // this directive enabled parent/child relations between boolean models
  //   - if all children are true, the parent will be true
  //   - if all children are false, the parent will be false
  //   - if some children are true, the parent will be undefined
  //   - 'null' values will not be considered
  .directive('parentModel', function($parse, $timeout) {
    var initialized = false;

    var childToParent = {}
    var parentToChildren = {}
    var nodeLookup = {}
    var rootNodes = [];
    var leafNodes = [];
    
    function Node(scope, path){
      this.scope = scope;
      this.path = path;
      this.parentNode = null;
      this.childNodes = null;
      this.accessor = $parse(path);
      this.unwatch = null;
    }
    
    Node.prototype = {
      getValue : function(){
        return this.accessor(this.scope);
      },
      
      setValue : function(value){
        if (this.getValue() === null) // don't set value, if bound to 'null' (meaning 'disabled')
          return;
          
        this.accessor.assign(this.scope, value);
      },
      
      getRootNode : function(){
        if (this.parentNode === null)
          return this;
          
        return this.parentNode.getRootNode();
      },
      
      // register $watch on each node's ngModel path (recursive)
      // will only be called from root-nodes and trickle down
      startWatching : function(){
        if (this.unwatch)
          return;

        var self = this;
        this.unwatch = this.scope.$watch(this.path, function(newValue, oldValue){
          if (newValue === oldValue)
            return;
            
          self.changeHandler(newValue, oldValue);
        });
        
        angular.forEach(this.childNodes, function(node){
          node.startWatching();
        })
      },
      
      // remove $watch for each node's ngModel path (recursive)
      // will only be called from root-nodes and trickle down
      stopWatching : function(){
        if (!this.unwatch)
          return;
        
        this.unwatch();
        this.unwatch = null;
        
        angular.forEach(this.childNodes, function(node){
          node.stopWatching();
        })
      },
      
      changeHandler : function(newValue, oldValue){
        
        var rootNode = this.getRootNode();
        
        rootNode.stopWatching();
        
        this.updateParentState();
        this.updateChildStates(newValue);
        
        rootNode.startWatching();
      },
      
      updateState : function(){
        var states = this.childNodes.map(function(childNode) {
          return childNode.getValue();
        }).filter(function(val) {return val !== null;})
        
        var allChecked = states.reduce(function(it, current){ return it && current && current !== undefined}, true);
        var allUnchecked = states.reduce(function(it, current) { return it && !current && current !== undefined}, true);
        
        if (allChecked)
          this.setValue(true);
        else if (allUnchecked)
          this.setValue(false);
        else
          this.setValue(undefined);
          
        this.updateParentState();
      },
      
      updateParentState : function(){
        if (this.parentNode === null)
          return;
        
        this.parentNode.updateState();
      },
      
      updateChildStates : function(value){
        this.setValue(value);
        
        if (!this.childNodes)
          return;
        
        angular.forEach(this.childNodes, function(childNode){
          childNode.updateChildStates(value);
        })
      }
    }
    
    // helper for findScope()
    function hasPropertyPath(scope, modelPathSplit){
      var first = modelPathSplit[0];
      
      if (modelPathSplit.length == 1)
        return true;
        
      if (!scope.hasOwnProperty(first))
        return false;
        
      return hasPropertyPath(scope[first], modelPathSplit.splice(0, 1));
    }
    
    // find correct scope for a binding path (traverse up the chain)
    function findScope(scope, modelPath) {
      if (hasPropertyPath(scope, modelPath.split('.')))
        return scope;
      
      if (!scope.$parent)
        return null;
        
      return findScope(scope.$parent, modelPath);
    }
    
    // build lookup key for scope/path combination
    function key(scope, path){ 
      return scope.$id + '|' + path; 
    }
    
    // builds lookups for
    //   - parentKey > childKeys
    //   - childKey > parentKey
    //   - key > Node
    function collectRelations(parentScope, parentPath, childScope, childPath){
      var parentKey = key(parentScope, parentPath);
      var childKey = key(childScope, childPath);
      
      childToParent[childKey] = parentKey;
      if (!parentToChildren.hasOwnProperty(parentKey))
        parentToChildren[parentKey] = [];
        
      parentToChildren[parentKey].push(childKey);
      
      nodeLookup[childKey] = new Node(childScope, childPath);
      nodeLookup[parentKey] = new Node(parentScope, parentPath);
    }
    
    // find parent node for every node and append
    // set 'parentNode' property as well as 'childNodes' property
    // basically build a tree to traverse later on
    function buildHierarchies() {
      angular.forEach(parentToChildren, function(childKeys, parentKey){
        var parentNode = nodeLookup[parentKey];
        angular.forEach(childKeys, function(childKey){
          var childNode = nodeLookup[childKey];
          if (!parentNode.childNodes)
            parentNode.childNodes = [];

          if (parentNode.childNodes.indexOf(childNode) < 0)
            parentNode.childNodes.push(childNode);
            
          childNode.parentNode = parentNode;
        })
      })
    }
    
    // set initial checkbox states and start watching for changes
    function initializeStates() {
      // iterate all nodes and find rootnode(s) and leafnodes
      angular.forEach(nodeLookup, function(node){
        if (node.parentNode === null)
          rootNodes.push(node);
        
        if (node.childNodes === null)
          leafNodes.push(node);
      })
      
      // from all leaf nodes upwards: update parent and parent's parent
      angular.forEach(leafNodes, function(leafNode){
        leafNode.updateParentState();
      })
      
      // from all root nodes downard: start watching for changes
      angular.forEach(rootNodes, function(rootNode){
        rootNode.startWatching();
      })
    }
    
    return {
      restrict : 'A',
      link : function(scope, element, attrs){
        // first we collect all parent/child relations, before building a hierarchy
        // and start watching
        var parentScope = findScope(scope, attrs.parentModel);
        collectRelations(parentScope, attrs.parentModel, scope, attrs.ngModel);
        
        // this ensures that the following init calls are run after all other directives (of the same type) were linked'
        $timeout(function(){
          if (initialized)
            return;
          
          buildHierarchies();
          initializeStates();
          
          initialized = true;
        }, 0);
      }
    }
  })