Angular de Custom Form
本記事は、Angular Advent Calendar 2021 17日目の記事です。
Angularには、公式が提供しているAngular Materialをはじめとして、高品質なフォーム用のコンポーネント群が多数あります。
しかし、そんな高品質なコンポーネントでも「足りない!」となることがあるのは紛れもない事実。そこで今回は、そんな時に「自分でカスタムフォームコンポーネントを作る」方法をご紹介します。
今回作るもの
今回は、例として「数字16桁を4桁ずつ区切って表示してくれるinput」を作成してみたいと思います。クレジットカード情報の入力とかで使えると思います。
サンプルをStackBlitzで実装しています。
Control Value Accessor
Control Value Accessor
をご紹介しておこうと思います。
公式曰く、「Angular Form APIとネイティブエレメントのDOMをつなぐためのInterface」であると説明されています。めっちゃわかりにくい。
例えば普通のMatInputの場合、
<input matInput [(ngModel)]="value" />
<input matInput [formControl]="form" />
のように、ngModel
かFormControl
をバインディングするかと思います。
ひとまず、子コンポーネントから、バインディングされたフォームの値を取得したり、子コンポーネントから、イベントを親に伝播するための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.disabled
、ngModel
の場合は、テンプレート上で指定した[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');
}
}
実装については、以上の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を実装します。
Validator
Interfaceには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