Validating Angular Material Chips - Tags
Tags: angular, material, tags, chips
Published: 2021-10-24 20:02:42

Validating tags in Angular Material does not work as it should out-of-the-box, so I am sharing my custom version. This assumes you are familiar with basic angular reactive forms, and are using [Angular Material Chips](https://material.angular.io/components/chips/overview) to handle your tags input.

Here you will be able to produce `min` and `required` errors, as well as display them in `mat-error` as expected out-of-the-box. Unfortunately, Angular does not work this way out-of-the-box.

**tags.service.ts**
```typescript
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { Injectable } from '@angular/core';
import { 
AbstractControl, 
FormArray, 
FormBuilder, 
ValidationErrors, 
ValidatorFn 
} from '@angular/forms';
import { 
MatChipInputEvent, 
MatChipList 
} from '@angular/material/chips';

export interface Tag {
  name: string;
}

@Injectable({
  providedIn: 'root'
})

/**
 * A bunch of tools for dealing with Tags
 */
export class TagService {

  visible = true;
  selectable = true;
  removable = true;
  addOnBlur = true;

  readonly separatorKeysCodes = [ENTER, COMMA];

  constructor(private fb: FormBuilder) { }

  /**
   * Get tags from form control
   * @param _tags tags
   */
  getTags(control: FormArray): string[] {
    let tags = [];
    for (let i = 0; i < control.length; ++i) {
      tags.push(control.at(i).value);
    }
    return tags;
  }

  /**
   * Adds an array of tags to a form control
   * @param tags
   * @param control
   */
  addTags(tags: any, control: FormArray): void {

    if (tags) {
      // add new tags
      for (let i = 0; i < tags.length; ++i) {
        this.addTag(this.tagFormat(tags[i]), control);
      }
    }
  }

  /**
   * Adds a tag to the form control
   * @param tag
   * @param control
   */
  addTag(tag: string, control: FormArray): void {
    control.push(this.fb.control(tag));
  }

  /**
   * Adds a tag to the form field
   * Necessary for MatChips
   * @param event - mat chip input event
   * @param tags - tags array
   */
  add(
    event: MatChipInputEvent, 
    control: FormArray, 
    chipList: MatChipList
  ): void {
    // add tag from keyboard
    const input = event.chipInput;
    const value = event.value;

    // Get rid of duplicates
    if (!(control.value as string[]).includes(value)) {

      // Add tag to new form group
      if (value.trim()) {
        const newVal = this.tagFormat(value);
        if (newVal) {
          this.addTag(newVal, control);
        }
      }
    }

    // Reset the input value
    if (input) {
      input.clear();
    }

    // update chip error state
    chipList.errorState = control.status === 'INVALID';
  }

  /**
   * Removes the tag from the form field
   * Necessary for MatChips
   * @param tag tag
   * @param tags - tags array
   */
  remove(
    index: number,
    control: FormArray,
    chipList: MatChipList
  ): void {

    control.removeAt(index);

    // update chip error state
    chipList.errorState = control.status === 'INVALID';
  }

  /**
   * Required Validator for tags
   * @param control
   * @returns
   */
  tagValidatorRequired(control: AbstractControl): 
    ValidationErrors | null {
    return (control.value && control.value.length === 0)
      ? { required: true }
      : null;
  };

  /**
   * Required Validator for tags
   * @param control
   * @returns
   */
  tagValidatorMin(min: number): ValidatorFn {
    return (control: AbstractControl): 
      ValidationErrors | null => {
      return (control.value && control.value.length > min)
        ? { min: true }
        : null;
    }
  };

  /**
   * Format a tag in db for viewing
   * @param tag
   * @returns
   */
  tagFormat(tag: string): string {
    // can't begin with number or contain only number, no dashes
    return this.slugify(tag)
    .replace(/-*/g, '').replace(/^\d+/, '');
  }

  /**
   * Create a slug from a string
   * @param value
   * @returns
   */
  slugify(value: string): string {
    return value
      .split('-').join(' ')
      .normalize('NFD')
      .replace(/[\u0300-\u036f]/g, '')
      .toLowerCase()
      .trim()
      .replace(/[^a-z0-9 ]/g, '') 
      .replace(/\s+/g, '-')
  }
}
```

To use the validators, add the tag validators just like you add the other validators, but to a sub form group:

**component constructor**
```typescript
public ts: TagService
```

**in your form creation of myForm**
```typescript
this.fb.group({
  title: ['', [
    Validators.required, 
    Validators.minLength(2)]
  ],
  content: ['', [
    Validators.required,
    Validators.minLength(3)]
  ],
  tags: this.fb.array([], [
    ts.tagValidatorMin(5), 
    ts.tagValidatorRequired])
});
```

You can patch your tag values in just like anything else:

```typescript
this.db.getFormData().pipe(
  tap((data: any) => {
    if (data) {
    ... // add other data

    // add tags
    const field = this.myForm.get('tags');
    this.ts.addTags(data.tags, field);

    ...
   }
 })
);
```

and the component.html

```html
<mat-form-field class="chip-list">
  <mat-chip-list #chipList aria-label="Tag selection">
    <mat-chip *ngFor="let tag of tagsField.controls; 
    let i = index" [selectable]="ts.selectable"
    [removable]="ts.removable" (removed)=
    "ts.remove(i, tagsField, chipList)">
      {{ tag.value }}
    <mat-icon matChipRemove *ngIf="ts.removable">
    cancel
    </mat-icon>
    </mat-chip>
    <input placeholder="Tags" [matChipInputFor]="chipList"
    [matChipInputSeparatorKeyCodes]="ts.separatorKeysCodes"
    [matChipInputAddOnBlur]="ts.addOnBlur" 
    (matChipInputTokenEnd)="ts.add($event, tagsField, chipList)"
    formArrayName="tags" />
  </mat-chip-list>
  <mat-error *ngIf="<!--check for errors here-->">
    <!-- errors are 'required' and 'min' -->
  </mat-error>
</mat-form-field>
```

The service handles everything, so you don't have to think about chips, and it is reusable in multiple components. You can see you can easily modify the code for `max` or any other validator you can think of.

And you can actually use your tags form in Angular with errors.

J