<!DOCTYPE html>
<html>

  <head>
    <script data-require="angular.js@1.2.22" data-semver="1.2.22" src="https://code.angularjs.org/1.2.22/angular.js"></script>
    <link rel="stylesheet" href="style.css" />
    <script src="script.js"></script>
    <script src="likert.js"></script>
  </head>

  <body ng-app="Plunkr" ng-controller="Form1">
    <h1>Likert Scale custom AngularJS form control</h1>
    <p>Choice range {{0+si}}-{{3+si}}. Initialized to <span ng-init="n = choice">{{n | json}}</span>
    <p>Red border indicates <kbd>.ng-invalid</kbd>
    
    <form name="theForm" novalidate>
    <div class="likert" likert ng-model="choice" start-index="{{si}}" ng-required="isRequired" ng-disabled="isDisabled">
      <div>
          <b>Unsatisfactory</b>
      </div>
      <div>
          <b>Below Expectations</b>
      </div>
      <div>
          <b>Meets Expectations</b>
      </div>
      <div>
          <b>Exceeds Expectations</b>
      </div>
    </div>
    
    <br>
    
    ngModel Value: <input type="number" ng-model="choice"><br>
    
    <!-- For testing form validity -->
    <!--<input type="text" ng-model="x" required><button ng-click="x=''">X</button><br>-->
    
    Required: <button ng-click="isRequired=!isRequired">{{isRequired?'Required':'Not Required'}}</button><br>
    Disabled: <button ng-click="isDisabled=!isDisabled">{{isDisabled?'Disabled':'Not Disabled'}}</button><br>
    Start index: <button ng-click="si=(si)?0:1">{{(si)?1:0}}</button><br>
    
    <kbd style="white-space:pre-line">
      Data Dump:
      $scope.choice = {{choice | json}}
      $scope.theForm.$valid = {{theForm.$valid | json}}
      Errors: <span ng-repeat="(errKey,val) in theForm.$error" ng-show="val">{{errKey}} </span>
      Start index
      $scope.si = {{si | json}}
    </kbd>
    
    </form>
    
  </body>

</html>
// Code goes here

var app = angular.module('Plunkr', []);


app.controller('Form1', function ($scope) {
  // Likert choice / model variable
  // no nested scope, so by-value is ok
  $scope.choice = null;
  
  // start index (0 or 1)
  $scope.si = 0;
  
  // for testing form validity
  //$scope.x = 'required value';
});
/* Styles go here */
.likert {
  display: table;
  border-collapse: collapse;
  width: 100%;
}
.likert > div {
  display: table-cell;
  border: 1px solid #c3c3c3;
  text-align: center;
  cursor: pointer;
}
.likert > div > b {
  display: block;
  background-color: #ebebeb;
  font-size: 12pt;
  border-bottom: .0833333em solid #c3c3c3;
  padding: 1em;
  text-align: center;
}
.likert > div:after {
  content: '';
  display: inline-block;
  background-color: #d4d4d4;
  width: 2.01em;
  height: 2em;
  border-radius: 1em;
  margin: 1em 1em .8em 1em;
}
.likert > div:hover:after {
  background-color: #9f9f9f;
}
.likert > div.selected:after {
  content: ' ';
  font-size: .9em;
  width: 2.23em;
  height: 2.23em;
  border-radius: 2em;
  margin-top: 1.1em;
  line-height: 2.2;
  font-family: 'icomoon';
  color: #fff;
  speak: none;
  font-weight: normal;
  font-variant: normal;
  text-transform: none;
  -webkit-font-smoothing: antialiased;
  background-color: #ca5241;
}
.likert[disabled] > div {
  cursor: default;
}
.likert[disabled] > div:after {
  background-color: transparent;
}
.likert[disabled] > div:hover:after {
  background-color: transparent;
}
.likert[disabled] > div.selected:after {
  background-color: #ca5241;
}


.ng-invalid {
  box-shadow: 0 0 0 1px red;
}

# Angular custom form control

A custom form control written in Angular the right way (based on Angular's input directive).

A likert scale allows the user to choose a response from a scale of 1-4, for example.

This directive supports two-way data binding, 0 and 1-indexed values, and ngDisabled.
It also validates the range of values.

I did my best to make this a pristine example of how to create a custom directive. 
If you see any way I can make it better (or more like the Angular Way), then 
contact me on [Google+](https://plus.google.com/+MatthiasDailey).

Author: [Matthias Dailey](https://plus.google.com/+MatthiasDailey)
/**
 * 
 * Likert scale
 * 
 * Sample usage:
    <div class="likert" likert ng-model="myLikertValue" ng-disabled="asdf" start-index="1">
        <div>
            <b>Unsatisfactory</b>
        </div>
        <div>
            <b>Below Expectations</b>
        </div>
        <div>
            <b>Meets Expectations</b>
        </div>
        <div>
            <b>Exceeds Expectations</b>
        </div>
    </div>
 * 
 * Active class: .selected is added to the child element when selected
 * Validation classes: 
 *      see ngRequired
 * 
 * start-index attribute can be 0 or 1. Default 0.
 * 
 * Data binding:
 *      No matter how many child elements the likert scale has, the model will be set to the 1-based index of the 
 *      selected option.
 * 
 * Allows deselection.
 * Respects the `disabled` attribute.
 * 
 * Requires:
 *      `app` variable to be an Angular module. Or modify to suit your needs.
 * 
 */

// based on example code from [NgModelController](http://docs.angularjs.org/api/ng.directive:ngModel.NgModelController)
// and on angular's native input directives: https://github.com/angular/angular.js/blob/master/src/ng/directive/input.js#L902
app.directive('likert', [
        
function() {
    'use strict';
    
    // Configuration
    var selectedClass = 'selected';
    
    return {
        restrict: 'A',
        require: '?ngModel', // get a hold of NgModelController
        link: function(scope, element, attrs, ngModel) {
          
            if (!ngModel) return;
            
            var choice, numChoices = 0;
            
            var listener = function() {
                var value = choice;
                
                if (ngModel.$viewValue !== value) {
                    if (scope.$$phase) {
                        ngModel.$setViewValue(value);
                    } else {
                        scope.$apply(function() {
                            ngModel.$setViewValue(value);
                        });
                    }
                }
            };
            
            var isDisabled;
            var startIndex = 0;
            
            // count choices
            angular.forEach(element.children(), function(child) {
                // count visible items
                if (child.style.display != 'none') numChoices++;
            });
            
            var isSelected = function (el) {
                return (el.className.indexOf(selectedClass) !== -1);
            };
            
            // update view selection
            var updateViewSelection = function () {
                if (isNaN(choice)) {return;}
                angular.forEach(element.children(), function(child, i){
                    if (i + startIndex === choice) {
                        angular.element(child).addClass(selectedClass);
                    }
                    else {
                        angular.element(child).removeClass(selectedClass);
                    }
                });
            };
            
            var deselectItem = function (el) {
                angular.element(el).removeClass(selectedClass);
            };
            
            var getIndexInSiblings = function (elem) {
                for (var i = 0, l = elem.parentNode.children.length; i < l; i++) {
                    if (elem.parentNode.children[i] === elem) {
                        return i;
                    }
                }
                return -1;
            };
            
            element.children().on('click', function(e){
                if (isDisabled) {return;}
                var i = getIndexInSiblings(e.currentTarget) + startIndex;
                // if clicking an empty box, select it. Else unset choice
                if (choice != i) {
                    choice = i;
                }
                else {
                    choice = null;
                }
                listener();
            });
            
            // $render is run when the $modelValue programmatically changes, but never as a result of $setViewValue
            ngModel.$render = function() {
                choice = ngModel.$modelValue;
                updateViewSelection();
                //ngModel.$setValidity('likertBlank', !isNaN(ngModel.$viewValue));
            };
            
            // this is called only when $setViewValue is called
            ngModel.$viewChangeListeners.push(updateViewSelection);
            
            // validate the model value. Make sure it's within the range
            var validate = function(value) {
                var valid = true;
                if (!ngModel.$isEmpty(value)) {
                    if (value < startIndex || value - startIndex >= numChoices)
                        valid = false;
                }
                ngModel.$setValidity('likertRange', valid);
                return (valid ? value : undefined);
            };
            ngModel.$formatters.unshift(validate);
            ngModel.$parsers.unshift(validate);
            // before 1.3 we can't use the ngModel.$validators pipeline :(
            
            // override $isEmpty
            ngModel.$isEmpty = function (value) {
                return !value && (value !== 0);
            };
            
            // when the value of @disabled changes, the update the variable
            attrs.$observe('disabled', function () {
                isDisabled = !!attrs.disabled;
            });
            attrs.$observe('startIndex', function (si) {
                var orig = startIndex;
                startIndex = (si === '0') ? 0 : 1;
                if (startIndex != orig) {
                    /*// keep choice number in sync
                    // actually the model value shouldn't be changed. Commenting this out.
                    //choice += (startIndex ? 1 : -1); // too complicated
                    // Expanded:
                    if (startIndex == 1) {
                        // was 0. increment choice
                        choice++;
                    }
                    else {
                        // was 1. decrement choice
                        choice--;
                    }*/
                    listener();
                    
                    // updateViewSelection() must be called manually because the 
                    // change to startIndex does not change the actual 
                    // model value. $render won't run, and $setViewValue won't trigger 
                    // $viewChangeListeners because the view value is the same.
                    updateViewSelection();
                    
                    // and check validation again
                    validate(ngModel.$modelValue);
                }
            });
        }
    };
}]);