AngularJS

How to create a custom form input and custom validations with a Directive.


Some times ago someone asked me to implement a date selector with 3 select inputs instead of a date input.

I thought about multiple problems with that implementation:

  • No form input validation with ngMessages
  • No 28/30/31 days a month check out of the box
  • No leap year check out of the box

Directive to the rescue


At the end of this post we should have:

  • A single directive to handle 3 select inputs (month/day/year)
  • A valid javascript date format as input and output linked to a ng-model
  • A ‘required’ error handling with 3 sub-errors (year required, month required, day required) to interact with ngMessages
Demo & sources


I’ll assume that you know how to install and use ngMessages.


main.html

  <multi-select-date name="birthdate" ng-model="user.birthdate" year-order="desc" start-year="1980" end-year="2000" required></multi-select-date>

as you can see the “multi-select-date” has multiple attributes:

  • name, so you can intercept it from ngMessages
  • ng-model, to bind data to a model in your controller
  • year-order, asc = 1980 -> 2015, desc = 2015 -> 1980, default is desc
  • start-year & end-year, let you specify the min and max year, by default end-year = the actual year and start-year = the actual year minus 100 years

multi-select-date.html

This is the directive template, 3 select inputs that populate through ng-options, and an ”orderBy” filter on the year’s select to handle the “year-order” using the reverse option of the filter.

  <div class="form-inline">
  <select ng-model="date.month" ng-options="month for month in selects.months()" class="form-control">
    <option value="" disabled>--</option>
  </select>

  <select ng-model="date.day" ng-options="day for day in selects.days()" class="form-control">
    <option value="" disabled>--</option>
  </select>

  <select ng-model="date.year" ng-options="year for year in selects.years() | orderBy: year:yearOrder" class="form-control">
    <option value="" disabled>----</option>
  </select>
</div>

Note that the sources of the ng-options are not arrays but functions that will generate dynamically:

  • The right years number based on “start-year” and “end-year”
  • 12 months with a leading zero
  • And the right count of days based on the year and month previously selected (or 31 days if nothing selected yet)

multiSelectDate.directive.js

Now let see the actual directive.

  • restrict: ‘E’ to tell angular that we want a element.
  • require: ‘?ngModel’ to tell angular that we require ng-model, it will be available through the ngModel’s link’s function parameter.
  • templateUrl to target our directive template.
  • The link function where the magic happens

As you can see the directive has 4 major parts:

  • getting the ng-model content and create our local scope with a string equivalent of year/month/day (in case that you specified a date in your controller for example).
  // GET FROM NG MODEL AND PUT IT IN LOCAL SCOPE
ngModel.$render = function () {
  scope.date = {
    day: $filter('date')(ngModel.$viewValue, 'dd'),
    month: $filter('date')(ngModel.$viewValue, 'MM'),
    year: $filter('date')(ngModel.$viewValue, 'yyyy')
  };
};

  • getting the directive options or defined default options if no attributes have been set in your tag.
  // ATTRIBUTES (with default values if not set)
scope.yearOrder = (attrs.yearOrder && attrs.yearOrder === 'asc') ? false : true; // year order: 'asc' or 'desc', default: desc
var endYear = attrs.endYear || new Date().getFullYear(); // default: this year
var startYear = attrs.startYear || startYear - 100; // default: this year - 100

  • generating years, month and days list.
  // INIT YEARS, MONTHS AND DAYS NUMBER
scope.selects = {

  days: function(){

    // Get number of days based on month + year 
    // (January = 31, February = 28, April = 30, February 2000 = 29) or 31 if no month selected yet
    var nbDays = new Date(scope.date.year, scope.date.month, 0).getDate() || 31;

    var daysList = [];
    for( var i = 1; i <= nbDays ; i++){
      var iS = i.toString();
      daysList.push( (iS.length < 2) ? '0' + iS : iS ); // Adds a leading 0 if single digit
    }
    return daysList;
  },
  months: function(){
    var monthList = [];
    for( var i = 1; i <= 12 ; i++){
      var iS = i.toString();
      monthList.push( (iS.length < 2) ? '0' + iS : iS ); // Adds a leading 0 if single digit
    }
    return monthList;
  },
  years: function(){
    var yearsList = [];
    for( var i = endYear; i >= startYear ; i--){
      yearsList.push( i.toString() );
    }
    return yearsList;
  }
};

  • watching “scope.date” model changes and setting validation rules to interact with the main form, and update the ng-model if everything is allright.
  // WATCH FOR scope.date CHANGES
scope.$watch('date', function(date) {

  // IF REQUIRED
  if(attrs.required){

    // VALIDATION RULES
    var yearIsValid = !!date.year && parseInt(date.year) <= endYear && parseInt(date.year) >= startYear;
    var monthIsValid = !!date.month;
    var dayIsValid = !!date.day;

    console.log(yearIsValid, monthIsValid, dayIsValid);

    // SET INPUT VALIDITY
    ngModel.$setValidity('required', yearIsValid || monthIsValid || dayIsValid ? true : false );
    ngModel.$setValidity('yearRequired', yearIsValid ? true : false);
    ngModel.$setValidity('monthRequired', monthIsValid ? true : false);
    ngModel.$setValidity('dayRequired', dayIsValid ? true : false);

    // UPDATE NG MODEL
    if(yearIsValid && monthIsValid && dayIsValid){
      ngModel.$setViewValue( new Date(date.year, date.month - 1, date.day) );
    }
  }

  // IF NOT REQUIRED (still need the 3 values filled to update the model)
  else if(date.year && date.month && date.day){
    ngModel.$setViewValue( new Date(date.year, date.month - 1, date.day) );
  }

}, true);


47,630 6 13