12

I'm trying to write a directive that toggles classes based on a selector condition:

<label class-when="{'is-checked': ':has(input:checked)', 'is-disabled': ':has(input:disabled)'}">
    <input type="checkbox">
    Example checkbox
</label>

I need to somehow watch for DOM changes on the element and its descendents but I'm getting an ng:areq error. How can I do this?

define(function (require) {
    var _ = require('lodash');

    return {
        restrict: 'A',
        scope: {
            object: '@classWhen'
        },
        link: function (scope, element) {
            scope.$watchCollection(function() {
                return element.find('*').add(element);
            }, function () {
                _.forOwn(scope.object, function (test, classes) {
                    test = typeof test === 'boolean' ? test : element.is(test);
                    element.toggleClass(classes, test);
                });
            });
        }
    };
});
12
  • 1
    @ryanyuyu I would assume that a directive is required because of a structural directive on a parent element, or the element itself (that is just not shown in the code provided). I could be wrong, though Commented Oct 6, 2016 at 21:36
  • 1
    I have solved one major issue for you, which is that your scope.object is a string, so doing _.forOwn() gives you each character in the string with an index as the key. To fix this, you need to do a JSON.parse() on scope.object, which leads to the next issue. Your string is invalid JSON because it has single quotes surrounding the properties/values and it needs double quotes to be valid JSON. The issue then remains that your directive does not fire when the user checks/unchecks the checkbox. I'm still working on that part, but I think i've given you a big step in the right direction Commented Oct 6, 2016 at 22:09
  • 1
    Or just use the two way "=" binding for the scope.object. Commented Oct 6, 2016 at 22:18
  • 2
    All this is just wrong from angular view. You should never do things like this. If you want container style to depend on inputs - directives should be on each input element, not one magic directive on top. Commented Oct 7, 2016 at 2:24
  • 1
    @ryanve Check my updated solution Commented Nov 1, 2016 at 19:36

3 Answers 3

6

Okay, after reading your bounty comment, I understand that you want this to be controller independent. I spent some time completely reworking my solution, and I think I have finally figured out a way to accomplish what you want.

It really comes down to 2 things:

1) Detecting a change on the checkbox :checked status, and

2) Detecting a change on the checkbox :disabled status

Detecting 1) was easy, as you can use a simple jQuery change handler, but detecting 2) took a bit more research. It requires the use of scope.$watch on the child ng-disabled attribute.

Here is a demo of how this would work:

var app = angular.module("myApp", [])
  .directive("classWhen", function() {
    function setClasses(classWhen, $element, $input) {
      Object.keys(classWhen).forEach(function(key) {
        var test = classWhen[key];
        // .toggleClass("className", true) === .addClass("className")
        // .toggleClass("className", false) === .removeClass("className")
        $element.toggleClass(key, $element.is(test));
      });
    }
    return {
      restrict: 'A',
      link: function link (scope, element, attrs) {
        var classWhen = JSON.parse(attrs.classWhen);
        var $element = $(element);
        $element.find("*").each(function (index, elem) {
          var $elem = $(this);
          // namespace the change event so we can easily .off() it
          $elem.off("change.classWhen");
          $elem.on("change.classWhen", function () {
            setClasses(classWhen, $element, $elem);
          });
          // watch child ng-disabled attribute
          scope.$watch($elem.attr("ng-disabled"), function (val) {
            setClasses(classWhen, $element, $elem);
          });
        });
      }
    };
  });
.is-checked {
  background-color: yellow;
}
.is-disabled {
  background-color: lightblue;
}
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.2.23/angular.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<div ng-app="myApp">
  <label>
    <input type="checkbox" ng-model="disableAll" />Disable All</label>
  <br>
  <br>
  <label class-when='{"is-checked": ":has(input:checked)", "is-disabled": ":has(input:disabled)"}'>
    <input type="checkbox" ng-disabled="disableAll">Example checkbox 1
  </label>
  <br>
  <label class-when='{"is-checked": ":has(input:checked)", "is-disabled": ":has(input:disabled)"}'>
    <input type="checkbox" ng-disabled="disableAll">Example checkbox 2
  </label>
  <br>
  <label class-when='{"is-checked": ":has(input:checked, .test)", "is-disabled": ":has(input:disabled)"}'>
    <input type="text" ng-disabled="disableAll" ng-class="testingClass" ng-model="testingClass"/>
    <br>
    <input type="checkbox" ng-disabled="disableAll">
    Example checkbox 3
  </label>
  <br>
  <label class-when='{"is-checked": ":has(input:checked)", "is-disabled": ":has(input:disabled)"}'>
    <input type="checkbox" ng-disabled="disableAll">Example checkbox 4
  </label>
</div>

Sign up to request clarification or add additional context in comments.

2 Comments

Thanks for the updated answer! This seems to work in the snippet runner but i still get the ng:areq error when in my angular environment
@ryanve ng:areq is a generic error for a bad argument. It obviously is not an issue with my code, so I have no way to debug your issue unless you post more details about it. Can you give me a line number, the extended description of the error, and the related code in the area? Or better yet, can you post a fiddle that reproduces the error?
1

Here's my attempt to solve your problem in generic way: https://plnkr.co/edit/kVyJQClmQhF3FeBTmKaX?p=preview

Directive code:

app.directive('classWhen', () => (scope, element, attrs) => {
  scope.$watchCollection(getClasses, setClasses)

  function getClasses() {
    // We have to evaluate expression in attrs.classWhen so that we get object
    const rawClasses = scope.$eval(attrs.classWhen)
    const classes = {}

    // then we normalize it so that strings are treated as jquery selectors
    Object.keys(rawClasses).forEach((className) => {
      const expr = rawClasses[className]
      classes[className] = typeof expr === 'string'
        ? element.is(expr)
        : !!expr // we normalize falsy values to booleans
    })

    // then we return it to $watchCollection
    return classes
  }

  /**
   * will be called whenever any of the classes changes
   */
  function setClasses(classes) {
    Object.keys(classes).forEach((className) => {
      element.toggleClass(className, classes[className])
    })
  }
})

4 Comments

@ryanve does it solve your problem? How can I improve my answer?
I like the simplicity of this approach but depending on how the DOM changes it take a few seconds to work. Like as if it waits for the next digest
The problem with this solution, is the same as one of my original solutions to this (if you look at the edit history on my answer). #1, it does not handle multiple checkboxes individually, and #2, it relies on controller variables, which the OP stated in the bounty comment: "I'd like to do this in a generic directive or filter that does not require anything from a controller"
@ryanve what do you mean by how the DOM changes? Can you provide an example plunk where it behaves like that? My guess is that you're changing it from outside of digest. In my plunk I added ng-model to the input exactly because it solves triggering digest whenever input value changes.
0

I have created a generic fiddle for this and also with RequireJS as you used.

Please have a look here

http://fiddle.jshell.net/balasuar/dugmu/15/

Note: Whatever property you added in the selector like :checked and :disabled, add the related angular property to the child element like ng-model and ng-disabled. It will work like a charm !!

Code

var app = window.app = angular.module("myApp", []);

define('classWhenDirective', function () {
    //var _ = require('lodash');
    var app = window.app;debugger;
    app.directive("classWhen", function() {
    return {
    restrict: 'A',
    link: function(scope, element, attrs) {
          scope.$watch(getChanges, setClasses)

  function getChanges() {
    var attrs = [];
    
    function getAttrs(elm) {
       $.each(elm.attributes, function(i, attrib){
     var name = attrib.name;
     var value = attrib.value;
       if(value && name.indexOf('ng-') > -1) {
         value = scope.$eval(value);
       }
     if(value) {      
       attrs.push(name + ':' + value);
     }
  });
     
    }
    
    getAttrs(element.get(0));
    
    element.children().each(function(){
       getAttrs(this);
    });
    
    return attrs.join(',');
  }

  function setClasses() {
    var rawClasses = scope.$eval(attrs.classWhen);

    Object.keys(rawClasses).forEach(function(className){
      var expr = rawClasses[className]
      var setClass = typeof expr === 'string'
        ? element.is(expr)
        : !!expr;
       element.toggleClass(className, setClass);
    });
  }
    }
  };   
    });
});

define('todo', ['classWhenDirective'], function() {
  var app = window.app;
  app.controller('myController', function($scope) {

  });
});

// angular bootstrap
require(['todo'], function() {
  angular.bootstrap(document, ['myApp']);
});
.is-checked {
  color: green;
}
.is-disabled {
  color: gray;
}
<script src="http://requirejs.org/docs/release/2.0.6/minified/require.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.2.23/angular.min.js"></script>
<h2>ClassWhen Directive</h2>

<div ng-controller="myController">
  <label>
    <input type="checkbox" ng-model="disableAll" />Disable All</label>
  <br>
  <br>
  <label class-when='{"is-checked": ":has(input:checked)", "is-disabled": ":has(input:disabled)"}'>
    <input type="checkbox" ng-model="checkBox1" ng-disabled="disableAll">ClassWhen checkbox 1
  </label>
  <br>
  <label class-when='{"is-checked": ":has(input:checked)", "is-disabled": ":has(input:disabled)"}'>
    <input type="checkbox" ng-model="checkBox2" ng-disabled="disableAll">ClassWhen checkbox 2
  </label>
</div>

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.