angular.module('exampleModule', ['aa.formExtensions'])
    .controller('exampleController', function($scope) {

        $scope.save = function() {
            //this WON'T get called unless the form is valid
            //aa-save-form only invokes the function if the
            //containing form is valid
            alert('person saved!');
        };
    });
<!DOCTYPE html>
<html ng-app="exampleModule">
<head>
    <link rel="stylesheet" href="style.css" />
    <script src="http://code.angularjs.org/1.2.8/angular.js"></script>
    <script src="aa.formExtensions.js"></script>
    <script src="app.js"></script>
</head>
<body>
<div ng-controller="exampleController" ng-form="exampleForm">

    <div>
        <input type="text" required="" ng-minlength="2" ng-maxlength="30"
               aa-auto-field="firstName" />
    </div>
    
    <button aa-save-form="save()">Save</button>
</div>

</body>
</html>
/*some optional CSS that will make your aa.formExtensions experience a little prettier*/
input.ng-dirty.ng-valid, select.ng-dirty.ng-valid {
    border:1px solid Green;
}
input.ng-dirty.ng-invalid, select.ng-dirty.ng-invalid,
input.ng-invalid.aa-invalid-attempt, select.ng-invalid.aa-invalid-attempt,
input.ng-invalid.aa-had-focus, select.ng-invalid.aa-had-focus {
    border:1px solid Red;
}

div.validation-icons {
    margin-top: 5px;
    margin-left: -40px;
}

/*requires font awesome*/
div.validation-icons i.fa-check  {
    color: green;
}

/*requires font awesome*/
div.validation-icons i.fa-exclamation-circle {
    color: red;
}

div.validation-error {
    color: red;
    font-weight: bold;
    width: 300px;
    text-align: left;
}
/*
 * AngularAgility Form Extensions
 *
 * http://www.johnculviner.com
 *
 * Copyright (c) 2014 - John Culviner
 *
 * Licensed under the MIT license:
 *   http://www.opensource.org/licenses/mit-license.php
 */

(function () {
    var formExtensions = angular.module('aa.formExtensions', [])

        .directive('aaSaveForm', ['aaFormExtensions', function (aaFormExtensions) {
            return {
                scope: {
                    onInvalidAttempt: '='
                },
                restrict: 'A',
                require: '^form',
                link: function (scope, element, attrs, ngForm) {

                    ensureaaFormExtensions(ngForm);

                    element.on('click', function () {
                        scope.$apply(function () {
                            if (ngForm.$valid) {
                                ngForm.$aaFormExtensions.$invalidAttempt = false;
                                scope.$eval(attrs.aaSaveForm);
                            } else {
                                ngForm.$aaFormExtensions.$invalidAttempt = true;

                                var hasScopeFunction = typeof scope.onInvalidAttempt === 'function';
                                var hasGlobalFunction = typeof aaFormExtensions.defaultOnInvalidAttempt === 'function'

                                if(hasScopeFunction || hasGlobalFunction) {
                                    //calc error messages

                                    var errorMessages = [];

                                    angular.forEach(ngForm.$aaFormExtensions, function(fieldObj, fieldName) {

                                        if(fieldName.indexOf('$') === 0){ return; }

                                        errorMessages = errorMessages.concat(fieldObj.$errorMessages);
                                    });

                                    if(hasScopeFunction) {
                                        scope.onInvalidAttempt(errorMessages, ngForm);
                                        return;
                                    }

                                    aaFormExtensions.defaultOnInvalidAttempt(errorMessages, ngForm);
                                }
                            }
                        });
                    });
                }
            };
        }])

        //constructs myForm.$aaFormExtensions.myFieldName object
        //including validation messages for all ngModels at form.$aaFormExtensions.
        //messages can be used there manually or emitted automatically with aaValMsg
        .directive('ngModel', ['aaFormExtensions', '$document', '$timeout', function (aaFormExtensions, $document, $timeout) {
            return {
                require: ['ngModel', '^form'],
                priority: 1,
                link: function (scope, element, attrs, controllers) {
                    var ngModel = controllers[0],
                        ngForm = controllers[1],
                        fieldName = "This field";

                    if(attrs.aaLabel) {
                        //use default label
                        fieldName = attrs.aaLabel;

                    } else if(element[0].id){
                        //is there a label for this field?
                        angular.forEach($document.find('label'), function(label) {
                            if(label.getAttribute("for") === element[0].id) {
                                fieldName = (label.innerHTML || "").replace('*', '').trim();
                            }
                        });
                    }

                    ensureaaFormExtensionsFieldExists(ngForm, ngModel.$name);
                    ngForm.$aaFormExtensions[ngModel.$name].$element = element;
                    element.on('blur', function() {

                        scope.$apply(function() {
                            ngForm.$aaFormExtensions[ngModel.$name].$hadFocus = true;
                            element.addClass('aa-had-focus');
                        });
                    });


                    scope.$watch(function() {
                        return ngForm.$aaFormExtensions.$invalidAttempt;
                    }, function(val) {
                        if(val) {
                            element.addClass('aa-invalid-attempt');
                        }
                    });

                    //need this to run AFTER Angular's 'ngModel' runs... another way?
                    $timeout(calcErrorMessages, 0);

                    //subsequent runs after value changes in GUI...
                    ngModel.$parsers.push(calcErrorMessages);

                    function calcErrorMessages(val) {
                        var errorMessages = ngForm.$aaFormExtensions[ngModel.$name].$errorMessages;
                        errorMessages.length = 0;

                        for (var key in ngModel.$error) {
                            if(ngModel.$error[key]) {

                                if(key === 'minlength') {
                                    errorMessages.push(
                                        stringFormat(aaFormExtensions.validationMessages[key], fieldName, attrs.ngMinlength)
                                    )
                                } else if (key === 'maxlength') {
                                    errorMessages.push(
                                        stringFormat(aaFormExtensions.validationMessages[key], fieldName, attrs.ngMaxlength)
                                    )
                                } else if (key === 'required' && element[0].type === 'number') {
                                    //angular doesn't correctly flag numbers as invalid rather as required when something wrong is filled in
                                    //hack around it
                                    errorMessages.push(
                                        stringFormat(aaFormExtensions.validationMessages['number'], fieldName)
                                    )
                                } else {
                                    errorMessages.push(
                                        stringFormat(aaFormExtensions.validationMessages[key], fieldName)
                                    )
                                }
                            }
                        }
                        return val;
                    }
                }
            };
        }])

        //place on an element with ngModel to generate validation messages for it
        //will use the default configured validation message placement strategy unless a custom strategy is passed in
        .directive('aaValMsg', ['$compile', 'aaFormExtensions', function($compile, aaFormExtensions) {
            return {
                require: ['ngModel', '^form'],
                link: function(scope, element, attrs, ctrls) {

                    var ngModel = ctrls[0];
                    var form = ctrls[1];

                    var msgElement = aaFormExtensions.valMsgPlacementStrategies[attrs.aaValMsg ||  aaFormExtensions.defaultValMsgPlacementStrategy](
                        element, form.$name, attrs.name
                    )

                    $compile(msgElement)(scope);
                }
            };
        }])

        //if used directly rather than passively with aaValMsg allows for direct placement of validation messages
        //for a given form field. ex. pass "myForm.myFieldName"
        .directive('aaValMsgFor', ['aaFormExtensions', function(aaFormExtensions) {
            //generate the validation message for a particular form field here
            return {
                require: ['^form'],
                priority: 1,
                scope: true,
                link: function($scope, element, attrs) {

                    var fullFieldPath = attrs.aaValMsgFor,
                        fieldInForm = $scope.$eval(fullFieldPath),
                        formObj = $scope.$eval(fullFieldPath.substring(0, fullFieldPath.indexOf('.')));

                    //could nest multiple forms so can't trust directive require and have to eval to handle edge cases...
                    ensureaaFormExtensionsFieldExists(formObj, fieldInForm.$name);
                    var fieldInFormExtensions = $scope.$eval(fullFieldPath.replace('.', '.$aaFormExtensions.'));

                    $scope.$watchCollection(
                        function() {
                            return fieldInFormExtensions.$errorMessages;
                        },
                        function(val) {
                            $scope.errorMessages = val;
                        }
                    );

                    $scope.$watchCollection(
                        function() {
                            return [
                                formObj.$aaFormExtensions.$invalidAttempt,
                                fieldInFormExtensions.$hadFocus
                            ];
                        },
                        function(watches) {
                            var invalidAttempt = watches[0],
                                hadFocus = watches[1];

                            $scope.showMessages = invalidAttempt || hadFocus;
                        }
                    );
                },
                template: aaFormExtensions.valMsgForTemplate,
                replace: true
            };
        }])

        //generate a label for an input generating an ID for it if it doesn't already exist
        .directive('aaLabel', ['aaFormExtensions', function (aaFormExtensions) {
            return {
                link: function (scope, element, attrs) {

                    var strategyName = attrs.aaLabelStrategy || aaFormExtensions.defaultLabelStrategy;
                    var isRequiredField = (attrs.required !== undefined);

                    //auto generate an ID for compliant label names
                    if (!element[0].id) {
                        element[0].id = guid();
                    }

                    aaFormExtensions.labelStrategies[strategyName](element, attrs.aaLabel, isRequiredField);
                }
            };
        }])

        .directive('aaAutoField', ['$compile', function ($compile) {
            return {
                restrict: 'A',
                scope: false,
                replace: true,
                priority: 1000,
                terminal: true,
                compile: function(element, attrs) {

                    //use the passed value for ng-model
                    element.attr("ng-model", attrs.aaAutoField);

                    var lastPartOfName = attrs.aaAutoField.substring(attrs.aaAutoField.lastIndexOf('.') + 1);

                    //if no name set calc one
                    if (!attrs.name) {
                        element.attr("name", lastPartOfName);
                    }

                    //if no label and "no-label" don't calc one
                    if (!attrs.aaLabel && attrs.noLabel === undefined) {
                        element.attr('aa-label', toTitleCase(splitCamelCase(lastPartOfName)))
                    }

                    element.attr("aa-val-msg", "")

                    element.removeAttr('aa-auto-field');

                    element.replaceWith(outerHTML(element[0]));

                    return function(scope, element, attrs) {
                        $compile(element)(scope);
                    }
                }
            };
        }])

        .directive('aaValidIcon', ['aaFormExtensions', function(aaFormExtensions) {
            return {
                require: 'ngModel',
                scope: false,
                compile: function(element) {

                    var container = aaFormExtensions.validIconStrategy.getContainer(element);

                    var validIcon = angular.element(aaFormExtensions.validIconStrategy.validIcon)
                    container.append(validIcon);
                    validIcon[0].style.display = 'none';

                    var invalidIcon = angular.element(aaFormExtensions.validIconStrategy.invalidIcon)
                    container.append(invalidIcon);
                    invalidIcon[0].style.display = 'none';

                    return function(scope, element, attrs, ngModel) {
                        ngModel.$parsers.push(function(val) {

                            if(ngModel.$valid) {
                                validIcon[0].style.display = '';
                                invalidIcon[0].style.display = 'none';
                            } else {
                                validIcon[0].style.display = 'none';
                                invalidIcon[0].style.display = '';
                            }

                            return val;
                        });
                    }
                }
            };
        }])

        .provider('aaFormExtensions', function () {

            var self = this;

            this.defaultLabelStrategy = "default";
            this.setDefaultLabelStrategy = function(strategyName) {
                this.defaultLabelStrategy = strategyName;
            };
            this.labelStrategies = {

                //create a bootstrap3 style label
                bootstrap3InlineForm: function (ele, labelText, isRequired) {

                    var label = angular.element('<label>')
                        .attr('for', ele[0].id)
                        .addClass('col-sm-2 control-label')
                        .html(labelText + (isRequired ? ' *' : ''));


                    var unsupported = [
                        'button',
                        'checkbox',
                        'hidden',
                        'radio',
                        'submit'
                    ];

                    if(unsupported.indexOf(ele[0].type) !== -1) {
                        throw "Generating a label for and input type " + ele[0].type + " is unsupported.";
                    }

                    ele.parent().parent().prepend(label);
                },

                //create a no-frills label directly before the element
                default: function (ele, labelText, isRequired) {
                    ele[0].parentNode.insertBefore(
                        angular.element('<label>')
                            .attr('for', ele[0].id)
                            .html(labelText + (isRequired ? ' *' : ''))[0],
                        ele[0]);
                }

                //add you own here using registerLabelStrategy
            };
            this.registerLabelStrategy = function (name, strategy) {
                this.labelStrategies[name] = strategy;
            };



            this.defaultValMsgPlacementStrategy = "default";
            this.setDefaultValMsgPlacementStrategy = function(strategyName) {
                this.defaultValMsgPlacementStrategy = strategyName;
            };
            this.valMsgPlacementStrategies = {

                default: function(formFieldElement, formName, formFieldName) {

                    var msgElement = angular.element(stringFormat('<div aa-val-msg-for="{0}.{1}"></div>', formName, formFieldName));
                    var fieldType = formFieldElement[0].type.toLowerCase();

                    if(fieldType === 'radio') {
                        //radios tend to be wrapped, go up a few levels (of course you can customize this with your own strategy)
                        formFieldElement.parent().parent().append(msgElement);

                    } else {
                        formFieldElement.after(msgElement);
                    }

                    return msgElement;
                }
            };
            this.registerValMsgPlacementStrategy = function(name, strategy) {
                this.valMsgPlacementStrategies[name] = strategy;
            };


            //bootstrap 3 + font awesome
            this.validIconStrategy = {
                validIcon: '<i class="fa fa-check fa-lg"></i>',
                invalidIcon: '<i class="fa fa-exclamation-circle fa-lg"></i>',
                getContainer: function(element) {
                    var ele = angular.element('<div class="col-xs-1 validation-icons"></span>')
                    element.parent().after(ele)
                    return ele;
                }
            };
            this.setValidIconStrategy = function(strategy) {
                self.validIconStrategy = strategy;
            };


            this.validationMessages = {
                required: "{0} is required.",
                email: "The field {0} must be an email.",
                minlength: "{0} must be at least {1} character(s).",
                maxlength: "{0} must be less than {1} characters.",
                pattern: "{0} is invalid.",
                url: "{0} must be a valid URL.",
                number: "{0} must be number."
            };
            this.setValidationMessage = function(directiveName, message) {
                self.validationMessages[directiveName] = message;
            };
            this.setValidationMessages = function(messages) {
                self.validationMessages = messages;
            };

            this.valMsgForTemplate ='<div class="validation-error" ng-show="showMessages" ng-repeat="msg in errorMessages">{{msg}}</div>';
            this.setValMsgForTemplate = function(valMsgForTemplate) {
                this.valMsgForTemplate = valMsgForTemplate;
            };

            this.defaultOnInvalidAttempt = function() {
                //todo integrate with 'growl like' notifications
            };
            this.setDefaultOnInvalidAttempt = function(func) {
                this.defaultOnInvalidAttempt = func;
            };

            this.$get = function () {
                return {
                    defaultLabelStrategy: self.defaultLabelStrategy,
                    labelStrategies: self.labelStrategies,
                    validIconStrategy: self.validIconStrategy,
                    validationMessages: self.validationMessages,
                    valMsgForTemplate: self.valMsgForTemplate,
                    valMsgPlacementStrategies: self.valMsgPlacementStrategies,
                    defaultValMsgPlacementStrategy: self.defaultValMsgPlacementStrategy,
                    defaultOnInvalidAttempt: self.defaultOnInvalidAttempt
                };
            };
        });

    //utility
    function guid(a) {
        return a ? (a ^ Math.random() * 16 >> a / 4).toString(16) : ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, guid)
    }

    function toTitleCase(str) {
        return str.replace(/\w\S*/g, function (txt) {
            return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();
        });
    }

    function splitCamelCase(str) {
        return str.replace(/([a-z](?=[A-Z]))/g, '$1 ');
    }

    function outerHTML(node){
        // if IE, Chrome take the internal method otherwise build one
        return node.outerHTML || (
            function(n){
                var div = document.createElement('div'), h;
                div.appendChild( n.cloneNode(true) );
                h = div.innerHTML;
                div = null;
                return h;
            })(node);
    }

    function stringFormat(format) {
        var args = Array.prototype.slice.call(arguments, 1);
        return format.replace(/{(\d+)}/g, function(match, number) {
            return typeof args[number] != 'undefined'
                ? args[number]
                : match;
        });
    };

    function ensureaaFormExtensions(form) {
        if(!form.$aaFormExtensions) {
            form.$aaFormExtensions = {};
        }
    }
    
    function ensureaaFormExtensionsFieldExists(form, fieldName) {
        ensureaaFormExtensions(form);

        if(!form.$aaFormExtensions[fieldName]) {
            form.$aaFormExtensions[fieldName] = {
                $hadFocus: false,
                $errorMessages: [],
                $element: null
            };
        }
    }
})()