DEV Community

loading...
Cover image for Building a Reusable MultiCheck Field in Angular

Building a Reusable MultiCheck Field in Angular

Camilo Muñoz
There is no try. Do... or do not.
Updated on ・8 min read

Cover photo by Alev Takil on Unsplash

It's quite common to have forms where the user can select many options from several available:

Alt Text

The most popular way to tackle this in Angular is by using a set of <input type="checkbox"> with a FormArray. However, when the same functionality is required in several forms across the application, it's highly possible we start repeating lots of code, for both the logic and the markup.

In this post we will address this issue by building a component that has the following features:

  • MultiCheck: several options can be selected simultaneously

  • Reusable: the options can be presented with different visual implementations without re-writing the logic

  • Custom Field: tailored form field that works directly with Angular Forms

Once we are done, we could use the component to build forms that behave like this:

Alt Text

Table of Contents

  • Design

  • Step 1: Supporting a SimpleCheckOption Component

  • Step 2: Supporting Any Kind of Option Component

  • Step 3: Integration with Angular Forms

  • Final Words

  • Demo

  • Further Improvement

  • Code Repository Links

Design

Our component will be composed by two elements:

  1. The field component, which keeps track of the selected options and provides the integration with AngularForms.

  2. The option component, which represents a single check option and provides the visual implementation for it. The idea is that we have several of this kind.

Step 1: Supporting a SimpleCheckOption Component

We will start by supporting only a simple-check-option by our multi-check-field, but keeping in mind that we want the field to be used with any option component.

That being said, we'll use Content Projection to provide the desired options to the multi-check-field, like this:

<multi-check-field>
    <simple-check-option *ngFor="let option of options" [value]="option"
      [label]="option.label">
    </single-check-option>
</multi-check-field>

Note how Content Projection is used by passing the options inside the enclosing tags of the multi-check-field.

Now, let's see the implementation of the simple-check-option:

@Component({
  selector: 'simple-check-option',
  template: `
    <label>
      <input type="checkbox" [formControl]="control">
      {{ label }}
    </label>
  `
})
export class SimpleCheckOptionComponent {

  @Input() value: any;
  @Input() label: string;

  public control = new FormControl(false);

  get valueChanges$(): Observable<boolean> {
    return this.control.valueChanges;
  }

}

The component has a standard <input type="checkbox"> with it's label. We also declare a FormControl to manipulate the checkbox value and, additionally, we provide a valueChanges$ accessor so we can interact with the component with type safety from the outside.

The multi-check-field component will use the ContentChildren decorator to query the projected options:

@Component({
  selector: 'multi-check-field',
  template: `<ng-content></ng-content>`
})
export class MultiCheckFieldComponent implements AfterContentInit {

  @ContentChildren(SimpleCheckOptionComponent)
  options!: QueryList<SimpleCheckOptionComponent>;

  ngAfterContentInit(): void {
    // Content query ready
  }

}

It's worth to be noted that the content query will first be ready to be used in the AfterContentInit lifecycle, but not before. Additionally, see how we use the <ng-content> tags in the component's template to render there the provided content (the options).

Now, let's see how we keep track of the selected options

private subscriptions = new Subscription();
private selectedValues: any[] = [];

ngAfterContentInit(): void {
  this.options.forEach(option => {
    this.subscriptions.add(
      option.valueChanges$.subscribe(
        (optionChecked) => {
          if (optionChecked) {
            this.add(option.value);
          } else {
            this.remove(option.value);
          }
        }
      )
    );
  });
}

private add(value: any): void {
  this.selectedValues.push(value);
}

private remove(value: any): void {
  const idx = this.selectedValues.findIndex(v => v === value);
  if (idx >= 0) {
    this.selectedValues.splice(idx, 1);
  }
}

We use the option's valueChanges$ accessor to subscribe to the event when an option is checked/unchecked. Depending on the optionChecked boolean value, we then proceed to add or remove this option from our selectedValues array.

Alt Text

At this point, our multi-check-field is fully integrated with the simple-check-option. But we should take advantage of Angular's Content Projection to be able to support any kind of component as a check-option. Let's see how.

Step 2: Supporting Any Kind of Option Component

Let's create a new option component that looks very different to the simple-check-option but has the same functionality. We'll name it user-check-option and it will represent... well, an user 😅.

Alt Text

The component logic is basically the same that we have in simple-check-option, but the template has considerable differences:

@Component({
  selector: 'user-check-option',
  template: `
    <label>
      <input type="checkbox" [formControl]="control">
      <div class="card">
        <div class="avatar">
          <img src="assets/images/{{ value.avatar }}">
          <div class="span"></div>
        </div>
        <h1>{{ value.name }}</h1>
        <h2>{{ value.location }}</h2>
      </div>
    </label>
  `
})
export class UserCheckOptionComponent {

  @Input() value: any;

  public control = new FormControl(false);

  get valueChanges$(): Observable<boolean> {
    return this.control.valueChanges;
  }

}

To support our new user-check-option by the field component, we have to modify the ContentChildren query, given that we are not targeting exclusively a SimpleCheckOption anymore. This is the query we currently have:

@ContentChildren(SimpleCheckOptionComponent)
options!: QueryList<SimpleCheckOptionComponent>;

Unfortunately, we cannot use ContentChildren to target two different kind of components, but we can use the power of Angular's Dependency Injection (DI) to overcome this situation.

Dependency Injection to the Rescue 👨‍🚒 👩‍🚒 🚒

One possible solution for this issue would be to use alias providers to create a common DI token to be employed by our option components.

abstract class MultiCheckOption { }                        // (1)

@Component({
  selector: 'simple-check-option',
  providers: [
    {                                                      // (2)
      provide: MultiCheckOption,
      useExisting: SimpleCheckOptionComponent,
    }
  ]
})
export class SimpleCheckOptionComponent { ... }

@Component({
  selector: 'user-check-option',
  providers: [
    {                                                      // (3)
      provide: MultiCheckOption,
      useExisting: UserCheckOptionComponent
    }
  ]
})
export class UserCheckOptionComponent { ... }
  1. We start by creating a MultiCheckOption class to be used as DI token by our option components.

  2. We configure the injector at the component level of our SimpleCheckOptionComponent by using the providers metadata key. With this configuration, when Angular's DI ask our component's injector for an instance of MultiCheckOption, it would pass the existing instance of the component itself.

  3. We do the same for the UserCheckOptionComponent.

The ContentChildren query could now be rewritten as:

@ContentChildren(MultiCheckOption)
options!: QueryList<MultiCheckOption>;

But we are not finished yet... at this point we lost access to the members and methods of the option components, since the MultiCheckOption class is empty. We can fix this by using the class itself to hold what is common among the options and expose what necessary. After that, we take advantage of ES6 class inheritance to extend the option components from MultiCheckOption.

export abstract class MultiCheckOption {
  abstract value: any;
  public control = new FormControl(false);
  get valueChanges$(): Observable<boolean> {
    return this.control.valueChanges;
  }
}

@Component(...)
export class SimpleCheckOptionComponent extends MultiCheckOption {
  @Input() value: any;
  @Input() label: string;
}

@Component(...)
export class UserCheckOptionComponent extends MultiCheckOption {
  @Input() value: any;
}

And just like that, the multi-check-field supports now any component that implements the MultiCheckOption logic.

Step 3: Integration with Angular Forms

At this stage, you might try to use the multi-check-field with Angular Forms

<multi-check-field formControlName="subjects">
    ...
</multi-check-field>

But then, you will get the following error:

No value accessor for form control with name: 'subjects'

The reason is, the AngularFormsModule only knows how to deal with native form elements (like <input> and <select>). In order for our custom multi-check-field to work with Angular Forms, we'll have to tell the framework how to communicate with it. (If this is the first time you hear about custom form fields in Angular, I would recommend you to check this post.

1. The NG_VALUE_ACCESSOR Provider

We start by registering the component with the global NG_VALUE_ACCESSOR provider:

import { Component, forwardRef } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'multi-check-field',
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => MultiCheckFieldComponent),
      multi: true
    }
  ]
})
export class MultiCheckFieldComponent { ... }

2 . The ControlValueAccesor Interface

Additionally, we need to implement the ControlValueAccesor interface, which defines the following set of methods to keep the view (our component) and the model (the form control) in sync.

writeValue(obj: any): void;
registerOnChange(fn: any): void;
registerOnTouched(fn: any): void;
setDisabledState?(isDisabled: boolean): void;

writeValue(obj: any)

This function is executed by the framework to set the field value from the model to the view. For example, when performing any of the following actions.

multiCheckControl = new FormControl(TEST_INITIAL_VALUE);
multiCheckControl.setValue(TEST_VALUE);
multiCheckControl.patchValue(TEST_VALUE);

In our case, the obj parameter should be an array containing the selected options values. We better name it values for improved readability.

writeValue(values: any[]): void {
    this.selectedValues = [];
    values = values || [];
    values.forEach(selectedValue => {
      const selectedOption = this.options.find(v => v.value === selectedValue);
      selectedOption.control.setValue(true);
    });
}

Each item of the values array is mapped to the corresponding option, and then the checked value is reflected in it's view (in our example, this is done yet through another control).

Note that every time we call selectedOption.control.setValue(), the corresponding valueChanges$ subscription declared in ngAfterContentInit is called and the option's value gets added to the local selectedValues array.

Let's see it working

@Component({
  selector: 'app-root',
  template: `
    <multi-check-field [formControl]="multiCheckControl">
      <simple-check-option *ngFor="let subject of subjects"
        [value]="subject" [label]="subject.label">
      </simple-check-option>
    </multi-check-field>
    <button (click)="setTestValue()">Set Test Value</button>
    Control value: <pre>{{ multiCheckControl.value | json }}</pre>
  `,
})
export class AppComponent {

  public subjects = [
    { code: '001', label: 'Math' },
    { code: '002', label: 'Science' },
    { code: '003', label: 'History' },
  ];

  public multiCheckControl = new FormControl();

  setTestValue() {
    const testValue = [this.subjects[0], this.subjects[1]];
    this.multiCheckControl.setValue(testValue);
  }

}

Alt Text

registerOnChange(fn: any)

Registers the function that needs to be called when the field value changes in the UI. When the provided function is called, it will update the value from the view to the model.

In our case, we have to update the model value every time an option is checked/unchecked.

export class MultiCheckFieldComponent implements ControlValueAccessor {

  _onChange: (_: any) => void;

  registerOnChange(fn: any): void {
    this._onChange = fn;
  }

  private add(value: any): void {
    this.selectedValues.push(value);
    this._onChange(this.selectedValues);
  }

  private remove(value: any): void {
    const idx = this.selectedValues.findIndex(v => v === value);
    if (idx >= 0) {
      this.selectedValues.splice(idx, 1);
      this._onChange(this.selectedValues);
    }
  }
  ...
}

Alt Text

registerOnTouched(fn: any)

In the same way as the previous method, we need to register the function to be called when the field is touched, in order for the control to trigger validation and more.

We will leave the implementation of this method out of the scope of this tutorial.

setDisabledState?(isDisabled: boolean)

Last but no least, the setDisabledState method. This function is called when the field is enable/disabled programmatically. For example, when the following actions are performed:

multiCheckControl = new FormControl({
  value: TEST_INITIAL_VALUE,
  disabled: true
});
multiCheckControl.disabled();
multiCheckControl.enabled();

This method will also be left out of the scope of the tutorial.

Final Words

We managed to create a component that provides a multi-check functionality but also offers:

  • Reducing of code duplication, given that all the logic is encapsulated within the component and doesn't need to be re-written for every form.

  • Simplicity, since the usage is pretty straightforward. Very similar to a native <select> with <option> tags inside.

  • Reusability, because the options can be styled as desired.

  • Compatibility, considering that it supports integration with Angular Forms.

Demo Time 🌋

Further Improvement

There is still a lot of room for improvement. I list here some ideas in case you want to code a bit. Don't hesitate to open a PR to integrate your solution to the repository:

  • Support a value passed on initialization (writeValue executed before ngAfterContentInit) ✅

  • Support changes in the projected options (when they are added or removed from DOM)

  • Support registerOnTouched and setDisableState methods

  • Write a minValuesLength and maxValuesLength validators

  • Support passing a template as an option instead of a component

Code Repository Links

  • The full source code can be found here

  • In this branch, you can find the implementation for some of the improvements suggested above

Discussion (2)

Collapse
acnicolasdc profile image
Nicolas Reyes

Amazing !

Collapse
gitsobek profile image
Piotr Sobuś

Very good article.