🎁

Angular de Custom Form

2021/12/17に公開

本記事は、Angular Advent Calendar 2021 17日目の記事です。

Angularには、公式が提供しているAngular Materialをはじめとして、高品質なフォーム用のコンポーネント群が多数あります。

しかし、そんな高品質なコンポーネントでも「足りない!」となることがあるのは紛れもない事実。そこで今回は、そんな時に「自分でカスタムフォームコンポーネントを作る」方法をご紹介します。

今回作るもの

今回は、例として「数字16桁を4桁ずつ区切って表示してくれるinput」を作成してみたいと思います。クレジットカード情報の入力とかで使えると思います。

サンプルをStackBlitzで実装しています。

Control Value Accessor

https://angular.io/api/forms/ControlValueAccessor
カスタムフォームを作成するために、まずControl Value Accessorをご紹介しておこうと思います。

公式曰く、「Angular Form APIとネイティブエレメントのDOMをつなぐためのInterface」であると説明されています。めっちゃわかりにくい。

例えば普通のMatInputの場合、

<input matInput [(ngModel)]="value" />
<input matInput [formControl]="form" />

のように、ngModelFormControlをバインディングするかと思います。

ひとまず、子コンポーネントから、バインディングされたフォームの値を取得したり、子コンポーネントから、イベントを親に伝播するためのInterfaceであると理解すればよいです。

このInterfaceには以下の4つの関数が定義されています。それぞれ説明しながら実装を進めていきたいと思います。

interface ControlValueAccessor {
  writeValue(obj: any): void
  registerOnChange(fn: any): void
  registerOnTouched(fn: any): void
  setDisabledState(isDisabled: boolean)?: void
}

実装していく

1. カスタムフォーム用のComponentを作る

まずは、カスタムフォーム用のComponentを作成します。
ネイティブな見た目のInputで作成してももちろん良いですが、今回は見た目のためにAngular MaterialのInputをラップするような形で作っていきたいと思いますので、以下のように実装します。

<mat-form-field>
  <input matInput placeholder="カスタム" />
</mat-form-field>

比較用にふつうのInputも用意しました。
スクリーンショット

2. Control Value Accessorの関数をComponent Classに実装する

先ほど説明したControl Value Accessorの各関数をComponent Classに実装していきます。

まず、Componentのproviders配列にNG_VALUE_ACCESSORを追加します。

@Component({
  selector: 'app-custom-fc',
  templateUrl: './custom-fc.component.html',
  styleUrls: ['./custom-fc.component.css'],
  providers: [
    // ここから
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => CustomFcComponent),
      multi: true,
    },
    // ここまで
  ],
})
export class CustomFcComponent implements OnInit {
  constructor() {}

  ngOnInit() {}
}

ClassはControlValueAccessorをimplementsするように変更します。

export class CustomFcComponent implements ControlValueAccessor {

ControlValueAccessorに定義されている以下の関数をとりあえず書いておきます。

writeValue(obj: any): void {
  throw new Error('Method not implemented.');
}
registerOnChange(fn: any): void {
  throw new Error('Method not implemented.');
}
registerOnTouched(fn: any): void {
  throw new Error('Method not implemented.');
}
setDisabledState(isDisabled: boolean): void {
  throw new Error('Method not implemented.');
}

3. カスタムフォーム用コンポーネントにFormControlを実装する

カスタムフォーム用コンポーネントの中でのみ使う、FormControlを実装していきます。

export class CustomFcComponent implements OnInit, ControlValueAccessor {
  readonly childControl = this.fb.control('');

  constructor(private readonly fb: FormBuilder) {}

  // ...略
}
<mat-form-field>
  <input matInput placeholder="カスタム" [formControl]="childControl" />
</mat-form-field>

特に何の変哲もないFormControlですが、ここからControl Value Accessorとの合わせ技で、親コンポーネントと連携するようにしていきます。

4. 親コンポーネントとと連携するように各関数を実装する

Control Value Accessorで定義されている4つの関数は、それぞれ以下のような役割を持っています。

writeValue(val: any)

この関数が実行された際、バインディングされたngModelまたはFormControlで設定されている現在の値がval引数に入ってきます。
実行タイミングは「バインディングされたngModelまたはFormControlの値が変更されたとき」です。
バインディングされたngModelまたはFormControl上で変更された値を、子コンポーネントのFormに反映するために用います。

writeValue(numStr: string): void {
  this.childControl.setValue(numStr, { emitEvent: false });
}

オプションとしてemitEvent: falseを設定していますが、この理由については後述します。

さて、動作確認のため、親コンポーネントで1秒ごとにカウントアップした値をFormControlにセットするようにしました。

export class AppComponent {
  name = 'Angular ' + VERSION.major;
  form = this.fb.control('');

  constructor(private readonly fb: FormBuilder) {
    let count = 0;
    const countUp = () => {
      this.form.setValue(count++);
    };
    setInterval(countUp, 1000);
  }
}

このFormControlをカスタムフォーム用コンポーネントにバインディングします。
スクリーンショット
1秒ごとに数字が増えていくのを確認できました。

registerOnChange(fn: any)

カスタムフォーム用コンポーネントのOnChangeイベントを発行する際に実行する関数が引数として入ります。
先ほどのwriteValue()とは逆の役割となり、カスタムフォーム用コンポーネントのFormが変更されたときに、バインディングされたngModelまたはFormControlへ値を送るために用います。
実行タイミングは、カスタムフォーム用コンポーネントがインスタンス化されたときの1回のみです。

具体的には以下のような実装になります。

registerOnChange(fn: any): void {
  this.childControl.valueChanges.subscribe(fn);
}

先ほどのwriteValue()時にemitEvent: falseを指定したのは、ここでvalueChangesイベントを購読しているために、「親で値が変更→子にセット→子の値が変更→親にセット→親で値が変更→…以下無限ループ」となるのを防ぐためです。

動作確認のため、親コンポーネントでFormControlの値を確認できるように実装します。

export class AppComponent {
  name = 'Angular ' + VERSION.major;
  form = this.fb.control('');

  constructor(private readonly fb: FormBuilder) {
    this.form.valueChanges.subscribe(console.log)
  }
}

スクリーンショット
スクリーンショット
入力した値がコンソールに表示されていますね!

registerOnTouched(fn: any)

これも先ほどのregisterOnChange()と同じような関数で、イベントがOnTouchedに変わったものです。
カスタムフォーム用コンポーネントのFormに何らかの操作を加えたときに、バインディングされたngModelまたはFormControlの操作状況を変更するために用います。
このイベントで変更されるFormControl.touchedはエラーメッセージの表示可否の条件となっていたりします。
実行タイミングは、カスタムフォーム用コンポーネントがインスタンス化されたときの1回のみです。

onTouchedChangesみたいな便利なEventEmitterはないので、一度インスタンスプロパティに引数fnを保存したうえで、テンプレート上で(blur)イベント時に保存した関数を実行するようにします。

export class CustomFcComponent implements OnInit, ControlValueAccessor {

  // ...略
  
  onTouched: any;
  
  // ...略
  
  registerOnTouched(fn: any): void {
    this.onTouched = fn;
  }
}
<mat-form-field>
  <input
    matInput
    placeholder="カスタム"
    [formControl]="childControl"
    (blur)="onTouched()"
  />
</mat-form-field>

動作確認は、親コンポーネント上でvalueChangesイベントが発生したときに、touchedプロパティがtrueになっているか、で行います。

export class AppComponent {
  name = 'Angular ' + VERSION.major;
  form = this.fb.control('');

  constructor(private readonly fb: FormBuilder) {
    this.form.valueChanges.subscribe(() => {
      console.log('parent', this.form.touched);
    });
  }
}

スクリーンショット
一度フォーカスを外してから値を入力すると、親コンポーネントのtouchedプロパティがtrueになっていることが確認できます。

setDisableState(isDisabled: boolean)

バインディングされたdisabledの値が引数に入ってきます。FormControlの場合は、FormControl.disabledngModelの場合は、テンプレート上で指定した[disabled]の値です。

ですので、子コンポーネントのFormのdisabled状況を変更するために用います。

具体的には以下の実装になります。

setDisabledState(isDisabled: boolean): void {
  isDisabled ? this.childControl.disable() : this.childControl.enable();
}

親コンポーネントのFormでdisabled: trueとすると、カスタムフォーム用コンポーネントも無効化されます。

form = this.fb.control({ value: '', disabled: true });

スクリーンショット

ここまでで、バインディングされたFormとの連携のための実装は完了しました。

5. 桁区切りする

今回やりたいこととしては、以下のような感じです。

バインディングされたFormに値がセットされる
↓
桁区切りする
↓
カスタムフォーム用コンポーネントのFormにセットする

カスタムフォーム用コンポーネントのFormに値が入力される
↓
桁区切りする
↓
バインディングされたFormには桁区切りされていない文字列をセットしたい

何はともあれ桁区切りを行う関数を実装しましょう。

@ViewChild('input', { static: true }) input: ElementRef<HTMLInputElement>;

// ...略

stringSpacing(): void {
  const input = this.input.nativeElement;
  const { selectionStart } = input;
  const form = this.childControl;

  if (!form.value) {
    form.setValue('', { emitEvent: false });
    return;
  }

  let trimmed = form.value.replace(/([^0-9]|\s)+/g, '');

  if (trimmed.length > 16) {
    trimmed = trimmed.substr(0, 16);
  }

  const partitions = [4, 4, 4, 4];

  const numbers = [];
  let position = 0;
  partitions.forEach((partition) => {
    const part = trimmed.substr(position, partition);
    if (part) {
      numbers.push(part);
    }
    position += partition;
  });

  form.setValue(numbers.join(' '), { emitEvent: false });

  if (selectionStart < form.value.length - 1) {
    input.setSelectionRange(selectionStart, selectionStart, 'none');
  }
}

https://stackoverflow.com/a/61829121
実装については、以上のStackOverFlowのコメントを参考にしました。

そして、writevalue()registerOnChange()関数に処理を付け足します。

writeValue(numStr: string): void {
  this.childControl.setValue(numStr, { emitEvent: false });
  this.stringSpacing();
}

registerOnChange(fn: any): void {
  this.childControl.valueChanges
  .pipe(
    tap(() => {
      this.stringSpacing();
    }),
    map((v) => v.replace(/[^0-9]/g, ''))
  )
  .subscribe(fn);
}

スクリーンショット
スクリーンショット
見た目上では桁区切りされていますが、バインディングされたフォームのvalueは桁区切りされていない文字列がセットされていることが確認できました!

6. バリデーション

ここまでで、桁区切りするフォームのコンポーネントは一通り動作するようになりましたが、まだバリデーションに引っかかった時の処理を書いていません。

実装するには、Component ClassにValidatorというInterfaceを実装します。
https://angular.jp/guide/form-validation#カスタムバリデーターをテンプレート駆動フォームに追加する

ValidatorInterfaceにはvalidate()registerOnValidatorChange()という2つの関数が定義されていますが、今回はvalidate()のみ実装します。

validate()関数の返り値はValidationErrorsとなっており、バリデーションに引っかかっていなければnull、引っかかっていれば{[key: string]: true}を返すものとなります。
自分で条件を定義してもよいのですが、今回はバインディングされたFormに設定されているValidatorFnを、そのままカスタムフォーム用コンポーネントのFormにセットすることにしました。

@Component({
  // ...略
  providers: [
    // ...略
    {
      provide: NG_VALIDATORS,
      useExisting: forwardRef(() => CustomFcComponent),
      multi: true,
    },
  ],
})
export class CustomFcComponent
  implements OnInit, ControlValueAccessor, Validator
{
  // ...略
  
  constructor(
    private readonly fb: FormBuilder,
    private readonly cd: ChangeDetectorRef,
    private readonly injector: Injector
  ) {}

  ngOnInit() {
    const ngControl = this.injector.get(NgControl, null);
    if (ngControl) {
      const parentControl = ngControl.control as AbstractControl;
      this.childControl.setValidators(parentControl.validator);
    }
  }

  // ...略

  validate(control: AbstractControl): ValidationErrors {
    return this.childControl.errors;
  }

  // ...略
}

親コンポーネントのFormは以下の通り。

form = this.fb.control({ value: '', disabled: false }, [Validators.required]);

スクリーンショット
スクリーンショット
ちゃんとバリデーションエラーも反映されています。

ちなみにsetValidators()ではなくaddValidators()に変更した場合、カスタムフォーム用コンポーネントのFormで設定したValidatorFnもしっかり効きます。

readonly childControl = this.fb.control('', [Validators.min(10)]);

// ...略

ngOnInit() {
  const ngControl = this.injector.get(NgControl, null);
  if (ngControl) {
    const parentControl = ngControl.control as AbstractControl;
    this.childControl.addValidators(parentControl.validator);
  }
}

スクリーンショット

最後に、<ng-content>を用いて任意のエラーメッセージを親コンポーネントから注入できるようにします。

<mat-form-field>
  <input
    #input
    matInput
    placeholder="カスタム"
    [formControl]="childControl"
    (blur)="onTouched()"
  />
  <mat-error>
    <ng-content select="[error]"></ng-content>
  </mat-error>
</mat-form-field>
<app-custom-fc [formControl]="form">
  <ng-container error>
    <ng-container *ngIf="form.hasError('required')">必須項目</ng-container>
  </ng-container>
</app-custom-fc>

スクリーンショット

以上で、カスタムフォーム用コンポーネントは完成です!

あとがき

作ってみると意外とシンプルなので、アイデア次第で結構いろいろな応用が利きそうです。
社内共通のコンポーネントとして利用したり、はたまたnpmライブラリとして公開してみたりといろいろできると思いますので、皆さんも気軽にチャレンジしてみてはいかがでしょうか。

Discussion