<button mat-button (click)="onClick">ボタンだよー</button>
みたいなテンプレートを持つボタンコンポーネント(my-buttonとします)を作ってみたわけですよ。Angular Materialのボタンをラップして、少しスタイルをカスタマイズしたボタンです。disabledフラグを見て、クリックを拒絶したいところです。
export class MyButtonComponent {
@Input()
disabled: boolean;
@Output()
click: EventEmitter<void> = new EventEmitter();
onClick() {
if (!this.disabled) {
this.click.emit();
}
}
}
これで動きそうなもんですが、このonClick()が呼ばれないのに、このボタンを使ったアプリで設定したクリックイベントがなぜか呼ばれるという・・・
対策1
どうも、ルートの(click)は特殊な属性で、クリックイベントに強制束縛されるようなので、こいつを強引にフックすればいい気がします。HostListenerというのを使えばいいと見かけたのでやってみます。
export class MyButtonComponent {
// 追加
@HostListener('click', ['$event'])
clickEvent(event: Event) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (!this.disabled) {
this.click.emit();
}
}
}
やった!動いた!と思ったのですが、周辺部分はうまくいくものの、どうも、ボタンの真ん中部分のテキスト部分をクリックした時にはやはり強制的にクリックが発動してしまいます。このHostListenerがバイパスされてしまう。
対策2
真ん中のテキスト周辺部分はspan.mat-button-wrapperという要素が作られて、このクリックイベントが呼ばれちゃうみたいです。このspan要素のclickイベントをハイジャックしてみます。
export class ButtonComponent implements AfterContentInit, OnDestroy {
constructor(private el: ElementRef) {}
private listener: any;
ngAfterContentInit() {
const node = this.el.nativeElement.querySelector(
'.mat-button-wrapper'
) as HTMLElement;
this.listener = node.addEventListener('click', event => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (!this.disabled) {
this.click.emit();
}
});
}
ngOnDestroy() {
const node = this.el.nativeElement.querySelector(
'.mat-button-wrapper'
) as HTMLElement;
node.removeEventListener('click', this.listener);
}
}
うまくいったと思いきや・・・・急にPCのファンがぶんぶん回り始めてChromeが暴走しました。this.click.emit()で自分自身を呼んで無限ループみたいなことが起きているような・・・
対策3
ネイティブのHTMLと同じインタフェースを維持するために(click)という名前を採用しましたが、うまくいかないので、(press)にしてみます。苦渋の選択。
export class ButtonComponent implements AfterContentInit, OnDestroy {
@Output()
press: EventEmitter<void> = new EventEmitter();
@HostListener('click', ['$event'])
clickEvent(event: Event) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (!this.disabled) {
this.press.emit();
}
}
ngAfterContentInit() {
const node = this.el.nativeElement.querySelector(
'.mat-button-wrapper'
) as HTMLElement;
this.listener = node.addEventListener('click', event => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (!this.disabled) {
this.press.emit();
}
});
}
}
これで動きました。ちょっと負けた感じですが、Angular Materialはたまに行儀が悪い挙動をしますね。
対策4
救世主らこらこさんから福音が!!!
自身にclickイベントを発生させたくないなら HostBindingで `style.pointer-events` をnoneにしちゃうとか思いつきはしましたっが、そもそものニーズが掴みきれてない自覚はあります
— Suguru Inatomi / laco (@laco2net) January 9, 2019
export class ButtonComponent implements OnDestroy {
@HostBinding('style.pointer-events')
get pointerEvents(): string {
return this.disabled ? 'none' : 'auto';
}
}
いけました! かなり短くなりました。
まとめ
clickイベントの束縛は勝手にAngularがやります。最終コードには@Output
もない。
コンポーネントユーザーが(click)="handle($event)"
と書くと、クリック時の処理としてそのハンドラーが設定されます。
コンポーネント作者は何もやる必要はありません。というか、コンポーネント作者がそのクリックを強奪して何かをする、ということはできないようです。
他のフレームワークを知っていると気軽にclickイベントを上書きしちゃいたくなりますが、今回のケースのように、とりあえずdisabledにしてイベントをなくしたい場合にはそのclickイベントを発生する前に止めてしまう、というのがAngularのやり方ということですね。例えば音を鳴らしつつ、ユーザー定義のハンドラーを呼び出すボタンみたいなのを作るときには、対策3のような別名イベントを作成してあげないといけないんですかね・・・