Nathan Knowler

Extending HTML Form Validation

Permalink

HTML is awesome; it includes so much out-of-the-box. For example, <form> will validate its controls upon submission and report if there are any validation errors. You can use attributes like required, maxlength, pattern, etc. to set the validation for different fields. While these can get you pretty far, sometimes you might want to provide some more complex validation. Often, this is where developers plop novalidate on the <form> element and then use full replacement for the browser’s built-in validation. Instead of replacing it, you can use the Web platform’s Constraint Validation API to extend the built-in functionality.

Using the Constraint Validation API

The Constraint Validation API allows one to add custom complex validation in their forms (among other things not described in this post).

Here’s an example: there are two text inputs that you want to be unique from each other. We’ll wrap them in a custom element, since they are great for bundling up this sort of functionality.

<form>
	<unique-values>
		<fieldset>
			<legend>Unique values</legend>
			<label>A <input name="a"></label>
			<label>B <input name="b"></label>
		</fieldset>
	</unique-values>
	<button>Submit</button>
</form>

Now, let’s scaffold the custom element and listen for the input event on the <fieldset>:

class UniqueValues extends HTMLElement {
  get fieldset() { return this.querySelector(':scope > fieldset'); }

  connectedCallback() {
		this.fieldset.addEventListener('input', event => {
			// TODO: set and report the validity.
		});
  }
}

window.customElements.define('unique-values', UniqueValues);

The Constraint Validation API provides a few methods for setting, checking, and reporting validity. We’ll use .setCustomValidity(message) and .reportValidity(). The first allows us to set a custom error message. The second is what spawns the tooltip on a form control.

We’ll use the input event as our validation logic isn’t very costly performance-wise. This also follows how HTML seems to work. If you want to see for yourself, use CSS to style the :invalid state of a form control with one the basic HTML validation constraints (e.g. required, minlength, pattern). The validity is checked as the input changes.

In the input event handler for <fieldset>, we can compare the values of both nested controls, then set and report the validity accordingly.

this.fieldset.addEventListener('input', event => {
	const {a, b} = event.currentTarget.elements;

	if (a.value === b.value) {
		// If the values are the same, set the error, and report it on
		// the current form control.
		a.setCustomValidity('A and B must be unique.');
		b.setCustomValidity('A and B must be unique.');
		event.target.reportValidity();
	} else {
		// If the values aren’t the same, clear the error on each
		// control.
		a.setCustomValidity('');
		b.setCustomValidity('');
	}
});

Now, if you pay close attention to how HTML’s built-in validation works, you’ll notice that the tooltip doesn’t show until a submission attempt is made. I’m not presenting any hard opinion about whether or not you should do the same. That’s a decision that’s best determined by research and context, but that’s outside of the scope of this blog post.

You’ll also notice that HTML’s validation also takes place when the elements are first added to a page. We can move the event listener callback into a method.

connectedCallback() {
	this.validate();
	this.fieldset.addEventListener('input', this.validate.bind(this));
}

validate(event) {
	const {a, b} = this.fieldset.elements; // Might as well use fieldset

	if (a.value === b.value) {
		a.setCustomValidity('A and B must be unique.');
		b.setCustomValidity('A and B must be unique.');
		// We’ll optionally chain the report call as the event won’t be
		// there on page load and it shouldn’t report immediately anyway.
		event?.target.reportValidity();
	} else {
		a.setCustomValidity('');
		b.setCustomValidity('');
	}
}

Now, here is the code altogether. Remember, this is just a demo. This code is not as robust as it could be (e.g. there are checks I’ve left out, missing teardown, and it’s not very reusable).

<form>
	<unique-values>
		<fieldset>
			<legend>Unique values</legend>
			<label>A <input name="a"></label>
			<label>B <input name="b"></label>
		</fieldset>
	</unique-values>
	<button>Submit</button>
</form>
<script>
class UniqueValues extends HTMLElement {
  get fieldset() { return this.querySelector(':scope > fieldset'); }

	connectedCallback() {
		this.validate();
		this.fieldset.addEventListener('input', this.validate.bind(this));
	}

	validate(event) {
		const {a, b} = this.fieldset.elements;

		if (a.value === b.value) {
			a.setCustomValidity('A and B must be unique.');
			b.setCustomValidity('A and B must be unique.');
			event?.target.reportValidity();
		} else {
			a.setCustomValidity('');
			b.setCustomValidity('');
		}
	}
}

window.customElements.define('unique-values', UniqueValues);
</script>