angularangular-formscontrolvalueaccessor

A custom angular two-state checkbox component issue


I'm trying to create a custom checkbox component to replace an old-old NG Prime version that had been being used but wanted to make it leaner/cleaner with some added aria points and utilizing more of the checkbox attributes directly. The problem I'm running into though (I'm guessing) is with updating the value accessor / ngmodel and could use some guidance on what I'm apparently missing...

I thought what I had was simplistic enough but apparently not. The idea is that @Input() binary is set to true by default expecting ngModel to have a boolean to use but I don't get the value from ngModel when it's initialized. I need ngModel to communicate that to the component though when it's used standalone as like <app-checkbox [ngModel]="blah"></app-checkbox> but also have value_accessor working as expected if it's used in say a reactive angular form etc.

I also get a "NG0303: Can't bind to 'checked' since it isn't a known property of 'app-checkbox'" in my instance, but it's not on the stackblitz which I don't understand.

👉 A Stackblitz showing the implementation / problem

(the example is angular 12, I use 13, but it's fine for the example)

And for quick code reference;

.ts

import {
  Component,
  OnInit,
  Input,
  Output,
  EventEmitter,
  forwardRef,
  ViewChild,
  ElementRef,
  ChangeDetectorRef,
  ChangeDetectionStrategy,
} from '@angular/core';
import {
  NG_VALUE_ACCESSOR,
  ControlValueAccessor,
  FormControl,
} from '@angular/forms';
import { ObjectUtils } from '../objectUtils';

export const CHECKBOX_VALUE_ACCESSOR: any = {
  provide: NG_VALUE_ACCESSOR,
  useExisting: forwardRef(() => CheckboxComponent),
  multi: true,
};

@Component({
  selector: 'app-checkbox',
  templateUrl: './checkbox.component.html',
  styleUrls: ['./checkbox.component.css'],
  providers: [CHECKBOX_VALUE_ACCESSOR],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CheckboxComponent implements OnInit, ControlValueAccessor {
  @Input() value: any;
  @Input() name: string;
  @Input() disabled: boolean;

  @Input() label: string;
  @Input() ariaLabelledBy: string;
  @Input() ariaLabel: string;
  @Input() tabindex: number;
  @Input() id: string;
  @Input() labelStyleClass: string;
  @Input() formControl: FormControl;
  @Input() required: boolean = false;
  @Input() isValid: boolean = true;
  @Input() invalidMessage: string;

  // This is set true by default now since every instance at the time this replacement component was made requires it for a boolean value bound to ngModel
  // If in the future we start using angular reactive forms more it can be toggled at the instance.
  @Input() binary: boolean = true;

  @ViewChild('cb') inputViewChild: ElementRef;

  @Output() onChange: EventEmitter<any> = new EventEmitter();

  isChecked: boolean = false;

  focused: boolean = false;
  model: any;
  onModelChange: Function = () => {};
  onModelTouched: Function = () => {};

  constructor(private cd: ChangeDetectorRef) {}

  ngOnInit(): void {
    this.id = this.id
      ? this.id
      : `tcl-cb-${Math.random()
          .toString()
          .substr(2, length ? length : 6)}`;
  }

  updateModel(event) {
    let newModelValue;

    if (!this.binary) {
      if (this.isChecked) {
        newModelValue = this.model.filter(
          (val) => !ObjectUtils.equals(val, this.value)
        );
      } else {
        newModelValue = this.model ? [...this.model, this.value] : [this.value];
      }

      this.onModelChange(newModelValue);
      this.model = newModelValue;

      if (this.formControl) {
        this.formControl.setValue(newModelValue);
      }
    } else {
      newModelValue = this.isChecked;
      this.model = newModelValue;
      this.onModelChange(newModelValue);
    }

    this.onChange.emit({ checked: newModelValue, originalEvent: event });
  }

  handleChange(event) {
    this.isChecked = event.srcElement.checked;
    if (!this.disabled) {
      this.updateModel(event);
    }
  }

  onFocus() {
    this.focused = true;
  }

  onBlur() {
    this.focused = false;
    this.onModelTouched();
  }

  focus() {
    this.inputViewChild.nativeElement.focus();
  }

  writeValue(model: any): void {
    this.model = model;
    this.cd.markForCheck();
  }

  registerOnChange(fn: Function): void {
    this.onModelChange = fn;
  }

  registerOnTouched(fn: Function): void {
    this.onModelTouched = fn;
  }

  setDisabledState(val: boolean): void {
    this.disabled = val;
    this.cd.markForCheck();
  }
}

html

<div role="group" class="app-checkbox">
  <input
    #cb
    type="checkbox"
    [attr.id]="id"
    [attr.name]="name"
    [disabled]="disabled"
    [value]="value"
    [checked]="isChecked"
    [attr.tabindex]="tabindex"
    [attr.required]="required"
    [attr.aria-labelledby]="ariaLabelledBy"
    [attr.aria-label]="ariaLabel"
    [attr.aria-checked]="isChecked"
    (focus)="onFocus()"
    (blur)="onBlur()"
    (change)="handleChange($event)"
  />
  <label *ngIf="label" [attr.for]="id" [class]="labelStyleClass">
    {{ label }} <sup *ngIf="required">*</sup>
  </label>
  <div *ngIf="!isValid" class="tcl-checkbox-invalid-msg">
    {{ invalidMessage }}
  </div>
</div>

Any insight is appreciated especially if there's anything additional being forgotten!


Solution

  • I need ngModel to communicate that to the component though when it's used standalone as like <app-checkbox [ngModel]="blah"> but also have value_accessor working as expected if it's used in say a reactive angular form etc.

    In your case ngModel does communicate the value to your custom checkbox component, but you are not using the received value within your template to bind it to the input.

    When using

    <app-checkbox [ngModel]="isChecked"></app-checkbox>
    

    or

    <!-- If isChecked is FormControl name -->
    <app-checkbox formControlName="isChecked"></app-checkbox>
    

    the writeValue method is called passing it the form control value.

    Within writeValue method you are setting this.model, but in your template you are using isChecked to bind to input checked property, and hence the checkbox isn't checked. You need to bind the value received from forms module to the correct property within your custom component, in your case it would be:

    // within writeValue method
    this.isChecked = model;
    

    I also get a "NG0303: Can't bind to 'checked' since it isn't a known property of 'app-checkbox'" in my instance, but it's not on the stackblitz which I don't understand.

    If you are getting the above error when doing something as below, then that's a valid error which you will also get in your stackblitz example. The reason being, CheckboxComponent doesn't have any input property named checked.

    <app-checkbox [checked]="isChecked"></app-checkbox>