The component we created thus far is enough to make the form invalid
if submitted, and show the "required" balloon when the field is invalid. Following is other built in validations.
The final result is preferably an unobtrusive one like this:
<input crinput type="number" id="valvet" formControlName="valvet" [min]="3" [required]="true" />
Before we build the new crinput
directive, the component itself can be enhanced.
We need to first add a location for the help text, and some new CSS. And a custom error property.
- The error message belongs to our custom component but needs to be flexible and can be overwritten
- The help text belongs to the project, don't even think about automating it
- From user experience side, I believe stating the rule up front, then keeping the error message short is the best way to fill the form efficiently.
In our final HTML, this is where we want to get:
<!-- form template-->
<cr-input placeholder="Mars" error="Too small">
<input crinput type="number" id="mars" formControlName="mars" [min]="2" />
<ng-container helptext>Minimum of 2</ng-container>
</cr-input>
To reflect this in our input component, we use a new content selector
<!-- input.partial -->
<div class="{{ cssPrefix }}-field">
<label class="cr-label" for="{{for}}">{{ placeholder }}</label>
<ng-content></ng-content>
<span class="cr-required"></span>
<span class="cr-feedback">{{ error }}</span>
<span class="cr-help">
<!-- second content projection -->
<ng-content select="[helptext]"></ng-content>
</span>
</div>
And the CSS
.cr-field {
/* ... */
.cr-help {
color: var(--sh-text-light, #959595);
font-size: 90%;
display: block;
margin-block-start: var(--sh-halfspace, 0.8rem);
text-align: end;
}
/* if there is nothing, hide it */
.cr-help:empty {
display: none;
}
}
And finally the error message. That can be set as an input:
// input.partial
export class CrInputPartial implements AfterContentInit {
// ...
@Input() error: string;
}
Directive
The directive should implement Validator
, the event to handle is
validate(control: AbstractControl): ValidationErrors | null
What we want to do is automate the error message based on the validation rule added, without canceling the validation. We need to provide the NG_VALIDATORS
token to be able to implement Validator
// new lib/input/input.directive.ts
@Directive({
selector: '[crinput]',
// the "multi" allows the native validation to kick in first
providers: [{provide: NG_VALIDATORS, multi: true, useExisting: InputDirective}]
})
export class InputDirective implements Validator {
constructor() {}
validate(control: AbstractControl): ValidationErrors | null {
// to be implemented
// set error text based on validation
// then return null to continue with the native Angular validation
return null;
}
}
Back to our component, swap the ContentChild
with the directive
// inut.partial
// ...
@ContentChild(InputDirective, { static: true }) inputDirective!: InputDirective;
Instead of having two content child definitions, one for the named element #crinput
and one for the directive crinput
, we can rely on the directive itself to get the element, and elevate it via a public property. Like this:
// input.directive
constructor(private el: ElementRef) {}
// the element is an HTML element of the input
public get element(): HTMLElement {
return this.el.nativeElement;
};
In the input component
// input.component
export class CrInputPartial implements AfterContentInit {
// we only need this
@ContentChild(InputDirective, { static: true }) inputDirective!: InputDirective;
ngAfterContentInit() {
if (this.inputDirective) {
const element = this.inputDirective.element;
// if element exists:
this.for = element.id;
element.classList.add('cr-input');
element.setAttribute('placeholder', this.placeholder);
}
}
}
So now we can remove the #crinput
<cr-input placeholder="Valvet">
<input crinput type="text" id="valvet" formControlName="valvet" [required]="true" />
</cr-input>
The error message property
Let's start with the simple min
validation. What we need is a way to read that property in the directive. One way to do that is to expose the min
input.
// input.directive
// expose the min property
@Input() min?: number;
// create an error text property
private _error: string = 'Required';
public get errorText(): string {
return this._error;
}
// ...
validate(control: AbstractControl): ValidationErrors | null {
// if min exists
if (this.min) {
// change error message
this._error = 'Too small';
}
// move on
return null;
}
Then in the component
// input.component
template: `
<div class="{{ cssPrefix }}-field">
// ...
<!-- use a new property errorText -->
<span class="cr-feedback">{{ errorText }}</span>
</div>
`,
// keep error input
@Input() error: string;
// error text now is a combination
get errorText(): string {
// get custom error or fall back to directive
return this.error || this.inputDirective?.errorText;
}
The native validation kicks in afterwards. Let's add max
input as well.
Edge case: min and max
If we test for both minimum and maximum at the same time, it won't quite work. We need to know which edge was the invalid edge.
<input crinput type="number" id="mars" formControlName="mars" [min]="2" [max]="5" />
In our validator, we need to validate using the native validation to see which edge was invalid.
// input.directive
// two inputs
@Input() min?: number;
@Input() max?: number;
// check which edge failed
if (this.min) {
if (Validators.min(this.min)(control)) {
this._error = 'Too small';
}
}
// when max fails, change to message
if (this.max) {
if (Validators.max(this.max)(control)) {
this._error = 'Too large';
}
}
There is one issue that blocked the process, and that is the errorText
is not updating. The directive changes the value, but the component never picks up. Unless:
- use
ChangeDetectionStrategy.Default
(nasty) - Join the Signal bandwagon (Finally, I have a proper use case!)
The errorText
need to be a Signal.
// input.directive
@Input() min?: number;
@Input() max?: number;
// ...
// turn it into a signal
public errorText = signal('Required');
validate(control: AbstractControl): ValidationErrors | null {
// when min fails, change to message
if (this.min) {
if (Validators.min(this.min)(control)) {
this.errorText.set('Too small');
}
}
// when max fails, change to message
if (this.max) {
if (Validators.max(this.max)(control)) {
this.errorText.set('Too large');
}
}
return null;
}
And the component now reads the signal:
// input.partial
get errorText(): string {
// get custom error or fallback to directive error
return this.error || this.inputDirective.errorText();
}
If it is also required, the message need to be reset. So reset to required on every validation. Also, we need to validate only when value is not null.
// directive
// slight adjustment to handle required as well
// always reset to required
this.errorText.set('Required');
// validate if value is not null
if (this.min && control.value) {
if (Validators.min(this.min)(control)) {
this.errorText.set('Too small');
}
}
if (this.max && control.value) {
if (Validators.max(this.max)(control)) {
this.errorText.set('Too large');
}
}
Reverse range
What if we want a truly custom validation, where the number should be outside range?
// directive
export class InputDirective implements OnInit, Validator {
// simple array
@Input() block?: [number, number];
validate(control: AbstractControl): ValidationErrors | null {
// ...
if (this.block) {
// its valid if the value is outside the block array
if (control.value >= this.block[0] && control.value <= this.block[1]) {
this.errorText.set('Invalid number');
// one of the few times we need to return invalid
return {
block: true
}
}
}
return null;
}
}
This is a sort of custom validation, so we need to return something, in this case block: true
. You can complicate this further if you wish, but it's good enough for this project.
Length
It's quite similar to max and min:
// directive
@Input() minLength?: number;
@Input() maxLength?: number;
// in validate
if (this.minlength && control.value) {
if (Validators.minLength(this.minlength)(control)) {
this.errorText.set('Too short');
}
}
if (this.maxlength && control.value) {
if (Validators.maxLength(this.maxlength)(control)) {
this.errorText.set('Too long');
}
}
Pattern
Working backwards, the pattern is added as an attribute:
<!--html-->
<cr-input placeholder="Shortname">
<input crinput type="text" id="shortname" formControlName="shortname" pattern="[A-Za-z]{5}" />
<ng-container helptext>Alphanumeric, 5 characters</ng-container>
</cr-input>
Then in our directive, we expose the pattern to change the error message:
// directive
@Input() pattern?: string;
validate(control: AbstractControl): ValidationErrors | null {
// ...
if(this.pattern) {
this.errorText.set('Invalid format');
}
return null;
}
This might look enough. But this is our chance to reign our patterns. I would like my final result to look like this
<input crinput id="short-name" formControlName="phone" pattern="phone" />
The error message should be "Invalid short-name format", the specs of our new feature are:
- 'Phone' could be a look up that is constantly used, the message would be "invalid phone format"
- Or it can be overwritten per project, the message would be "invalid format" or a custom error
- Nice to have: allow the format to be added. Silliest way is: global
const
. - Or a simple rollback to default Angular pattern, the message would be "invalid format" or a custom error
First, we create a lookup for our phone pattern to be used globally.
Using pattern
directly will fail, because it cannot be canceled if handled. And I will not dwell upon it much, overriding it or redefining the default behavior is not ideal. So we will stay away from the pattern
attribute. We will create a new property: crpattern
// partial.directive
// new prop
@Input() crpattern?: string;
if(this.crpattern) {
// the default
this.errorText.set('Invalid format');
// if pattern exists in our list (to be created), use validators
let _pattern = InputPatterns.get(this.crpattern);
if (_pattern) {
this.errorText.set(`Invalid ${this.crpattern} format`);
// now validate
return Validators.pattern(_pattern)(control);
}
}
We create a new file for the input patterns
// patterns.ts
export const InputPatterns = new Map<string, any>([
['phone', '[+\\d\\s]*'],
['password', '[\\S]{8,}']
]);
In our HTML, here are three scenarios that cover the requirements:
// form component
templtes: `
<cr-input placeholder="Phone">
<!-- A built-in phone format -->
<input crinput type="text" id="phone" formControlName="phone" crpattern="phone" />
<ng-container helptext>Alphabets and spaces</ng-container>
</cr-input>
<cr-input placeholder="Shortname">
<!-- an adhoc new pattern -->
<input crinput type="text" id="shortname" formControlName="shortname" crpattern="shortname" />
<ng-container helptext>Alphanumeric, 5 characters</ng-container>
</cr-input>
<cr-input placeholder="Some pattern" error="Invalid koolaid format">
<!-- native with a custom error -->
<input crinput type="text" id="koolaid" formControlName="koolaid" pattern="[1-9]{2,4}" />
<ng-container helptext>Two to 4 numbers</ng-container>
</cr-input>
`
// ...
ngOnInit(): void {
// add and adhoc pattern, this is forever
InputPatterns.set('shortname', '[A-Za-z]{5}');
// the form
this.fg = this.fb.group({
valvet: [''],
mars: [1],
buler: [],
shortname: [],
phone: [],
koolaid: []
});
// ...
}
This produces the following
So far so good. I'd rather use the silly way knowing that every project will have its own unique way to extend the formats. Personally, I would turn the patterns class into an abstract and override it locally.
Note about text
The ideal way is not to use the text in the directive directly but rather use a resources file. In my attempt to redefine localization in Angular, I came up with a Res
class that reads the values off of a javascript file. But you can use your own translation solution to return the right message. My resources would look like this
"INVALID_phone_FORMAT": "Have you ever used a phone!",
Where shortname
is the pattern key. The messages displayed in the directive would be:
this.errorText.set(Res.Get(INVALID_${this.crpattern}_FORMAT));
Email validation
It should be exactly similar to pattern, but according to the documentation, a sole attribute is enough: email
. But because we need to map it, and read it, we should get into the habit of using an exact value. I choose boolean
.
<!--from component-->
<cr-input placeholder="Email">
<input crinput type="email" id="email" formControlName="email" [email]="true" />
<ng-container helptext>Native email</ng-container>
</cr-input>
The validators
// input.directive
@Input() email?: boolean;
if(this.email) {
this.errorText.set('Invalid email format');
}
Using the Angular native email validator is better, it keeps the email pattern up to date. But you will find yourself in situations where the pattern is too loose. For example email@example
actually checks. In this case, use a custom crpattern
instead.
Custom validation
This one has resolved itself. If we use a validation function, all we need now is to set a custom error message:
// form component
template: `
<cr-input placeholder="Custom name" error="Bob is not allowed">
<input crinput type="text" id="koolaid" formControlName="name" />
<ng-container helptext>Not bob</ng-container>
</cr-input>
`
// inside class custom examle
forbiddenNameValidator = (nameRe: RegExp): ValidatorFn => {
return (control: AbstractControl): ValidationErrors | null => {
const forbidden = nameRe.test(control.value);
return forbidden ? {forbiddenName: {value: control.value}} : null;
};
}
// in form builder
name: ['', this.forbiddenNameValidator(/bob/i)]
Cross form validation
In cross form validation things are not as clear. From user experience point of view, it isn't correct to place the error message out of context. There must be context. For example, password confirm validation, is a custom validation of the second input. Since we can have the function on the same page, the form value is available. So this has been covered. Here is the example in documentation.
// form
template: `
<cr-input placeholder="Custom name" error="Bob is not allowed">
<input crinput type="text" id="name" formControlName="name" />
<ng-container helptext>Not bob</ng-container>
</cr-input>
<cr-input placeholder="Role" error="Name cannot match role or audiences will be confused.">
<input crinput type="text" id="role" formControlName="role" />
</cr-input>
`;
// in class
unambiguousRoleValidator = (control: AbstractControl): ValidationErrors | null => {
// read the other control directly from form
const name = this.fg?.get('name')?.value;
const role = this.fg?.get('role')?.value; // same as control
return name && role && name === role ? { unambiguousRole: true } : null;
};
name: [''],
role: ['', this.unambiguousRoleValidator],
The result looks like this
The only problem with this is that user needs to blur the Role field to validate again. The ugly way to fix it is watch changes on the first element, to update the second.
// form component
`<input crinput type="text" id="name" formControlName="name"
(change)="checkRole()" />`
// ...
checkRole() {
this.fg.get('role').updateValueAndValidity();
}
Form validation
Another way to catch cross-form validation is to validate the form itself. We still want the style to be attached to one control, but we want the trigger to be the form validation, instead of blur event validation. The simplest way is using CSS in combination to hasError
Going back to the above example, and changing the validator to be a form validator:
// form component
// add feedback somwhere, with a condition
template: `<div *ngIf="fg.hasError('unambiguousRole')" class="cr-form-feedback">An error here</div>`
this.fg = this.fb.group({
//...
name: ['', this.forbiddenNameValidator(/bob/i)],
role: ['']
}, { validators: this.unambiguousRoleValidator });
Then in CSS, we just need to show it upon submission only (and add the colors and what not)
/* input.css */
.cr-form {
.cr-form-feedback {
display: none;
}
&.ng-submitted .cr-form-feedback {
display: block;
}
}
We can place that HTML element anywhere, if we place it inside a cr-input
element, with an extra cr-feedback
class, it would look like a normal contextual error.
<cr-input placeholder="Role" >
<input crinput type="text" id="role" formControlName="role" [required]="true" />
<span *ngIf="fg.hasError('unambiguousRole')" class="cr-form-feedback cr-feedback">Name cannot match role or audiences will be confused.</span>
</cr-input>
Add a new property for cross field validation
We can now rewrite our input partial to cater for this with a new boolean
property, let's call it invalidForm
.
<cr-input placeholder="Role" error="Name cannot match role or audiences will be confused." [invalidForm]="fg.hasError('unambiguousRole')">
<input crinput type="text" id="role" formControlName="role" [required]="true" />
</cr-input>
In the library component
// input.partial
template: `
// add to container compoennt
<div class="{{ cssPrefix }}-field" [class.cr-invalid-form]="invalidForm">
// and to feedback element
<span class="cr-feedback" [class.cr-invalid-feedback]="invalidForm">{{ errorText }}</span>
</div>
`,
@Input() invalidForm: boolean;
Now our css can accommodate the new cr-invalid-form
, when the form is submitted, the cr-input
should act as invalid because the invalidForm
boolean
is true when invalid.
/* input.css */
.ng-submitted {
.cr-field {
.cr-input.ng-invalid,
/* add this selector */
&.cr-invalid-form .cr-input {
box-shadow: inset 0 0 0 1px var(--sh-red, #f31109);
border-color: var(--sh-red, #f31109);
}
.cr-input.ng-invalid ~ .cr-feedback {
display: block;
}
.cr-label:has(~ .cr-input.ng-invalid),
/* and this selector */
&.cr-invalid-form .cr-label {
color: var(--sh-red, #f31109);
}
}
}
/* we can keep the .cr-form definiton for standalone feedback */
Changing the value of other fields no longer needs to be watched, as fg.hasError('unambiguousRole')
is always up to date. Great.
Issue: Multiple error messages
The idea of having a custom error property is to overwrite the error messages in the component. But if there are multiple validations, the custom error will override them all. But we can bind the error message as follows:
[error]="fg.hasError('unambiguousRole') ? 'Name cannot match role or audiences will be confused.' : null"
If null
, the required
message takes over.
I personally have no problem with that, and I will not try to fix it to be a mutant. My simple user friendly solution is to change the message itself:
Required. And cannot match name to avoid confusion.
There. Problem solved.
Using the directive in other ways
Here is a scenario, a control of two fields, and the output result combined is what needs to be validated. Example: Month, and Year. Need to be in the future.
The idea is not to squeeze our control to handle that, but to have an output that follows the same design decisions. If we deal with them as separate fields, then it's just cross-form validation.
Ideally, the month and year controls, normal text controls, are wrapped inside a custom control that emits the value MMYY. Which is then assigned to a hidden input, that needs to be validated. This is easy and straight forward. Better than trying to squeeze in the validation in the non control. I like the fact that Angular turned validation to a simple custom function.
The only problem we will be facing is styling. Which we will be addressing later.
// form component
`<cr-input placeholder="Expiration" error="This is expired">
<input type="hidden" crinput id="mmyy" pattern="[0-9]{4}" formControlName="mmyy" />
// some component with MM and YY fields
<cr-product-expiry (onValue)="expirationValue($event)"></cr-product-expiry>
</cr-input>`
//... in class
thisMonth: string = (new Date()).toISOString().substring(2, 7).replace('-', '');
futureValidator = (control: AbstractControl): ValidationErrors | null => {
// if control MMYY in the future, return null
const value = control.value;
if (!value || +value > +this.thisMonth) {
return null;
}
return {
future: true
};
};
expirationValue(value: { month: string, year: string; }) {
// change form value to be mm then yy
this.fg.get('mmyy').setValue(value.year + value.month);
}
// on init
this.fg = this.fb.group({
//...
mmyy: [null, this.futureValidator],
}
The result is looks like this. May be later we need to adapt the required asterisk for hidden fields.
The best experience
After years of working with different mindsets and different forms, I'd say the best experience in form validation is like this;
- Do not validate first time, wait for first submission (unless it's an in place form, wait for blur)
- Error messages, show all possible errors without breaking logic (Required, too large, or too small) but don't wait for a second input to show a different error.
- Keep the error messages to the bare minimum, instead, depend on help text
- Do not scroll up, toast
The last bit was a lesson I learnt the hard way. When there is a problem in form, toast that there are problems, then let user do their own scrolling up.
On submit, show your toast
submit() {
if (this.fg.invalid) {
this.toast.ShowError('INVALID_FORM');
}
}
Want to create a toast control? Read about it in my Garage
Form types
Now that we validated the text input, let's move on to different types, and see what else we need to take care of. Besides the style. This should till next episode. 🥱
Did you know that Google is complicit in Gaza Genocide and Palestinian ethnic cleansing?
Top comments (0)