DEV Community

Cover image for Taming Angular Forms
Ayyash
Ayyash

Posted on • Originally published at garage.sekrab.com

Taming Angular Forms

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:

Angular form validated

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" />
Enter fullscreen mode Exit fullscreen mode

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 the form element if it exists, after submission by firing the submit 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('');
  }
}
Enter fullscreen mode Exit fullscreen mode

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 and pristine
  • If blurred it becomes touched even if no value changed
  • If required is set, it starts with invalid, 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();
  } 
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

To continue with the flow:

  • It starts with untouched and pristine and valid
  • It reflects the state of at least one form field that is touched dirty or invalid
  • 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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

We can make an enhancement by removing the style on focus

/* garnish, on focus remove red border */
.cr-input:focus {
    outline: 1px solid #007bff;
}
Enter fullscreen mode Exit fullscreen mode

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:

  • 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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }

}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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`);
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Image on Sekrab Garage

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The form itself is simple:

this.fg = this.fb.group({
  valvet: [''],
  mars: ['']
});
Enter fullscreen mode Exit fullscreen mode

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?

Resources

Using CSS to validate Angular reactive form - Sekrab Garage

Taming Angular Forms. 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:<ul><li>Use native HTML input.... Posted in Angular, Design

favicon garage.sekrab.com

Top comments (0)