├── .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 | [![Build Status](http://img.shields.io/travis/fiestah/angular-money-directive.svg)](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 | '
' + 17 | ' ' + 18 | '
'); 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 | --------------------------------------------------------------------------------