Angular Materialにカレンダーから日付を選択する
Datepickerという部品がある。ただ、このDatepickerには厄介なところがあって、日付をISO 8601でしか管理できない。なので、フォームとして日付を例えば yyyy/MM/dd
で管理したい場合に "部品の外部で" 自前で変換を差し込む必要がある。そしてここでもう一つ厄介なことに、DatepickerはValueAccessorをProvideしているため、同じ <input>
に自作のDirective等でValueAccessorを追加でProvideすることができない。つまり、ValueAccessorの onChange
や writeValue
でよしなに値を書き換えるという方法は取れない。
最近このような状況があり、いくつか考えた結果割とシンプルに上記課題を解決する方法を思いついたのでまとめておく。
以下の方針で実装した。
<input>
の値がISO 8601になってしまうのはもう諦めて、別途用意した <input hidden>
に影響を押し込める<input>
も用意し、formControl
はこちらと結びつけるformControl
に patchValue
する<input hidden>
の [value]
には formControl.value
(今回フォーマットは yyyy/MM/dd
の想定)をISO 8601に変換してバインドする最後に上記をComponentとして共通化したものを紹介する。
これから作るComponentは <mat-form-field>
の中に入るが、<mat-fom-field>
は直接の子要素として MatFormFieldControl
を実装したComponent(例えば <input>
)を持つ必要があり、単にすべての <input>
を共通Componentに押し込めてしまうとこの制約にひっかかる。
MatFormFieldControl
は<mat-form-field>
内で使えるinputを実装するためのinterfaceで結構制約が強い。このレベルの単純な機能を共通化するために実装するのはコストが見合わないのでそこまではやらないことにした。
そこで、改めて考えるとそもそも本質的にフォームとして働くのは formControl
をバインドした <input>
であって、Datepickerをバインドした <input hidden>
の方はおまけである。よって、フォームとして働く方の <input>
にはそのまま残ってもらって、あくまでもカレンダー系部品をまとめる存在として共通Componentを定義する。この共通Componentはカレンダーからの入力で formControl
に値をセットしないといけないので、@Input()
デコレータで外から formControl
だけもらえるようにしておく。
最終的な実装が以下である。
datepicker.component.tsimport { Component, Input } from '@angular/core'; import { AbstractControl, FormControl } from '@angular/forms'; import { MatDatepickerInputEvent } from '@angular/material/datepicker'; export const ISO_8601_REGEX = /^(\d{4})-(\d{2})-(\d{2})(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|(?:(?:\+|-)\d{2}:\d{2}))?)?$/; export const CUSTOM_YMD_REGEX = /^(\d{4})\/(\d{2})\/(\d{2})$/; @Component({ selector: 'app-datepicker', templateUrl: './datepicker.component.html', }) export class DatepickerComponent { @Input() control?: AbstractControl | null; public dateChangeHandler = (event: MatDatepickerInputEvent<Date>) => { if (!event.target.value) return; this.control?.setValue( this.convertToYYYYMMDDFromISODate(event.target.value?.toISOString()) ); this.control?.markAsTouched(); this.control?.markAsDirty(); }; public dateInputHandler = (event: MatDatepickerInputEvent<Date>) => { if (!event.target.value) return; this.control?.setValue( this.convertToYYYYMMDDFromISODate(event.target.value?.toISOString()) ); this.control?.markAsTouched(); this.control?.markAsDirty(); }; public convertToISODateFromYYYYMMDD(value: string): string { const found = value.match(CUSTOM_YMD_REGEX); if (!found || found[0] !== value) return value; return new Date( Number(found[1]), Number(found[2]) - 1, Number(found[3]) ).toISOString(); } private convertToYYYYMMDDFromISODate(value: string): string | null { if (ISO_8601_REGEX.test(value)) { const date = new Date(value); if (!isNaN(date.getTime())) { date.setMinutes(date.getMinutes() + 540); return date?.toISOString().replace(ISO_8601_REGEX, `$1/$2/$3`); } } return value; } }
datepicker.component.html<input type="hidden" [matDatepicker]="picker" [value]="convertToISODateFromYYYYMMDD(control?.value)" (dateChange)="dateChangeHandler($event)" (dateInput)="dateInputHandler($event)" /> <mat-datepicker-toggle [for]="picker"></mat-datepicker-toggle> <mat-datepicker #picker></mat-datepicker>
こうする必然性はないが、formGroup
から簡単に日付入力フォームのcontrolを参照できるようにgetterを定義しておく。直接 formControl
を参照しても悪くない。共通Component側のinterfaceは AbstractControl
を受け取るようにしたのでどちらでもいけるようになっている。
.tsget birthday(): AbstractControl | null { return this.sampleForm.get('birthday'); }
.html<mat-form-field appearance="fill"> <mat-label>誕生日</mat-label> <input type="text" matInput placeholder="誕生日" id="birthday" name="birthday" required formControlName="birthday" /> <app-datepicker matSuffix [control]="birthday"></app-datepicker> </mat-form-field>
あくまでも表面に出ているところはただのtextの <input>
なのでかなり扱いやすい。冒頭で少し触れた <input>
でValueAccessorをProvideする、ということも可能になり、以下の記事で紹介した ngx-mask
による入力値のフィルタリング等とも組み合わせることができる。