Angular MaterialのFormFieldに独自の入力コントロールを配置する
Angular Materialには MatFormField
というマテリアルデザインの入力コントロール表示を支援するコンポーネントがあります。こんな見た目のヤツです。
Angular Material標準では
- テキスト入力(1行)
- テキスト入力(複数行)
- ドロップダウン
の3つがサポートされています。ここに更に独自の入力コンポーネントをサポートさせる方法を紹介します。
前提条件
この記事は以下のAngularバージョンで作成されています。
- Angular CLI@6.0.9
- Angular@6.0.9
- Angular Material@6.4.0
カスタムフォームコントロールの作成
カスタムフォームコントロールの作成方法はライブラリの公式サイトに方法が記載されていますので、何をすべきか1つずつ、テキスト入力コントロールを再実装する形で解説していきます。
先にまとめると MatFormFieldControl
を実装したクラスを作成してProvideするだけになります。
Creating a custom form field control | Angular Material
MatFormFieldControlのプロパティ
MatFormFieldControlを実装すると言っても、これにはいくつかのプロパティが存在し、要求された通りに実装する必要があります。まずはMatFormFieldControlにどんなプロパティが存在するのかを紹介します。なお、ここに貼ってあるソースコードはAngular Materialライブラリサイトからの転載です。
value: T | null
value
は入力コントロールの値を入出力するプロパティです。MatFormFieldControlはジェネリクスを1つ持ち、それはvalueで入出力される値の型になります。
stateChanges: Observable<void>
stateChanges
はFormFieldに対して入力コントロールの状態が変化したことを通知するプロパティです。FormFieldは状態の変更検知を OnPush
に設定しているため、状態の変化があったことを明示的に通知されないと状態が反映されません。
stateChanges = new Subject<void>(); set value(tel: MyTel | null) { ... this.stateChanges.next(); } ngOnDestroy() { this.stateChanges.complete(); }
id: string
id
は入力コントロールのDOM上のIDを取得するプロパティです。これを正しく設定することでFormFieldが生成する label
などが入力コントロールに紐付きます。
static nextId = 0; @HostBinding() id = `my-tel-input-${MyTelInput.nextId++}`;
placeholder: string
placeholder
は入力コントロールの値が空の時に表示されるプレースホルダを取得するプロパティです。
@Input() get placeholder() { return this._placeholder; } set placeholder(plh) { this._placeholder = plh; this.stateChanges.next(); } private _placeholder: string;
ngControl: NgControl | null
ngControl
は @angular/forms
に存在するAngularのフォームコントロール(NgModelやReactiveFormControl)を取得するプロパティです。次のように取得ができます。
ngControl: NgControl = null; constructor( ..., @Optional() @Self() public ngControl: NgControl, ..., ) { }
focused: boolean
focused
は入力コントロールがフォーカス状態にあるのかどうかを取得するプロパティです。FormFieldはフォーカス状態に応じて下線の色が変わるなど見た目が変化します。 @angular/cdk
にある FocusMonitor
を使用すると簡単にフォーカス状態をチェックすることができます。
focused = false; constructor(fb: FormBuilder, private fm: FocusMonitor, private elRef: ElementRef) { ... fm.monitor(elRef.nativeElement, true).subscribe(origin => { this.focused = !!origin; this.stateChanges.next(); }); } ngOnDestroy() { ... this.fm.stopMonitoring(this.elRef.nativeElement); }
empty: boolean
empty
は入力コントロールが空なのかどうかを取得するプロパティです。
get empty() { let n = this.parts.value; return !n.area && !n.exchange && !n.subscriber; }
shouldLabelFloat: boolean
shouldLabelFloat
はラベルの表示が入力コントロール上では無く、入力コントロールの上に表示されるべきだというフラグを取得するプロパティです。
@HostBinding('class.floating') get shouldLabelFloat() { return this.focused || !this.empty; }
required: boolean
required
はフォーム項目が必須入力であることを取得するプロパティです。必須の場合FormFieldが生成するラベルに *
が表示されます。
@Input() get required() { return this._required; } set required(req) { this._required = coerceBooleanProperty(req); this.stateChanges.next(); } private _required = false;
disabled: boolean
disabled
は入力コントロールのdisabled状態を取得するプロパティです。注意すべき点として、このプロパティはFormFieldに対してdisabled状態を通知するものであって、入力コントロール自体のdisabled状態は自分で変更する必要があります。
@Input() get disabled() { return this._disabled; } set disabled(dis) { this._disabled = coerceBooleanProperty(dis); this.stateChanges.next(); } private _disabled = false;
errorState: boolean
errorState
は ngControl
の状態がエラーかどうかを取得するプロパティです。 ngControl
を使用しない場合は false
を設定します。
controlType: string
controlType
は入力コントロール種別を識別するためのユニークな文字列です。FormFieldは controlType
の値に mat-form-field-type-
を付加したCSSクラスを自身に設定します。例えば controlType
に sample
を設定した場合、 <mat-form-field>
に mat-form-field-sample
CSSクラスが追加されます。
autofilled: boolean
autofilled
は入力コントロールに入力された値がが自動で入力されたものかどうかを取得するプロパティです。
setDescribedByIds(ids: string[]): void
setDescribedByIds
はFormFieldから aria-describedby
属性に何を設定すべきかが通知されるメソッドです。
@HostBinding('attr.aria-describedby') describedBy = ''; setDescribedByIds(ids: string[]) { this.describedBy = ids.join(' '); }
onContainerClick(event: MouseEvent): void
onContainerClick
はFormFieldがクリックされた時にFormFieldから呼ばれるメソッドです。FormField部分のクリックで入力コントロールにフォーカスを当てる等に使用します。
onContainerClick(event: MouseEvent) { if ((event.target as Element).tagName.toLowerCase() != 'input') { this.elRef.nativeElement.querySelector('input').focus(); } }
MatFormFieldControlの実装
上記プロパティの情報を踏まえ、テキスト入力コントロールを自分で実装してみます(MatInputの再実装)。
プロジェクトの作成
ターミナル(コマンドプロンプト)上で次のコマンドを実行します。
ng new form-control-sample --style=scss cd form-control-sample ng add @angular/material
Angularのプロジェクトが生成され、またAngular Materialが依存に追加されます。
モジュールインポートの追加
app.module.ts
を次のようにいくつかのモジュールを追加します。
import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { AppComponent } from './app.component'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; // 追加 import { MatFormFieldModule } from '@angular/material'; // 追加 @NgModule({ declarations: [ AppComponent ], imports: [ BrowserModule, BrowserAnimationsModule, FormsModule, // 追加 ReactiveFormsModule, // 追加 MatFormFieldModule, // 追加 ], providers: [], bootstrap: [AppComponent] }) export class AppModule { }
Directiveの生成
続いて次のコマンドを入力するとディレクティブが生成され、またモジュールに自動で追加されます。
ng generate directive input
import { Directive } from '@angular/core'; @Directive({ selector: '[appInput]' }) export class InputDirective { constructor() { } }
MatFormFieldControlの実装
すごく適当に作るとこんな感じになります。状態の変化に応じて stateChanges
を呼ぶことを忘れないようにしましょう。実際の matInput
の実装はこちらです。
import { FocusMonitor } from '@angular/cdk/a11y'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { AutofillMonitor } from '@angular/cdk/text-field'; import { Directive, OnDestroy, OnInit, HostBinding, Optional, Self, Input, ElementRef, OnChanges, SimpleChanges } from '@angular/core'; import { NgControl } from '@angular/forms'; import { MatFormFieldControl } from '@angular/material'; import { Subject } from 'rxjs'; let nextId = 0; @Directive({ selector: '[appInput]', providers: [ { provide: MatFormFieldControl, useExisting: InputDirective } ] }) export class InputDirective implements MatFormFieldControl<string>, OnInit, OnChanges, OnDestroy { private readonly _element: HTMLInputElement; private _required = false; private _disabled = false; // コントロール種別 readonly controlType = 'app-input'; // 変更通知 readonly stateChanges = new Subject<void>(); // コントロールのID @HostBinding() id = `app-input-${nextId++}`; // プレースホルダ @HostBinding() @Input() placeholder = ''; // フォーカス状態 focused = false; // 自動フィル autofilled = false; // 値 get value(): string { return this._element.value; } @Input() set value(value: string) { if (value !== this.value) { this._element.value = value; this.stateChanges.next(); } } // 必須 get required(): boolean { return this._required; } @Input() @HostBinding() set required(value: boolean) { this.required = coerceBooleanProperty(value); } // disabled get disabld(): boolean { if (this.ngControl && this.ngControl.disabled !== null) { return this.ngControl.disabled; } return this._disabled; } @Input() @HostBinding() set disabled(value: boolean) { this._disabled = coerceBooleanProperty(value); } get empty(): boolean { return !this.value; } get shouldLabelFloat(): boolean { // フォーカス状態と値をチェックしてラベルの位置を決定する return this.focused || !this.empty; } get errorState(): boolean { // ngControlのエラー有無 return this.ngControl && !!this.ngControl.errors; } constructor( private readonly _autofillMonitor: AutofillMonitor, private readonly _focusMonitor: FocusMonitor, @Self() private readonly _elementRef: ElementRef, @Self() @Optional() public ngControl: NgControl | null) { this._element = this._elementRef.nativeElement; } ngOnInit() { // オートフィルとフォーカスの変更検知 this._autofillMonitor.monitor(this._element).subscribe(ev => { this.autofilled = ev.isAutofilled; this.stateChanges.next(); }); this._focusMonitor.monitor(this._element, true).subscribe(origin => { this.focused = !!origin; this.stateChanges.next(); }); } ngOnChanges(changes: SimpleChanges) { // 外部から何かプロパティがセットされたら変更検知させる this.stateChanges.next(); } ngOnDestroy() { // 変更検知の停止 this._autofillMonitor.stopMonitoring(this._element); this._focusMonitor.stopMonitoring(this._element); this.stateChanges.complete(); } setDescribedByIds(ids: string[]): void { } onContainerClick(event: MouseEvent): void { // FormFieldクリックでフォーカスさせる if (!this.focused) { this.focused = true; this._element.focus(); this.stateChanges.next(); } } }