こんにちは。24日目、思いっきり遅刻してしまい申し訳ありません。Angularを使い始めて半年も経ってないですが、Angular Material ComponentのWrapper component(特にmat-radio-button)を作りながらいくつか気づいたところを書いていきたいと思います。
@Input
まず基本的なInputプロパティ。普通のやり方でdisabled
で例えると
- Wrapperを使うクライアントからのBinding
<app-radio-button [disabled]="true">Option 1</app-radio-button>
- WrapperのInputプロパティを経由して
@Component({
...
})
export class RadioButtonComponent {
@Input()
public disabled: boolean;
...
- WrapされるComponentプロパティにBinding
<mat-radio-button [disabled]="disabled">
<ng-content></ng-content>
</mat-radio-button>
の順番にデータが流れます。
HTML attribute対応
<app-radio-button disabled>Option 1</app-radio-button>
ここで上のようにdisabled
をHTML attributeにも対応するには
(ここでdisalbed
は空の文字列が入る)
...
export class RadioButtonComponent {
private _disabled: boolean;
@Input()
get disabled(): boolean { return this._disabled; }
set disabled(value) {
this._disabled = (value === '' || !!value);
}
...
のように強引にstring型にも対応した感じですが、実はAngular Material側ですでに対策済みであるため、ただプロパティを流すだけでHTML attributeにも対応ができてしまいます。(というか上の方法ではTSエラーになってしまいます。)
coerceBooleanProperty()
対策されているAngular Materialを覗くと下記のようにCDKのヘルパー関数が使われています。
import { coerceBooleanProperty } from '@angular/cdk/coercion';
...
set disabled(value) {
this._disabled = coerceBooleanProperty(value);
}
...
HTML attributeで取得されるvalue
はstring型になり、上記の関数はvalue
が文字列なら'false'
以外すべてをtrue
に変換するようです。
もしプロパティの行き先がAngular MaterialのComponentじゃないときに使うと便利そうですね。
mat-radio-group
Front-end開発において、Radio buttonはその性質上、複数のComponentで単一のフォームを持つ必要があります。ReactiveFormsだったらFormControl
、Template-drivenだったらNgModel
をどのタグに持たせるか。Angular Materialでは<mat-radio-group>
という親タグその役割を担っています。
しかし、Radio buttonはまた、他のinputフォームと違って、name属性によってGroupingされる特殊な仕様があります。各radio-button
タグでnameを指定してまとめる手もありますが、そうしてしまうと親のradio-group
タグの存在意義がなくなる。
これをどう対処するか悩ましいところですが、そもそもwrapしようとするmat-radio-groupを覗くと、
<input #input class="...
...
[attr.name]="name"
...
let nextUniqueId = 0;
...
@Directive({
...
})
export class MatRadioGroup extends ... {
...
// nameはgroupコンポーネントで決まる
private _name: string = `mat-radio-group-${nextUniqueId++}`;
...
// content内の子コンポーネントのリストを取得
@ContentChildren(forwardRef(() => MatRadioButton), { descendants: true })
_radios: QueryList<MatRadioButton>;
...
@Input()
get name(): string { return this._name; }
set name(value: string) {
this._name = value;
// nameが変わったら子コンポーネントにも反映する
this._updateRadioButtonNames();
}
...
private _updateRadioButtonNames(): void {
if (this._radios) {
this._radios.forEach(radio => {
radio.name = this.name;
});
}
}
なるほど、@ContentChildren
で子コンポーネントを取得し、親と同じnameをセットする仕組みのようです。
今度はMatRadioButton
を見ると、
...
@Component({
...
})
export class MatRadioButton extends ... implements OnInit, ... {
...
@Input() name: string;
...
constructor(
@Optional() radioGroup: MatRadioGroup,
...
) {
...
}
...
ngOnInit() {
if (this.radioGroup) {
...
// 親groupのnameを引き継ぐ
this.name = this.radioGroup.name;
}
}
...
DIで親コンポーネントを取得してそのnameを引き継ぎます。
Circular dependency
ソースを見て気づいた方も多いと思いますが、MatRadioButton
とMatRadioGroup
が自分のクラスの中で互いを参照する状態になっています。上記の例のようにradio.ts
という一つのファイルの中ならforwardRef
を使うだけでなんとかなっています。ずるい。
しかし、Wrapper Componentを作る際はAngular流儀上、1コンポーネント/1ファイルになるため、そのまま真似してしまうと、
WARNING in Circular dependency detected:
src/app/radio-button/radio-button.component.ts -> src/app/radio-group/radio-group.component.ts -> src/app/radio-button/radio-button.component.ts
WARNING in Circular dependency detected:
src/app/radio-group/radio-group.component.ts -> src/app/radio-button/radio-button.component.ts -> src/app/radio-group/radio-group.component.ts
を食らってしまうし、一度決まったnameをプログラム途中で変更するユースケースは想定しにくいので、
- ButtonからGroupのnameを取りに行く
- Groupからは何もしない(Buttonを参照しない)
の方針で、依存をButton(子)→Group(親)のように一方向にするのが妥当かなと思います。
ControlValueAccessor
Custom Form Controlを作るためにControlValueAccessor
を実装します。ControlValueAccessor
とはAngular Form API(FormControl)とDOMのNative(or Custom) Elementをつなぐinterfaceで、<input>
などフォームタグ(Native element)に対応するDirectiveはすべてこのinterfaceを実装しており、以下のように定義されています。
Accessor | Form Element |
---|---|
DefaultValueAccessor | input, textarea |
CheckboxControlValueAccessor | input[type=checkbox] |
NumberValueAccessor | input[type=number] |
RadioControlValueAccessor | input[type=radio] |
RangeValueAccessor | input[type=range] |
SelectControlValueAccessor | select |
SelectMultipleControlValueAccessor | select[multiple] |
ControlValueAccessor
の各メソッドの実装が済んだら、あとはNG_VALUE_ACCESSOR
というDI tokenを持ってProviderに登録することで、FormControlが使えるようになります。
@Component({
...
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => RadioGroupComponent),
multi: true
}],
...
})
export class RadioGroupComponent implements ControlValueAccessor, ... {
...
writeValue(obj: any) {...}
registerOnChange(fn: any) {...}
registerOnTouched(fn: any) {...}
setDisabledState(isDisabled: boolean) {...}
}
DI tokenとかProviderとか調べるとどういう仕組になってるのか、あれこれ説明されているところは多いですが、いくらソースコードを読んでみても、そもそものAngularのDIコンテナの仕組み自体あまり理解できず、雰囲気だけで作ってる感があるので、詳しく説明していただける方がいらっしゃれば…