├── .gitignore
├── .travis.yml
├── CHANGELOG.md
├── README.md
├── bower.json
├── dist
├── angular-money-directive.js
├── angular-money-directive.min.js
└── angular-money-directive.min.js.map
├── gulpfile.js
├── karma.conf.js
├── package.json
├── src
└── angular-money-directive.js
└── test
└── angular-money-directive.spec.js
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "4.1"
4 | sudo: false
5 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 1.2.5 (Dec 15 2015)
4 |
5 | Fix #31: Make the $formatter support `0` correctly.
6 |
7 | ## 1.2.4 (Oct 27 2015)
8 |
9 | Fix changes not getting committed on blur when ng-model-options="{updateOn: 'blur'}".
10 |
11 | ## 1.2.3 (Oct 27 2015)
12 |
13 | Point "main" to dist for bower.
14 |
15 | ## 1.2.2 (Oct 23 2015)
16 |
17 | Build dist.
18 |
19 | ## 1.2.1 (Oct 23 2015)
20 |
21 | Fix #17 (again) and #28 (bad release — forgot to build dist)
22 |
23 | ## 1.2.0 (Oct 11 2015)
24 |
25 | Support for Angular 1.3 and up.
26 |
27 | ## 1.1.1 (Oct 11 2015)
28 |
29 | Fixed the shitshow in 1.1.0. I'm sorry about that :(
30 |
31 | ## 1.1.0 (Feb 16 2015)
32 |
33 | Made `min`, `max` and `precision` attributes dynamic.
34 |
35 | ## 1.0.2 (Aug 6 2014)
36 |
37 | This is the last version where things were mostly working swimmingly on Angular 1.2.
38 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # angular-money-directive
2 |
3 | [](http://travis-ci.org/fiestah/angular-money-directive)
4 |
5 | [Quick Demo](https://fiestah.github.io/angular-money-directive/)
6 |
7 | This directive validates monetary inputs in "42.53" format (some additional work is needed for "32,00" European formats). Note that this is _not_ designed to work with currency symbols. It largely behaves like Angular's implementation of `type="number"`.
8 |
9 | It does a few things:
10 |
11 | - Prevents entering non-numeric characters
12 | - Prevents entering the minus sign when `min >= 0`
13 | - Supports `min` and `max` like in ``
14 | - Rounds the model value by `precision`, e.g. `42.219` will be rounded to `42.22` by default
15 | - On `blur`, the input field is auto-formatted. Say if you enter `42`, it will be formatted to `42.00`
16 |
17 | Version 1.2.x supports Angular 1.3 and up. Version 1.1.x will continue to work for Angular 1.2.
18 |
19 |
20 | ## Usage:
21 |
22 | ```
23 | npm install angular-money-directive
24 | ```
25 |
26 | or
27 |
28 | ```
29 | bower install angular-money-directive
30 | ```
31 |
32 |
33 | Load the unminified or minified file from `dist` dir:
34 |
35 | ```
36 |
37 | ```
38 |
39 | Then include it as a dependency in your app.
40 |
41 | ```
42 | angular.module('myApp', ['fiestah.money'])
43 | ```
44 |
45 |
46 | ### Attributes:
47 |
48 | - `money`: _required_
49 | - `ng-model`: _required_
50 | - `type`: Set to `text` or just leave it out. Do _not_ set to `number`.
51 | - `min`: _optional_ Defaults to `0`.
52 | - `max`: _optional_ Not enforced by default
53 | - `precision`: _optional_ Defaults to `2`. Set to `-1` to disable rounding. Rounding is also disabled if `parseInt(precision, 10)` does not return `0` or higher.
54 |
55 | Basic example:
56 |
57 | ``` html
58 |
59 | ```
60 |
61 | `min`, `max` and `precision` can be set dynamically:
62 |
63 | ``` html
64 |
65 | ```
66 |
67 | ## Tests:
68 |
69 | 1. Install test deps: `npm install`
70 | 1. Run: `./node_modules/karma/bin/karma start`
71 |
--------------------------------------------------------------------------------
/bower.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "angular-money-directive",
3 | "main": "dist/angular-money-directive.js",
4 | "ignore": [
5 | "**/.*",
6 | "node_modules",
7 | "components"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/dist/angular-money-directive.js:
--------------------------------------------------------------------------------
1 | (function () {
2 | 'use strict';
3 |
4 | /**
5 | * Heavily adapted from the `type="number"` directive in Angular's
6 | * /src/ng/directive/input.js
7 | */
8 |
9 | var NUMBER_REGEXP = /^\s*(\-|\+)?(\d+|(\d*(\.\d*)))\s*$/;
10 | var DEFAULT_PRECISION = 2;
11 |
12 | angular.module('fiestah.money', [])
13 |
14 | .directive('money', ["$parse", function ($parse) {
15 | function link(scope, el, attrs, ngModelCtrl) {
16 | var minVal, maxVal, precision, lastValidViewValue;
17 | var isDefined = angular.isDefined;
18 | var isUndefined = angular.isUndefined;
19 | var isNumber = angular.isNumber;
20 |
21 | /**
22 | * Returns a rounded number in the precision setup by the directive
23 | * @param {Number} num Number to be rounded
24 | * @return {Number} Rounded number
25 | */
26 | function round(num) {
27 | var d = Math.pow(10, precision);
28 | return Math.round(num * d) / d;
29 | }
30 |
31 | /**
32 | * Returns a string that represents the rounded number
33 | * @param {Number} value Number to be rounded
34 | * @return {String} The string representation
35 | */
36 | function formatPrecision(value) {
37 | return parseFloat(value).toFixed(precision);
38 | }
39 |
40 | function isPrecisionValid() {
41 | return !isNaN(precision) && precision > -1;
42 | }
43 |
44 | function isValueValid(value) {
45 | return angular.isNumber(value) && !isNaN(value);
46 | }
47 |
48 | function updateValuePrecision() {
49 | var modelValue = ngModelCtrl.$modelValue;
50 |
51 | if (isValueValid(modelValue) && isPrecisionValid()) {
52 | ngModelCtrl.$modelValue = round(modelValue);
53 | $parse(attrs.ngModel).assign(scope, ngModelCtrl.$modelValue);
54 | changeViewValue(formatPrecision(modelValue));
55 |
56 | // Save the rounded view value
57 | lastValidViewValue = ngModelCtrl.$viewValue;
58 | }
59 | }
60 |
61 | function changeViewValue(value) {
62 | ngModelCtrl.$viewValue = value;
63 | ngModelCtrl.$commitViewValue();
64 | ngModelCtrl.$render();
65 | }
66 |
67 |
68 | ngModelCtrl.$parsers.push(function (value) {
69 | if (ngModelCtrl.$isEmpty(value)) {
70 | lastValidViewValue = value;
71 | return null;
72 | }
73 |
74 | // Handle leading decimal point, like ".5"
75 | if (value.indexOf('.') === 0) {
76 | value = '0' + value;
77 | }
78 |
79 | // Allow "-" inputs only when min < 0
80 | if (value.indexOf('-') === 0) {
81 | if (minVal >= 0) {
82 | changeViewValue('');
83 | return null;
84 | } else if (value === '-' || value === '-.') {
85 | return null;
86 | }
87 | }
88 |
89 | if (NUMBER_REGEXP.test(value)) {
90 | // Save as valid view value if it's a number
91 | lastValidViewValue = value;
92 | return parseFloat(value);
93 | } else {
94 | // Render the last valid input in the field
95 | changeViewValue(lastValidViewValue);
96 | return lastValidViewValue;
97 | }
98 | });
99 |
100 |
101 | // Min validation
102 | ngModelCtrl.$validators.min = function (value) {
103 | return ngModelCtrl.$isEmpty(value) || isUndefined(minVal) || value >= minVal;
104 | };
105 | if (isDefined(attrs.min) || attrs.ngMin) {
106 | attrs.$observe('min', function(val) {
107 | if (isDefined(val) && !isNumber(val)) {
108 | val = parseFloat(val, 10);
109 | }
110 | minVal = isNumber(val) && !isNaN(val) ? val : undefined;
111 | ngModelCtrl.$validate();
112 | });
113 | } else {
114 | minVal = 0;
115 | }
116 |
117 |
118 | // Max validation
119 | if (isDefined(attrs.max) || attrs.ngMax) {
120 | ngModelCtrl.$validators.max = function(value) {
121 | return ngModelCtrl.$isEmpty(value) || isUndefined(maxVal) || value <= maxVal;
122 | };
123 |
124 | attrs.$observe('max', function(val) {
125 | if (isDefined(val) && !isNumber(val)) {
126 | val = parseFloat(val, 10);
127 | }
128 | maxVal = isNumber(val) && !isNaN(val) ? val : undefined;
129 | ngModelCtrl.$validate();
130 | });
131 | }
132 |
133 | // Round off (disabled by "-1")
134 | if (isDefined(attrs.precision)) {
135 | attrs.$observe('precision', function (value) {
136 | precision = parseInt(value, 10);
137 |
138 | updateValuePrecision();
139 | });
140 | } else {
141 | precision = DEFAULT_PRECISION;
142 | }
143 |
144 | ngModelCtrl.$parsers.push(function (value) {
145 | if (value) {
146 | // Round off value to specified precision
147 | value = isPrecisionValid() ? round(value) : value;
148 | }
149 | return value;
150 | });
151 |
152 | ngModelCtrl.$formatters.push(function (value) {
153 | if (isDefined(value)) {
154 | return isPrecisionValid() && isValueValid(value) ?
155 | formatPrecision(value) : value;
156 | } else {
157 | return '';
158 | }
159 | });
160 |
161 | // Auto-format precision on blur
162 | el.bind('blur', function () {
163 | ngModelCtrl.$commitViewValue();
164 | updateValuePrecision();
165 | });
166 | }
167 |
168 | return {
169 | restrict: 'A',
170 | require: 'ngModel',
171 | link: link
172 | };
173 | }]);
174 |
175 | })();
176 |
--------------------------------------------------------------------------------
/dist/angular-money-directive.min.js:
--------------------------------------------------------------------------------
1 | !function(){"use strict";var n=/^\s*(\-|\+)?(\d+|(\d*(\.\d*)))\s*$/,e=2;angular.module("fiestah.money",[]).directive("money",["$parse",function(i){function r(r,t,u,a){function o(n){var e=Math.pow(10,p);return Math.round(n*e)/e}function s(n){return parseFloat(n).toFixed(p)}function l(){return!isNaN(p)&&p>-1}function f(n){return angular.isNumber(n)&&!isNaN(n)}function c(){var n=a.$modelValue;f(n)&&l()&&(a.$modelValue=o(n),i(u.ngModel).assign(r,a.$modelValue),d(s(n)),v=a.$viewValue)}function d(n){a.$viewValue=n,a.$commitViewValue(),a.$render()}var $,m,p,v,g=angular.isDefined,N=angular.isUndefined,V=angular.isNumber;a.$parsers.push(function(e){if(a.$isEmpty(e))return v=e,null;if(0===e.indexOf(".")&&(e="0"+e),0===e.indexOf("-")){if($>=0)return d(""),null;if("-"===e||"-."===e)return null}return n.test(e)?(v=e,parseFloat(e)):(d(v),v)}),a.$validators.min=function(n){return a.$isEmpty(n)||N($)||n>=$},g(u.min)||u.ngMin?u.$observe("min",function(n){g(n)&&!V(n)&&(n=parseFloat(n,10)),$=V(n)&&!isNaN(n)?n:void 0,a.$validate()}):$=0,(g(u.max)||u.ngMax)&&(a.$validators.max=function(n){return a.$isEmpty(n)||N(m)||m>=n},u.$observe("max",function(n){g(n)&&!V(n)&&(n=parseFloat(n,10)),m=V(n)&&!isNaN(n)?n:void 0,a.$validate()})),g(u.precision)?u.$observe("precision",function(n){p=parseInt(n,10),c()}):p=e,a.$parsers.push(function(n){return n&&(n=l()?o(n):n),n}),a.$formatters.push(function(n){return g(n)?l()&&f(n)?s(n):n:""}),t.bind("blur",function(){a.$commitViewValue(),c()})}return{restrict:"A",require:"ngModel",link:r}}])}();
2 | //# sourceMappingURL=angular-money-directive.min.js.map
3 |
--------------------------------------------------------------------------------
/dist/angular-money-directive.min.js.map:
--------------------------------------------------------------------------------
1 | {"version":3,"sources":["angular-money-directive.min.js"],"names":["NUMBER_REGEXP","DEFAULT_PRECISION","angular","module","directive","$parse","link","scope","el","attrs","ngModelCtrl","round","num","d","Math","pow","precision","formatPrecision","value","parseFloat","toFixed","isPrecisionValid","isNaN","isValueValid","isNumber","updateValuePrecision","modelValue","$modelValue","ngModel","assign","changeViewValue","lastValidViewValue","$viewValue","$commitViewValue","$render","minVal","maxVal","isDefined","isUndefined","$parsers","push","$isEmpty","indexOf","test","$validators","min","ngMin","$observe","val","undefined","$validate","max","ngMax","parseInt","$formatters","bind","restrict","require"],"mappings":"CAAA,WACA,YAOA,IAAIA,GAAgB,qCAChBC,EAAoB,CAExBC,SAAQC,OAAO,oBAEdC,UAAU,SAAU,SAAU,SAAUC,GACvC,QAASC,GAAKC,EAAOC,EAAIC,EAAOC,GAW9B,QAASC,GAAMC,GACb,GAAIC,GAAIC,KAAKC,IAAI,GAAIC,EACrB,OAAOF,MAAKH,MAAMC,EAAMC,GAAKA,EAQ/B,QAASI,GAAgBC,GACvB,MAAOC,YAAWD,GAAOE,QAAQJ,GAGnC,QAASK,KACP,OAAQC,MAAMN,IAAcA,EAAY,GAG1C,QAASO,GAAaL,GACpB,MAAOhB,SAAQsB,SAASN,KAAWI,MAAMJ,GAG3C,QAASO,KACP,GAAIC,GAAahB,EAAYiB,WAEzBJ,GAAaG,IAAeL,MAC9BX,EAAYiB,YAAchB,EAAMe,GAChCrB,EAAOI,EAAMmB,SAASC,OAAOtB,EAAOG,EAAYiB,aAChDG,EAAgBb,EAAgBS,IAGhCK,EAAqBrB,EAAYsB,YAIrC,QAASF,GAAgBZ,GACvBR,EAAYsB,WAAad,EACzBR,EAAYuB,mBACZvB,EAAYwB,UAhDd,GAAIC,GAAQC,EAAQpB,EAAWe,EAC3BM,EAAYnC,QAAQmC,UACpBC,EAAcpC,QAAQoC,YACtBd,EAAWtB,QAAQsB,QAiDvBd,GAAY6B,SAASC,KAAK,SAAUtB,GAClC,GAAIR,EAAY+B,SAASvB,GAEvB,MADAa,GAAqBb,EACd,IAST,IAL2B,IAAvBA,EAAMwB,QAAQ,OAChBxB,EAAQ,IAAMA,GAIW,IAAvBA,EAAMwB,QAAQ,KAAY,CAC5B,GAAIP,GAAU,EAEZ,MADAL,GAAgB,IACT,IACF,IAAc,MAAVZ,GAA2B,OAAVA,EAC1B,MAAO,MAIX,MAAIlB,GAAc2C,KAAKzB,IAErBa,EAAqBb,EACdC,WAAWD,KAGlBY,EAAgBC,GACTA,KAMXrB,EAAYkC,YAAYC,IAAM,SAAU3B,GACtC,MAAOR,GAAY+B,SAASvB,IAAUoB,EAAYH,IAAWjB,GAASiB,GAEpEE,EAAU5B,EAAMoC,MAAQpC,EAAMqC,MAChCrC,EAAMsC,SAAS,MAAO,SAASC,GACzBX,EAAUW,KAASxB,EAASwB,KAC9BA,EAAM7B,WAAW6B,EAAK,KAExBb,EAASX,EAASwB,KAAS1B,MAAM0B,GAAOA,EAAMC,OAC9CvC,EAAYwC,cAGdf,EAAS,GAKPE,EAAU5B,EAAM0C,MAAQ1C,EAAM2C,SAChC1C,EAAYkC,YAAYO,IAAM,SAASjC,GACrC,MAAOR,GAAY+B,SAASvB,IAAUoB,EAAYF,IAAoBA,GAATlB,GAG/DT,EAAMsC,SAAS,MAAO,SAASC,GACzBX,EAAUW,KAASxB,EAASwB,KAC9BA,EAAM7B,WAAW6B,EAAK,KAExBZ,EAASZ,EAASwB,KAAS1B,MAAM0B,GAAOA,EAAMC,OAC9CvC,EAAYwC,eAKZb,EAAU5B,EAAMO,WAClBP,EAAMsC,SAAS,YAAa,SAAU7B,GACpCF,EAAYqC,SAASnC,EAAO,IAE5BO,MAGFT,EAAYf,EAGdS,EAAY6B,SAASC,KAAK,SAAUtB,GAKlC,MAJIA,KAEFA,EAAQG,IAAqBV,EAAMO,GAASA,GAEvCA,IAGTR,EAAY4C,YAAYd,KAAK,SAAUtB,GACrC,MAAImB,GAAUnB,GACLG,KAAsBE,EAAaL,GACxCD,EAAgBC,GAASA,EAEpB,KAKXV,EAAG+C,KAAK,OAAQ,WACd7C,EAAYuB,mBACZR,MAIJ,OACE+B,SAAU,IACVC,QAAS,UACTnD,KAAMA","file":"angular-money-directive.min.js","sourcesContent":["(function () {\n'use strict';\n\n/**\n * Heavily adapted from the `type=\"number\"` directive in Angular's\n * /src/ng/directive/input.js\n */\n\nvar NUMBER_REGEXP = /^\\s*(\\-|\\+)?(\\d+|(\\d*(\\.\\d*)))\\s*$/;\nvar DEFAULT_PRECISION = 2;\n\nangular.module('fiestah.money', [])\n\n.directive('money', [\"$parse\", function ($parse) {\n function link(scope, el, attrs, ngModelCtrl) {\n var minVal, maxVal, precision, lastValidViewValue;\n var isDefined = angular.isDefined;\n var isUndefined = angular.isUndefined;\n var isNumber = angular.isNumber;\n\n /**\n * Returns a rounded number in the precision setup by the directive\n * @param {Number} num Number to be rounded\n * @return {Number} Rounded number\n */\n function round(num) {\n var d = Math.pow(10, precision);\n return Math.round(num * d) / d;\n }\n\n /**\n * Returns a string that represents the rounded number\n * @param {Number} value Number to be rounded\n * @return {String} The string representation\n */\n function formatPrecision(value) {\n return parseFloat(value).toFixed(precision);\n }\n\n function isPrecisionValid() {\n return !isNaN(precision) && precision > -1;\n }\n\n function isValueValid(value) {\n return angular.isNumber(value) && !isNaN(value);\n }\n\n function updateValuePrecision() {\n var modelValue = ngModelCtrl.$modelValue;\n\n if (isValueValid(modelValue) && isPrecisionValid()) {\n ngModelCtrl.$modelValue = round(modelValue);\n $parse(attrs.ngModel).assign(scope, ngModelCtrl.$modelValue);\n changeViewValue(formatPrecision(modelValue));\n\n // Save the rounded view value\n lastValidViewValue = ngModelCtrl.$viewValue;\n }\n }\n\n function changeViewValue(value) {\n ngModelCtrl.$viewValue = value;\n ngModelCtrl.$commitViewValue();\n ngModelCtrl.$render();\n }\n\n\n ngModelCtrl.$parsers.push(function (value) {\n if (ngModelCtrl.$isEmpty(value)) {\n lastValidViewValue = value;\n return null;\n }\n\n // Handle leading decimal point, like \".5\"\n if (value.indexOf('.') === 0) {\n value = '0' + value;\n }\n\n // Allow \"-\" inputs only when min < 0\n if (value.indexOf('-') === 0) {\n if (minVal >= 0) {\n changeViewValue('');\n return null;\n } else if (value === '-' || value === '-.') {\n return null;\n }\n }\n\n if (NUMBER_REGEXP.test(value)) {\n // Save as valid view value if it's a number\n lastValidViewValue = value;\n return parseFloat(value);\n } else {\n // Render the last valid input in the field\n changeViewValue(lastValidViewValue);\n return lastValidViewValue;\n }\n });\n\n\n // Min validation\n ngModelCtrl.$validators.min = function (value) {\n return ngModelCtrl.$isEmpty(value) || isUndefined(minVal) || value >= minVal;\n };\n if (isDefined(attrs.min) || attrs.ngMin) {\n attrs.$observe('min', function(val) {\n if (isDefined(val) && !isNumber(val)) {\n val = parseFloat(val, 10);\n }\n minVal = isNumber(val) && !isNaN(val) ? val : undefined;\n ngModelCtrl.$validate();\n });\n } else {\n minVal = 0;\n }\n\n\n // Max validation\n if (isDefined(attrs.max) || attrs.ngMax) {\n ngModelCtrl.$validators.max = function(value) {\n return ngModelCtrl.$isEmpty(value) || isUndefined(maxVal) || value <= maxVal;\n };\n\n attrs.$observe('max', function(val) {\n if (isDefined(val) && !isNumber(val)) {\n val = parseFloat(val, 10);\n }\n maxVal = isNumber(val) && !isNaN(val) ? val : undefined;\n ngModelCtrl.$validate();\n });\n }\n\n // Round off (disabled by \"-1\")\n if (isDefined(attrs.precision)) {\n attrs.$observe('precision', function (value) {\n precision = parseInt(value, 10);\n\n updateValuePrecision();\n });\n } else {\n precision = DEFAULT_PRECISION;\n }\n\n ngModelCtrl.$parsers.push(function (value) {\n if (value) {\n // Round off value to specified precision\n value = isPrecisionValid() ? round(value) : value;\n }\n return value;\n });\n\n ngModelCtrl.$formatters.push(function (value) {\n if (isDefined(value)) {\n return isPrecisionValid() && isValueValid(value) ?\n formatPrecision(value) : value;\n } else {\n return '';\n }\n });\n\n // Auto-format precision on blur\n el.bind('blur', function () {\n ngModelCtrl.$commitViewValue();\n updateValuePrecision();\n });\n }\n\n return {\n restrict: 'A',\n require: 'ngModel',\n link: link\n };\n}]);\n\n})();\n"],"sourceRoot":"/source/"}
--------------------------------------------------------------------------------
/gulpfile.js:
--------------------------------------------------------------------------------
1 | var gulp = require('gulp');
2 | var uglify = require('gulp-uglify');
3 | var ngAnnotate = require('gulp-ng-annotate');
4 | var sourcemaps = require('gulp-sourcemaps');
5 | var rename = require('gulp-rename');
6 | var del = require('del');
7 | var path = require('path');
8 |
9 | var SRC = 'src/angular-money-directive.js';
10 | var DEST = 'dist/';
11 |
12 | gulp.task('dist', function () {
13 | return gulp.src(SRC)
14 | .pipe(ngAnnotate())
15 |
16 | // dist/angular-money-directive.js
17 | .pipe(gulp.dest(DEST))
18 |
19 | // .min.js + .min.js.map
20 | .pipe(rename({ extname: '.min.js' }))
21 | .pipe(sourcemaps.init())
22 | .pipe(uglify())
23 | .pipe(sourcemaps.write('./'))
24 | .pipe(gulp.dest(DEST));
25 | });
26 |
27 | gulp.task('clean', function () {
28 | return del.sync(DEST);
29 | });
30 |
31 | gulp.task('build', ['clean', 'dist']);
32 |
--------------------------------------------------------------------------------
/karma.conf.js:
--------------------------------------------------------------------------------
1 | module.exports = function (config) {
2 | config.set({
3 | basePath: '',
4 | frameworks: ['mocha', 'chai'],
5 | files: [
6 | 'node_modules/angular/angular.js',
7 | 'node_modules/angular-mocks/angular-mocks.js',
8 | 'node_modules/jquery/dist/jquery.js',
9 | 'node_modules/chai-jquery/chai-jquery.js',
10 | 'src/angular-money-directive.js',
11 | 'test/angular-money-directive.spec.js'
12 | ],
13 | reporters: ['progress'],
14 | port: 9876,
15 | colors: true,
16 | logLevel: config.LOG_INFO,
17 | autoWatch: true,
18 | browsers: ['PhantomJS'],
19 | singleRun: false
20 | });
21 | };
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "angular-money-directive",
3 | "version": "1.2.5",
4 | "description": "AngularJS directive to validate money inputs",
5 | "main": "dist/angular-money-directive.js",
6 | "dependencies": {},
7 | "devDependencies": {
8 | "angular": "^1.4.7",
9 | "angular-mocks": "^1.4.7",
10 | "chai": "~1.9.1",
11 | "chai-jquery": "^2.0.0",
12 | "del": "^2.0.2",
13 | "gulp": "^3.9.0",
14 | "gulp-ng-annotate": "^1.1.0",
15 | "gulp-rename": "^1.2.2",
16 | "gulp-sourcemaps": "^1.6.0",
17 | "gulp-uglify": "^1.4.1",
18 | "jquery": "^2.1.4",
19 | "karma": "~1.3.0",
20 | "karma-chai": "~0.1.0",
21 | "karma-mocha": "~1.3.0",
22 | "karma-phantomjs-launcher": "~1.0.2",
23 | "mocha": "~3.2.0"
24 | },
25 | "scripts": {
26 | "test": "./node_modules/karma/bin/karma start --single-run"
27 | },
28 | "repository": {
29 | "type": "git",
30 | "url": "git://github.com/fiestah/angular-money-directive.git"
31 | },
32 | "author": "Marvin Tam ",
33 | "license": "MIT",
34 | "bugs": {
35 | "url": "https://github.com/fiestah/angular-money-directive/issues"
36 | },
37 | "homepage": "https://github.com/fiestah/angular-money-directive"
38 | }
39 |
--------------------------------------------------------------------------------
/src/angular-money-directive.js:
--------------------------------------------------------------------------------
1 | (function () {
2 | 'use strict';
3 |
4 | /**
5 | * Heavily adapted from the `type="number"` directive in Angular's
6 | * /src/ng/directive/input.js
7 | */
8 |
9 | var NUMBER_REGEXP = /^\s*(\-|\+)?(\d+|(\d*(\.\d*)))\s*$/;
10 | var DEFAULT_PRECISION = 2;
11 |
12 | angular.module('fiestah.money', [])
13 |
14 | .directive('money', function ($parse) {
15 | function link(scope, el, attrs, ngModelCtrl) {
16 | var minVal, maxVal, precision, lastValidViewValue;
17 | var isDefined = angular.isDefined;
18 | var isUndefined = angular.isUndefined;
19 | var isNumber = angular.isNumber;
20 |
21 | /**
22 | * Returns a rounded number in the precision setup by the directive
23 | * @param {Number} num Number to be rounded
24 | * @return {Number} Rounded number
25 | */
26 | function round(num) {
27 | var d = Math.pow(10, precision);
28 | return Math.round(num * d) / d;
29 | }
30 |
31 | /**
32 | * Returns a string that represents the rounded number
33 | * @param {Number} value Number to be rounded
34 | * @return {String} The string representation
35 | */
36 | function formatPrecision(value) {
37 | return parseFloat(value).toFixed(precision);
38 | }
39 |
40 | function isPrecisionValid() {
41 | return !isNaN(precision) && precision > -1;
42 | }
43 |
44 | function isValueValid(value) {
45 | return angular.isNumber(value) && !isNaN(value);
46 | }
47 |
48 | function updateValuePrecision() {
49 | var modelValue = ngModelCtrl.$modelValue;
50 |
51 | if (isValueValid(modelValue) && isPrecisionValid()) {
52 | ngModelCtrl.$modelValue = round(modelValue);
53 | $parse(attrs.ngModel).assign(scope, ngModelCtrl.$modelValue);
54 | changeViewValue(formatPrecision(modelValue));
55 |
56 | // Save the rounded view value
57 | lastValidViewValue = ngModelCtrl.$viewValue;
58 | }
59 | }
60 |
61 | function changeViewValue(value) {
62 | ngModelCtrl.$viewValue = value;
63 | ngModelCtrl.$commitViewValue();
64 | ngModelCtrl.$render();
65 | }
66 |
67 |
68 | ngModelCtrl.$parsers.push(function (value) {
69 | if (ngModelCtrl.$isEmpty(value)) {
70 | lastValidViewValue = value;
71 | return null;
72 | }
73 |
74 | // Handle leading decimal point, like ".5"
75 | if (value.indexOf('.') === 0) {
76 | value = '0' + value;
77 | }
78 |
79 | // Allow "-" inputs only when min < 0
80 | if (value.indexOf('-') === 0) {
81 | if (minVal >= 0) {
82 | changeViewValue('');
83 | return null;
84 | } else if (value === '-' || value === '-.') {
85 | return null;
86 | }
87 | }
88 |
89 | if (NUMBER_REGEXP.test(value)) {
90 | // Save as valid view value if it's a number
91 | lastValidViewValue = value;
92 | return parseFloat(value);
93 | } else {
94 | // Render the last valid input in the field
95 | changeViewValue(lastValidViewValue);
96 | return lastValidViewValue;
97 | }
98 | });
99 |
100 |
101 | // Min validation
102 | ngModelCtrl.$validators.min = function (value) {
103 | return ngModelCtrl.$isEmpty(value) || isUndefined(minVal) || value >= minVal;
104 | };
105 | if (isDefined(attrs.min) || attrs.ngMin) {
106 | attrs.$observe('min', function(val) {
107 | if (isDefined(val) && !isNumber(val)) {
108 | val = parseFloat(val, 10);
109 | }
110 | minVal = isNumber(val) && !isNaN(val) ? val : undefined;
111 | ngModelCtrl.$validate();
112 | });
113 | } else {
114 | minVal = 0;
115 | }
116 |
117 |
118 | // Max validation
119 | if (isDefined(attrs.max) || attrs.ngMax) {
120 | ngModelCtrl.$validators.max = function(value) {
121 | return ngModelCtrl.$isEmpty(value) || isUndefined(maxVal) || value <= maxVal;
122 | };
123 |
124 | attrs.$observe('max', function(val) {
125 | if (isDefined(val) && !isNumber(val)) {
126 | val = parseFloat(val, 10);
127 | }
128 | maxVal = isNumber(val) && !isNaN(val) ? val : undefined;
129 | ngModelCtrl.$validate();
130 | });
131 | }
132 |
133 | // Round off (disabled by "-1")
134 | if (isDefined(attrs.precision)) {
135 | attrs.$observe('precision', function (value) {
136 | precision = parseInt(value, 10);
137 |
138 | updateValuePrecision();
139 | });
140 | } else {
141 | precision = DEFAULT_PRECISION;
142 | }
143 |
144 | ngModelCtrl.$parsers.push(function (value) {
145 | if (value) {
146 | // Round off value to specified precision
147 | value = isPrecisionValid() ? round(value) : value;
148 | }
149 | return value;
150 | });
151 |
152 | ngModelCtrl.$formatters.push(function (value) {
153 | if (isDefined(value)) {
154 | return isPrecisionValid() && isValueValid(value) ?
155 | formatPrecision(value) : value;
156 | } else {
157 | return '';
158 | }
159 | });
160 |
161 | // Auto-format precision on blur
162 | el.bind('blur', function () {
163 | ngModelCtrl.$commitViewValue();
164 | updateValuePrecision();
165 | });
166 | }
167 |
168 | return {
169 | restrict: 'A',
170 | require: 'ngModel',
171 | link: link
172 | };
173 | });
174 |
175 | })();
176 |
--------------------------------------------------------------------------------
/test/angular-money-directive.spec.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | describe('angular-money-directive', function () {
4 | var $compile, scope, inputEl, form;
5 |
6 | function setValue(val) {
7 | inputEl.val(val);
8 | inputEl.triggerHandler('input');
9 | scope.$digest();
10 | }
11 |
12 | function setupDirective(attrs) {
13 | attrs = attrs || '';
14 |
15 | var formEl = angular.element(
16 | '');
19 | $compile(formEl)(scope);
20 |
21 | scope.$digest();
22 |
23 | form = scope.form;
24 | inputEl = formEl.find('input');
25 | }
26 |
27 |
28 | beforeEach(module('fiestah.money'));
29 | beforeEach(inject(function (_$compile_, $rootScope) {
30 | $compile = _$compile_;
31 | scope = $rootScope.$new();
32 | scope.model = {};
33 | }));
34 |
35 | describe('when ngModel is undefined', function () {
36 | beforeEach(function () {
37 | setupDirective();
38 | setValue(undefined);
39 | });
40 |
41 | it('displays an empty string', function () {
42 | expect(inputEl.val()).to.equal('');
43 | expect(form.price.$valid).to.be.true;
44 | });
45 | });
46 |
47 | describe('when ngModel is set to a valid value', function () {
48 | beforeEach(function () {
49 | scope.model.price = 0.01;
50 | setupDirective();
51 | });
52 |
53 | it('displays the formatted value', function () {
54 | expect(inputEl.val()).to.equal('0.01');
55 | expect(form.price.$valid).to.be.true;
56 | });
57 | });
58 |
59 | describe('when ngModel is set to a valid zero (0) value', function () {
60 | beforeEach(function () {
61 | scope.model.price = 0;
62 | setupDirective();
63 | });
64 |
65 | it('displays the formatted value', function () {
66 | expect(inputEl.val()).to.equal('0.00');
67 | expect(form.price.$valid).to.be.true;
68 | });
69 | });
70 |
71 | describe('when ngModel is set to an invalid value', function () {
72 | beforeEach(function () {
73 | scope.model.price = 'abc';
74 | setupDirective();
75 | });
76 |
77 | it('displays the formatted value', function () {
78 | expect(inputEl.val()).to.equal('abc');
79 | expect(form.price.$valid).to.be.false;
80 | });
81 | });
82 |
83 | describe('when ngModel is set to null', function () {
84 | beforeEach(function () {
85 | scope.model.price = null;
86 | setupDirective();
87 | });
88 | it('displays the an empty string', function () {
89 | expect(inputEl.val()).to.equal('');
90 | expect(form.price.$valid).to.be.true;
91 | });
92 | describe('on blur', function () {
93 | beforeEach(function () {
94 | inputEl.triggerHandler('blur');
95 | });
96 | it('displays an empty string', function () {
97 | expect(inputEl.val()).to.equal('');
98 | });
99 | });
100 | });
101 |
102 |
103 | describe('defaults with no optional attributes set', function () {
104 | beforeEach(function () {
105 | setupDirective();
106 | });
107 | it('does not become $dirty', function () {
108 | expect(form.price.$dirty).to.be.false;
109 | });
110 | it('displays an empty string in the view', function () {
111 | expect(inputEl.val()).to.equal('');
112 | })
113 | it('accepts in-range values', function () {
114 | setValue('50.4');
115 | expect(scope.model.price).to.equal(50.4);
116 | expect(form.price.$valid).to.be.true;
117 | });
118 | it('accepts decimals without a leading zero', function () {
119 | setValue('.5');
120 | expect(scope.model.price).to.equal(0.5);
121 | expect(form.price.$valid).to.be.true;
122 | });
123 | it('rounds off to two decimal points', function () {
124 | setValue('41.999');
125 | expect(scope.model.price).to.equal(42);
126 | });
127 | it('disallows negative values', function () {
128 | setValue('-5');
129 | expect(scope.model.price).to.not.be.ok;
130 | expect(inputEl.val()).to.equal('');
131 | expect(form.price.$valid).to.be.true;
132 | });
133 | it('strips out invalid chars', function () {
134 | setValue('a');
135 | expect(scope.model.price).to.not.be.ok;
136 | expect(inputEl.val()).to.equal('');
137 | });
138 | it('reverts to the last valid value on invalid char', function () {
139 | // A valid value is first entered
140 | setValue('50.4');
141 |
142 | // Then "a" is entered next
143 | setValue('50.4a');
144 |
145 | expect(scope.model.price).to.equal(50.4);
146 | expect(inputEl.val()).to.equal('50.4');
147 | expect(form.price.$valid).to.be.true;
148 | });
149 | });
150 |
151 | describe('on blur', function () {
152 | beforeEach(function () {
153 | setupDirective();
154 | setValue('12.345');
155 | inputEl.triggerHandler('blur');
156 | })
157 | it('formats decimals', function () {
158 | expect(inputEl.val()).to.equal('12.35');
159 | });
160 |
161 | describe('on invalid input', function () {
162 | it('reverts to the last rounded number', function () {
163 | setValue('12.345x');
164 | expect(inputEl.val()).to.equal('12.35');
165 | });
166 | });
167 | });
168 |
169 | describe('when ng-model-options="{updateOn: \'blur\'}"', function () {
170 | beforeEach(function () {
171 | scope.model.price = 20;
172 | setupDirective('ng-model-options="{updateOn: \'blur\'}"');
173 | inputEl.triggerHandler('blur');
174 | });
175 | it('formats decimals', function () {
176 | expect(inputEl.val()).to.equal('20.00');
177 | });
178 | describe('after changing value and blurring out', function () {
179 | beforeEach(function () {
180 | setValue('10');
181 | inputEl.triggerHandler('blur');
182 | });
183 | it('updates the value', function () {
184 | expect(inputEl.val()).to.equal('10.00');
185 | });
186 | });
187 | });
188 |
189 |
190 | describe('min', function () {
191 | describe('when min < 0', function () {
192 | beforeEach(function () {
193 | setupDirective('min="-10"');
194 | });
195 | it('allows the negative sign', function () {
196 | setValue('-');
197 | expect(scope.model.price).to.not.be.ok;
198 | expect(form.price.$valid).to.be.true;
199 | });
200 | it('allows negative values', function () {
201 | setValue('-5.4');
202 | expect(scope.model.price).to.equal(-5.4);
203 | expect(form.price.$valid).to.be.true;
204 | });
205 | it('allows negative fraction values', function () {
206 | setValue('-');
207 | expect(scope.model.price).to.equal(null);
208 | expect(form.price.$valid).to.be.true;
209 |
210 | setValue('-.');
211 | expect(scope.model.price).to.equal(null);
212 | expect(form.price.$valid).to.be.true;
213 |
214 | setValue('-.5');
215 | expect(scope.model.price).to.equal(-0.5);
216 | expect(form.price.$valid).to.be.true;
217 | });
218 | });
219 |
220 | describe('when min="{{min}}"', function () {
221 | beforeEach(function () {
222 | setupDirective('min="{{min}}"');
223 | });
224 | it('defaults min to 0', function () {
225 | setValue('1');
226 | expect(form.price.$valid).to.be.true;
227 | });
228 | it('reflects changes to min', function () {
229 | // Initial min value
230 | scope.min = 2;
231 | setValue('1');
232 | expect(form.price.$invalid).to.be.true;
233 |
234 | // Modified max value
235 | scope.min = 0;
236 | scope.$digest();
237 | expect(form.price.$valid).to.be.true;
238 | });
239 | });
240 | });
241 |
242 | describe('max', function () {
243 | describe('when value > max', function () {
244 | it('marks it as invalid', function () {
245 | setupDirective('max="100"');
246 | setValue('100.5');
247 | expect(form.price.$invalid).to.be.true;
248 | });
249 | });
250 |
251 | describe('when max="{{max}}"', function () {
252 | beforeEach(function () {
253 | setupDirective('max="{{max}}"');
254 | });
255 | it('defaults max to Infinity', function () {
256 | setValue('1000000000');
257 | expect(form.price.$valid).to.be.true;
258 | });
259 | it('reflects changes to max', function () {
260 | // Initial max value
261 | scope.max = 1;
262 | setValue('2');
263 | expect(form.price.$invalid).to.be.true;
264 |
265 | // Modified max value
266 | scope.max = 3;
267 | scope.$digest();
268 | expect(form.price.$valid).to.be.true;
269 | });
270 | });
271 | });
272 |
273 | describe('precision', function () {
274 | describe('when precision = 0', function () {
275 | it('should round to int', function () {
276 | setupDirective('precision="0"');
277 | setValue('42.01');
278 | expect(scope.model.price).to.equal(42);
279 | });
280 | });
281 |
282 | describe('when precision = -1', function () {
283 | it('should disable rounding', function () {
284 | setupDirective('precision="-1"');
285 | setValue('41.999');
286 | expect(scope.model.price).to.equal(41.999);
287 | });
288 | });
289 |
290 | describe('when precision="{{precision}}"', function () {
291 | beforeEach(function () {
292 | setupDirective('precision="{{precision}}"');
293 | });
294 |
295 | it('does not become $dirty', function () {
296 | expect(form.price.$dirty).to.be.false;
297 | });
298 |
299 | describe('when precision is undefined', function () {
300 | it('does not round', function () {
301 | setValue('2.555');
302 | expect(scope.model.price).to.equal(2.555);
303 | });
304 | });
305 |
306 | describe('when precision is invalid', function () {
307 | it('does not round', function () {
308 | scope.precision = 'a';
309 | scope.$digest();
310 | setValue('2.555');
311 | expect(scope.model.price).to.equal(2.555);
312 | });
313 | });
314 |
315 | describe('when entering an invalid char beyond the precision', function () {
316 | beforeEach(function () {
317 | scope.precision = 2;
318 | scope.$digest();
319 | setValue('2.5555');
320 | setValue('2.5555d');
321 | });
322 | it('should revert to the value right before entering the invalid char', function () {
323 | expect(inputEl.val()).to.equal('2.5555');
324 | })
325 | });
326 |
327 | it('reflects changes to precision', function () {
328 | // Initial precision
329 | scope.precision = 3;
330 | scope.$digest();
331 | setValue('2.55555');
332 | expect(scope.model.price).to.equal(2.556);
333 |
334 | // Decrease precision
335 | scope.precision = 1;
336 | scope.$digest();
337 | expect(scope.model.price).to.equal(2.6);
338 | expect($(inputEl)).to.have.value('2.6');
339 |
340 | // Revert precision (this loses the original fractions)
341 | scope.precision = 3;
342 | scope.$digest();
343 | expect($(inputEl)).to.have.value('2.600');
344 |
345 | // Disable rounding
346 | scope.precision = -1;
347 | scope.$digest();
348 |
349 | setValue('3.33333');
350 | inputEl[0].blur();
351 | expect($(inputEl)).to.have.value('3.33333');
352 | });
353 | });
354 | });
355 | });
356 |
--------------------------------------------------------------------------------