26

I'm creating an Angular component that wraps a native <button> element with some additional features. Buttons do not fire a click event if they're disabled and I want to replicate the same functionality. i.e., given:

<my-button (click)="onClick()" [isDisabled]="true">Save</my-button>

Is there a way for my-button to prevent onClick() from getting called?

In Angular you can listen to the host click event this way, and stop propagation of the event:

//Inside my-button component
@HostListener('click', ['$event'])
onHostClick(event: MouseEvent) {
  event.stopPropagation();
}

This prevents the event from bubbling to ancestor elements, but it does not stop the built-in (click) output from firing on the same host element.

Is there a way to accomplish this?


Edit 1: the way I'm solving this now is by using a different output called "onClick", and consumers have to know to use "onClick" instead of "click". It's not ideal.

Edit 2: Click events that originate on the <button> element are successfully stopped. But if you put elements inside the button tag as I have, click events on those targets do propagate up to the host. Hm, it should be possible to wrap the button in another element which stops propagation...

Kamil Naja
  • 6,267
  • 6
  • 33
  • 47
Ryan Silva
  • 925
  • 2
  • 9
  • 17
  • 2
    (click)="condition ? onClick() : null" – Joe Warner Mar 21 '18 at 20:18
  • I want it to behave like a button -- I don't want the consumer of my component to have to check the disabled state first. – Ryan Silva Mar 21 '18 at 20:22
  • Does [this stackblitz](https://stackblitz.com/edit/template-driven-form-2-vm9gqu?file=app%2Fmy-custom.component.ts) do what you want? – ConnorsFan Mar 21 '18 at 23:01
  • 1
    Close: I think [this stackblitz edit](https://stackblitz.com/edit/template-driven-form-2-dl7d5j?file=app%2Fmy-custom.component.ts) will be a little simpler for me. You're right we want to stop propagation of everything inside the host if disabled. If ` – Ryan Silva Mar 22 '18 at 11:52

5 Answers5

22

You could do the following:

  • Redefine the click event of the component, and emit this event when the button is clicked
  • Set the CSS style pointer-events: none on the component host
  • Set the CSS style pointer-events: auto on the button
  • Call event.stopPropagation() on the button click event handler

If you need to process the click event of other elements inside of your component, set the style attribute pointer-events: auto on them, and call event.stopPropagation() in their click event handler.

You can test the code in this stackblitz.

import { Component, HostListener, Input, Output, ElementRef, EventEmitter } from '@angular/core';

@Component({
  selector: 'my-button',
  host: {
    "[style.pointer-events]": "'none'"
  },
  template: `
    <button (click)="onButtonClick($event)" [disabled]="isDisabled" >...</button>
    <span (click)="onSpanClick($event)">Span element</span>`,
  styles: [`button, span { pointer-events: auto; }`]
})
export class MyCustomComponent {

  @Input() public isDisabled: boolean = false;
  @Output() public click: EventEmitter<MouseEvent> = new EventEmitter();

  onButtonClick(event: MouseEvent) {
    event.stopPropagation();
    this.click.emit(event);
  }

  onSpanClick(event: MouseEvent) {
    event.stopPropagation();
  }
}

UPDATE:

Since the button can contain HTML child elements (span, img, etc.), you can add the following CSS style to prevent the click from being propagated to the parent:

:host ::ng-deep button * { 
  pointer-events: none; 
}

Thanks to @ErikWitkowski for his comment on this special case. See this stackblitz for a demo.

ConnorsFan
  • 70,558
  • 13
  • 122
  • 146
  • 2
    See my reply to you on the OP. This wouldn't stop events originating from elements inside the button from propagating out (the button click event won't fire if the button is disabled, so propagation won't be stopped). – Ryan Silva Mar 22 '18 at 12:02
  • 1
    The click event of the host element will not fire in that case either, as you can see when you test the stackblitz. The `pointer-events` style attribute prevents the host from catching the event. – ConnorsFan Mar 22 '18 at 12:24
  • I'll accept this answer. It can be made simpler depending on the specific use-case but the general idea is that if it's disabled you have to prevent any click event from ever reaching the host element. – Ryan Silva Mar 23 '18 at 15:46
  • This is a nice solution to prevent host firing the event. For me it was even more simple by add `$event.stopPropagation();` directly on the button element. ` – Ian Poston Framer Aug 27 '18 at 22:40
  • Fantastic ! This solution works like a charm in order to disable conditionnally a custom Angular Component (which is not a "button", but is still clickable). Thank you ! – Adrien Dos Reis Sep 03 '19 at 13:36
  • 1
    NOTE: if you have a tag inside the button, it becomes a and the problem will be back as now the span handles the click and propagates it back to the host, to solve you can add css `:host /deep/ button span { pointer-events: none; } ` – ErikWitkowski Sep 26 '19 at 13:48
  • Consider the following full solution: https://stackblitz.com/edit/template-driven-form-2-toqhjr – ErikWitkowski Sep 26 '19 at 14:06
  • @ErikWitkowski - Thanks for the note and code sample. One small remark: according to [Angular documentation](https://angular.io/guide/component-styles#deprecated-deep--and-ng-deep), `::ng-deep` is preferred over `/deep/`. – ConnorsFan Sep 26 '19 at 14:11
  • @ErikWitkowski - I updated the answer to account for your comment. Thanks again! – ConnorsFan Sep 26 '19 at 14:55
  • This is great but doesnt seem to work in ie11. Any solutions for that? – Sean Oct 09 '19 at 22:49
  • Just saying, this hould not ne the accepted solution. Setting "pointer-events" to "none" also disabled other things aswell like hovering-cursor. As already stated, stopping the eventpropagation sinde the component does NOT stop it on the outside. And having internal logic OUTSIDE of the component does not make sense. I think the cleanest solution in this case is to go with an alternative output called something like "clicked" and use only that one. The emit of this output can be 100% controlled inside the component. – mhombach Sep 03 '20 at 11:45
2

I do not believe there is a native way to prevent the event from firing, as supported by this git issue in 2016:

The order of execution is red herring - the order in which an event on the same element is propagated to multiple listeners is currently undefined. this is currently by design.

Your problem is that the event exposed to the listeners is the real DOM event and calling stopImmediatePropagation() on the provided event stops execution of other listeners registered on this element. However since all the the listeners registered via Angular are proxied by just a single dom listener (for performance reasons) calling stopImmediatePropagation on this event has no effect.

Community
  • 1
  • 1
Zze
  • 18,229
  • 13
  • 85
  • 118
  • It makes sense that what I want is not possible if (click) on the component is an event listener on the element, but I was hoping it was more of a "default output" that I could override :( – Ryan Silva Mar 21 '18 at 22:32
  • @ryansilva I did try and solve this issue with some custom removing then custom re emitting of events via `HostListener` to unfortunately no avail. I hope you find a solution though as the functionality you propose sounds terrific. – Zze Mar 22 '18 at 00:08
1

Catch click with early subscription.

Easy way would be to catch event on capture phase, but there is some problem with capture catch in a Firefox (when event target is disabled, click event comes to host on bubbling phase, not on capture). So we have to add event listener in constructor (not on init) to be the first subscriber and to use stopImmediatePropagation on event.

@Input() disabled: boolean;

constructor(private elementRef: ElementRef) {
  this.elementRef.nativeElement.addEventListener('click', this.captureClick, true);
}

ngOnDestroy() {
  this.elementRef.nativeElement.removeEventListener('click', this.captureClick, true);
}

private captureClick = (event: PointerEvent) => {
  if (this.disabled) {
    event.stopPropagation();
    event.preventDefault();
    event.stopImmediatePropagation();
    return false;
  }
  return true;
};
blazkovicz
  • 732
  • 8
  • 18
0

You can use the native add and remove EventListeners. This is in no way a good solution when thinking in angular terms. Also, this won't work if you put disabled attribute in button as it will override eventListeners attached. A disabled class need to be used instead. (Or else wrap button in a span and use template ref #btn from it.)

StackBlitz

import { Component, OnInit, OnChanges, HostListener, Input, Output, EventEmitter, SimpleChanges, ElementRef, ViewChild } from '@angular/core';

@Component({
  selector: 'app-my-button',
  template: `<button [class.disabled]="isDisabled" #btn><span>hey</span></button>`,
  styles: [`button.disabled { opacity:0.5 }`]
})
export class MyButtonComponent implements OnInit, OnChanges {
  disableClick = e => e.stopPropagation();
  @Input() isDisabled: boolean;
  @ViewChild('btn') btn: ElementRef;
  constructor() { }

  ngOnChanges(changes: SimpleChanges) {
    if(this.isDisabled) {
      this.btn.nativeElement.addEventListener('click', this.disableClick);
    } else {
      this.btn.nativeElement.removeEventListener('click', this.disableClick);
    }
  }
  ngOnInit() {
  }

}
sabithpocker
  • 15,274
  • 1
  • 42
  • 75
0

You can try stop propagation of the event in capturing mode (look at true as the last param of addEventListener calling and window as the listening object).

window.addEventListener('click', (event) => {
    let clickDisallowed = true; // detect if this click is disallowed

    if (event.target === this.elementRef.nativeElement && clickDisallowed) {
        event.stopImmediatePropagation();
    }
}, true);

And do not forget unsubscribe this listener.

Woonder
  • 291
  • 4
  • 4