<!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);
}
});
}
};
}]);