noxi雑記

.NET、Angularまわりの小ネタブログ

Angular MaterialのFormFieldに独自の入力コントロールを配置する

Angular Materialには MatFormField というマテリアルデザインの入力コントロール表示を支援するコンポーネントがあります。こんな見た目のヤツです。

f:id:noxi515:20180723002850p:plain

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

errorStatengControl の状態がエラーかどうかを取得するプロパティです。 ngControl を使用しない場合は false を設定します。

controlType: string

controlType は入力コントロール種別を識別するためのユニークな文字列です。FormFieldは controlType の値に mat-form-field-type- を付加したCSSクラスを自身に設定します。例えば controlTypesample を設定した場合、 <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();
    }
  }

}