Customizing Native Form Validation

in JavaScript

JavaScript form validation is pretty awesome. If you add <input type='email'> to your document, the browser will check the user’s input against what it considers a valid email format and (unless you’re in Safari) prevent the form from submitting if the format does not match.

That’s pretty awesome, until you see how differently browsers report errors back to users. Let’s look at Firefox and Chrome when validating a required input field. Here’s the test page:

 1<html>
 2  <head>
 3    <style>
 4      /* just a little breathing room for screenshots */
 5      body { padding: 40px; }
 6    </style>
 7  <body>
 8    <form>
 9      <input type='email' required name='email'>
10      <button type='submit'>
11        Submit
12      </button>
13    </form>

Here’s what Chrome looks like when you try to submit that form:

Chrome&rsquo;s required behavior

And here’s what Firefox looks like:

Firefox&rsquo;s required behavior

That’s … functional I guess, but not exactly attractive. Let’s see if we can’t get these behaviors more consistent as well as more attractive across browsers that support form validation.

Constraint Validation and the ValidityState

In order to intercept the browser’s default form validation behavior, we need to get familiar with the HTML5 Constraint Validation API, the available markup, and the associated ValidityState interface. I’m not going to cover everything there and I leave it to you to get to grips with some of what’s available, but I’ll walk you through getting something up and running to intercept the submit event and report back on which pieces of the form are invalid.

Customizing Form Validation

First, let’s attach a function to the submit event of every form on our page. In order to actually receive the event, we’ll need to turn noValidate on, otherwise the browser will simply stop the form from submitting before we get a chance to inspect it. We’ll then attach the validateForm method which we will write shortly.

 1var validateForm = function(submitEvent) {
 2  /* We'll fill this in incrementally. */
 3};
 4
 5document.addEventListener('DOMContentLoaded', function() {
 6  var forms = document.querySelectorAll('form');
 7
 8  for (var index = forms.length - 1; index >= 0; index--) {
 9    var form = forms[index];
10
11    form.noValidate = true;
12    form.addEventListener('submit', validateForm);
13  }
14});

So, validateForm is going to handle the business logic of determining:

  1. Is the form valid? If not, stop all propagation and…
  2. Which element is invalid?
  3. Why is this element invalid?
  4. Report problem back to user.

Not too bad. Let’s get started:

Form Validity

Is the form valid? The checkValidity method of the HTMLFormElement is what we’re looking for here. It reports back the results of the browser’s form validation attempt and returns a simple Boolean. So:

1if (!submitEvent.target.checkValidity()) {
2  /* oh noes! */
3} else {
4  return true; /* everything's cool, the form is valid! */
5}

Which Element is Invalid

Now that we know something’s wrong, let’s look at our elements to determine where we’re falling down:

 1var form     = submitEvent.target,
 2    elements = form.elements;
 3
 4/* Loop through the elements, looking for an invalid one. */
 5for (var index = 0, len = elements.length; index < len; index++) {
 6  var element = elements[index];
 7
 8  if (element.willValidate === true && element.validity.valid !== true) {
 9    /* element is invalid, let's do something! */
10    /* break from our loop */
11    break;
12  } /* willValidate && validity.valid */
13}

But Why?

OK, what’s wrong with this element? Pretty simple, we just read the validationMessage:

1var message = element.validationMessage,

Let the User Know

Cool, now let’s just report it back to the user!

 1var message = element.validationMessage,
 2    parent  = element.parentNode,
 3    div     = document.createElement('div');
 4
 5/* Add our message to a div with class 'validation-message' */
 6div.appendChild(document.createTextNode(message));
 7div.classList.add('validation-message');
 8
 9/* Add our error message just after element. */
10parent.insertBefore(div, element.nextSibling);
11
12/* Focus on the element. */
13element.focus();

Altogether Now

Here’s the entire validateForm function for reference:

 1var validateForm = function(submitEvent) {
 2  if (!submitEvent.target.checkValidity()) {
 3    /* Seriously, hold everything. */
 4    submitEvent.preventDefault();
 5    submitEvent.stopImmediatePropagation();
 6    submitEvent.stopPropagation();
 7
 8    var form     = submitEvent.target,
 9        elements = form.elements;
10
11    /* Loop through the elements, looking for an invalid one. */
12    for (var index = 0, len = elements.length; index < len; index++) {
13      var element = elements[index];
14
15      if (element.willValidate === true && element.validity.valid !== true) {
16        var message = element.validationMessage,
17            parent  = element.parentNode,
18            div     = document.createElement('div');
19
20        /* Add our message to a div with class 'validation-message' */
21        div.appendChild(document.createTextNode(message));
22        div.classList.add('validation-message');
23
24        /* Add our error message just after element. */
25        parent.insertBefore(div, element.nextSibling);
26
27        /* Focus on the element. */
28        element.focus();
29
30        /* break from our loop */
31        break;
32      } /* willValidate && validity.valid */
33    }
34  } else {
35    return true; /* everything's cool, the form is valid! */
36  }
37};

BONUS!

If you want to override the validationMessage and unify it across all browsers, why not try something like this:

 1/*
 2  Determine the best message to return based on validity state.
 3 */
 4var validationMessageFor = function(element) {
 5  var name = element.nodeName,
 6      type = element.type,
 7
 8      /* Custom, reused messages. */
 9      emailMessage = "Please enter a valid email address.";
10
11  /* Pattern is present but the input doesn't match. */
12  if (element.validity.patternMismatch === true) {
13    if (element.pattern == '\\d*') {
14      return "Please only enter numbers.";
15    } else {
16      return element.validationMessage;
17    }
18
19  /* Type mismatch. */
20  } else if (element.validity.typeMismatch === true) {
21    if (name == 'INPUT' && type === 'email') {
22      return emailMessage;
23    } else if (name == 'INPUT' && type === 'tel') {
24      return "Please enter a valid phone number.";
25    } else {
26      return element.validationMessage;
27    }
28
29  /* Required field left blank. */
30  } else if (element.validity.valueMissing === true) {
31    if (name == 'SELECT' || (name == 'INPUT' && type === 'radio')) {
32      return "Please select an option from the list.";
33    } else if (name == 'INPUT' && type === 'checkbox') {
34      return "Please check the required box.";
35    } else if (name == 'INPUT' && type === 'email') {
36      return emailMessage;
37    } else {
38      return "Please fill out this field.";
39    }
40
41  /* Input is out of range. */
42  } else if (element.validity.rangeOverflow === true || element.validity.rangeUnderflow === true) {
43    var max = element.getAttribute('max'),
44        min = element.getAttribute('min');
45
46    return "Please input a value between " + min + " and " + max + ".";
47
48  /* Default message. */
49  } else {
50    return element.validationMessage;
51  }
52};

That’s it!