Creating a form has always been a terrible task I keep till the end. And I usually just start with copying an existing form file. Today, I decided to do something about it. Here is a series to dig into Angular forms, in their simplest shape, and create reusable component to control validation.
I want to create a simple reusable solution for forms in Angular. A solution that is unobtrusive, and non-third-party reliant. The following is what I want to end up with:
- Use native HTML input elements.
- Validation rules should be kept to minimum
- Keep the Angular form loose (do not reinvent the wheel)
- Use attributes instead of Form builder (unobtrusive)
- Keep form submission loose to allow as much flexibility
- Minimum styling allowing full replacement.
The end result should look something like this:
In order to do that, we need to crack open the secrets of Angular forms. First, the HTML input basic form:
<input type="text" formControlName="name" [required]="true" />
The final solution can be found on StackBlitz
The series will cover the following topics:
- Creating a component that relies on CSS only to display validation
- Creating a validation directive to better control the error message
- Listing the different input types and fine tune their validation
- Putting together the most common validation functions, like Date, Password confirmation and Upload file validation
Opening the input box
Angular adds CSS classes and HTML attributes to the form field, upon interactions, as follows.
-
.ng-valid
versus.ng-invalid
reflects the validity at all times -
.ng-pending
used with asynchronous validation, between states -
.ng-pristine
versus.ng-dirty
reflects whether the value changed, on keypress -
.ng-untouched
versus.ng-touched
reflects the first state after blur, it will be touched after first blur -
.ng-submitted
is for theform
element if it exists, after submission by firing thesubmit
event
Angular mirrors native HTML classes, then disables it using novalidate
, we will not enabled it because we don’t want to end up caring for two systems. Let’s dig in those classes. This is the simplest standalone form control:
// form page
@Component({
// ...
template: `<input type="text" [formControl]="valvet" />`
})
export class FormComponent implements OnInit {
valvet: FormControl;
ngOnInit(): void {
this.valvet = new FormControl('');
}
}
Running, turns out the following
<input type="text" class="ng-untouched ng-pristine ng-valid" >
The flow is summarized as follows:
- It begins with
untouched
andpristine
- If blurred it becomes
touched
even if no value changed - If
required
is set, it starts withinvalid
, unless value is already set. - On keypress it receives
dirty
- Validity is updated whenever change occurs
The main thing to pay attention to: it becomes touched after first blur event, and not on focus.
Resetting using reset()
returns everything to its initial state, except the value, it becomes empty.
Placing in a form
element:
// form page
template: `
<form [formGroup]="fg">
<input type="text" formControlName="valvet" [required]="true" />
<button type="reset" (click)="reset()">Reset</button>
<button type="submit">Submit</button>
</form>
`
// ...
fg: FormGroup;
ngOnInit(): void {
this.fg = new FormGroup({
valvet: new FormControl('')
});
}
reset() {
this.fg.reset();
}
The form gets the same classes, and reflects the single input classes
/* Output */
<form novalidate="" class="ng-untouched ng-pristine ng-invalid">
<input type="text" formcontrolname="valvet" class="ng-untouched ng-pristine ng-invalid" required>
<button type="reset">Reset</button>
<button type="submit" >Submit</button>
</form>
To continue with the flow:
- It starts with
untouched
andpristine
andvalid
- It reflects the state of at least one form field that is
touched
dirty
orinvalid
- On
submit
button click,ng-submitted
is added to the form classes. - Resetting does not take away the
submitted
state
Remember that by default the button type is submit
, so we always need to be explicit about non submitting buttons. In this case, the reset button is of type reset
This is nice, we can control the validation style completely in CSS. The ideal behavior is: do not validate until submitted, there is nothing more annoying that validation nag.
Here is a quick example:
/* CSS to control the red border */
.cr-input {
outline: 1px solid #ccc;
border: 0;
}
/* when sourunded by ng-submitted */
.ng-submitted .cr-input.ng-invalid {
outline: 1px solid red;
}
There might be times you want to handle it differently, by showing invalidity the minute it’s dirty.
/* a differnet way */
.cr-input {
outline: 1px solid #ccc;
border: 0;
}
/* when invalid and dirty */
.cr-input.ng-invalid.ng-dirty {
outline: 1px solid red;
}
We can make an enhancement by removing the style on focus
/* garnish, on focus remove red border */
.cr-input:focus {
outline: 1px solid #007bff;
}
We need to control the appearance of the required
label, and the safest way is to wrap it with another element. The bare minimum of this new element is:
- a label
- an asterisk
- a span with
required
text
The final result of the HTML should look like this
<!-- the targeted HTML -->
<div class="cr-field">
<label class="cr-label" for="valvet">Valvet</label>
<input type="text" id="valvet" class="cr-input" formControlName="valvet" [required]="true" />
<span class="cr-required"></span>
<span class="cr-feedback">Required</span>
</div>
The asterisk is a pseudo element. We can target the asterisk with the validity state
.cr-input ~ .cr-required {
display: none;
color: red;
font-weight: bold;
&:after {
content: '*';
}
}
.cr-input[required] ~ .cr-required {
display: inline-block;
}
We can also target the parent-child relationship using the :has()
operator. We will need that for the label, because we need to style the “previous sibling” according to state.
.cr-label {
display: block;
margin-block-end: 5px;
}
/* this is a new CSS feature */
.cr-label:has(~ .cr-input:focus-visible) {
color: #007bff;
}
This looks nice. Last time I worked on this, I had to write a whole lot of code, now eliminated by two features:
-
:has()
new selector -
ng-submitted
new state
Amazing!
Preparing for floating labels
One more pseudo class that can spice it up, is the :placeholder-shown
selector. If the field is empty, the placeholder is showing. If field is focused, or the field is not empty, the placeholder is hidden. Let’s style this (this will come in handy when we create floating placeholders).
We don’t need the placeholder itself, because we will float in the label itself, later. So we make it transparent:
/* example styles to target placeholder */
.cr-input:placeholder-shown {
outline: 1px solid green;
}
/* because we don't need the placholder */
.cr-input::placeholder {
color: transparent;
}
.cr-label:has(~ .cr-input:placeholder-shown) {
color: red;
}
But this means the placeholder value cannot be empty. We can place a “.” in it. Later we can automate this process when we create our component.
Finally the “required” feedback that appears when validated and form is submitted.
.cr-input ~ .cr-feedback {
display: none;
color: red;
font-weight: bold;
}
.ng-submitted {
.cr-input.ng-invalid {
outline: 1px solid red;
}
.cr-input.ng-invalid ~ .cr-feedback {
display: block;
}
}
Nested styles are now native. Yippee!
The code can now be transferred into its own component. We probably need to add “id” for the label (naming it an id
property is a bad choice, so we name it for
). Let’s call it input.partial.ts
. The native input field is content-projected.
// input.partial
@Component({
selector: 'cr-input',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="cr-field">
<label class="cr-label" for="{{for}}">{{ placeholder }}</label>
<!-- content projection -->
<ng-content></ng-content>
<span class="cr-required">*</span>
<span class="cr-feedback">Required</span>
</div>
`,
})
export class CrInputPartial implements AfterContentInit {
@ContentChild('crinput', { static: true, read: ElementRef }) inputElement!: ElementRef;
@Input() placeholder: string;
for!: string;
ngAfterContentInit() {
// get the id from the native element
this.for = this.inputElement.nativeElement.id;
// assign the default placeholder as a placeholder attribute
this.inputElement.nativeElement.setAttribute('placeholder', this.placeholder);
}
}
In the consuming component (let’s call it formComponent
):
<!-- formComponent html template -->
<cr-input placeholder="Valvet">
<input #crinput type="text" id="valvet" class="cr-input" formControlName="valvet" [required]="true" />
</cr-input>
The #crinput
is necessary, but later we will create a directive to take care of other things and replace it.
Do we keep the styles contained within the component, or do we let the outside world define it? I’m torn.
- I usually prefer to keep it outside, so that each project has its own styling.
- For the past 5 or so years, I rarely touched the CSS
So to stay true to both, let’s add the CSS in the component, but allow an override of the parent class for further fine tuning.
// input.partial
@Component({
// ...
styleUrl: 'input.css',
// do not encapsulate
encapsulation: ViewEncapsulation.None,
// added cssPrefix
template: `
<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">Required</span>
</div>
`,
})
export class CrInputPartial implements AfterContentInit {
//...
// make the cssPrefix an optional input
@Input() cssPrefix: string = 'cr';
ngAfterContentInit() {
// push the class to the input
this.inputElement?.nativeElement.classList.add(`cr-input`);
}
}
The CSS is going to be a floating labels style, we will use CSS vars with a fallback, and we will not include the width, because width is defined in the consumer. Every project has its own story.
The CSS:( --sh
stands for shut)
/* input.css */
.cr-field {
position: relative;
display: block;
margin-block-end: var(--sh-doublespace, 3.2rem);
margin-block-start: var(--sh-halfspace, 0.8rem);
.cr-input {
border: 1px solid var(--sh-grey, #999999);
border-radius: var(--sh-radius, 3px);
appearance: none; /* I don't think this is necessary */
outline: none;
padding: var(--sh-halfspace, 0.8rem);
box-shadow: 0 2px 5px 0 var(--sh-grey-light, #f3f3f3cc);
background-color: var(--sh-white, #ffffff);
caret-color: var(--sh-linkcolor, #0c00b4);
&:focus-visible {
outline: none;
border-color: var(--sh-linkcolor, #0c00b4);
box-shadow: 0 0 0 1px var(--sh-linkcolor, #0c00b4);
}
/* nice to have, hide asterisk when input is focused */
&:focus-visible ~ .cr-required {
display: none!important;
}
&::placeholder {
color: transparent!important;
}
}
.cr-input ~ .cr-required {
display: none;
position: absolute;
inset-inline-end: var(--sh-halfspace, 0.8rem);
inset-block-start: 0;
z-index: 2;
&:after {
display: block;
content: '*';
font-size: 1.6rem;
font-weight: var(--sh-font-weight-bold, 700);
line-height: 1.5;
color: var(--sh-red, #f31109);
}
}
.cr-input[required] ~ .cr-required {
display: inline-block;
}
.cr-feedback {
background-color: var(--sh-red, #f31109);
color: var(--sh-white, #ffffff);
margin-inline-start: 1rem;
margin-block-start: calc(-1 * var(--sh-halfspace, 1.6rem));
width: fit-content;
float: inline-start;
z-index: 100;
position: relative;
border-radius: var(--sh-radius, 3px);
padding-inline: var(--sh-halfspace, 0.8rem);
padding-block: calc(var(--sh-halfspace, 0.8rem) / 2);
white-space: nowrap;
display: none;
font-size: 85%;
}
/* floating label */
.cr-label {
position: absolute;
inset-block-start: var(--sh-halfspace, 0.8rem);
inset-inline-start: 9px;
padding: 0 3px;
color: var(--sh-text-light, #959595);
cursor: auto;
transform-origin: left;
transition: transform 0.3s;
transform: translateY(-100%) scale(0.8);
/* a touch to hide the border behind the floating label */
background-image: linear-gradient(0deg, var(--sh-white, #ffffff) 62%, transparent 62%);
}
/* when input is empty */
.cr-label:has(~ .cr-input:placeholder-shown) {
transform: none;
}
/* when input is focused */
.cr-label:has(~ .cr-input:focus-visible) {
transform: translateY(-100%) scale(0.8);
}
}
/* when sourunded by ng-submitted */
.ng-submitted {
.cr-field {
.cr-input.ng-invalid {
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) {
color: var(--sh-red, #f31109);
}
}
}
The form itself is simple:
this.fg = this.fb.group({
valvet: [''],
mars: ['']
});
The initial state of this component is coming together, find it on StackBlitz.
Next.
Validation
We now know how to make it required, the above was enough to make the form invalid
if submitted, and show the required balloon. Let’s see other built in validations. Next episode. 🥱
Did you see how useless touching an input was?
Top comments (0)