Search code examples
javascriptangulardom-eventsstoppropagation

How to stop 'Click' event from being triggered when dealing with nested components


What I'm trying to do:

  • Create a 'resize' angular directive that allows the user to drag borders of DOM elements and call custom application logic (done)
  • ensure clicks don't bubble up to parent components.

stackblitz link -> stackblitz example

The component hierarchy is as such

  • app.component > parent.component > child.component (the child component uses the 'resize' directive)

in the 'resize.directive'.

  • the mousedown event is captured and event.stopPropagation() & event.preventDefault() are used to prevent event bubbling
  • the mousedown event is only capture if the user's mouse is over the borders

the child.component

  • is a simple div the uses the 'resize' directive

the parent component

  • uses @HostListener('click', ['$event'])
  • I need to listen to 'click' events on the parent container to identify when it has been selected. so I can't simply get rid of it.

as you mouse over the inner child component, you can click to drag and resize the borders of the element. what I'm struggling with is the event propagation of the 'click' handler in the parent component.

My issue is that the @HostListener click event is being fired from the parent component, when I'm not expecting it to be. i.e. if you drag the borders of the child component. on mouse up, the click event of the ParentComponent is fired.

My understanding is that

  • to prevent event propagation of an click event, it should be captured at the mousedown event. hence stopPropagation()

yet the parent component is still pickup the click event.

There is a lot of comments around using return false. however, based on other issues, that doesn't seem to be the cause, as indicated here.

that being said, even if I add return false to the directive, the event still bubbles up.

Any insights into what I might be missing is appreciated.


Solution

  • To clarify, the event listeners will be fired in the following order, starting with the element in which the event originated

    directive -> child -> parent -> document
    

    As far as I can see, the issue you're having boils down to a combination of things:

    1. When you drag the border, the mouse ends up being outside the child element, so you will end up triggering parent.mouseup -> document.mouseup. To capture the event in directive, you have to listen to document.mouseup.

    2. Your directive is listening to document.mouseup. The parent.mouseup is triggered before document.mouseup so you can't stop the propagation in your directive.

    Let's see what happens when you drag the border down:

    1. You press on the border. This triggers directive.mousedown -> child.mousedown -> .... You stop propagation in the directive, so all good.

    2. You drag the mouse to the bottom, the cursor is now outside the child element.

    3. You release the mouse. Note that we're not in the child element anymore, so this triggers parent.mouseup -> document.mouseup. Parent handler will run BEFORE the document handler, so you can't stop it.

    4. Since the down and up happened within the parent controller, click is now triggered. Again, we're out of the child element, so the triggers are parent.click -> document.click. You can't really prevent this from happening, as the parent is the FIRST handler.

    I would propose the following solution, although it might seem a bit hacky:

    1. Instead of triggering parent click using HostListener('click'), we can roll our own: we will listen to parent.mousedown and parent.mouseup. If they happen in quick succession, the assume it's a click, so we "select" the parent.
    // parent.component.ts
    export class ParentComponent {
      parentClicked: boolean = false;
      down?: number;
    
    
      manualClick(): void {
        this.parentClicked = true;
        console.log(`parent manual click`);
      }
    
      @HostListener('mousedown', ['$event'])
      onMouseDown( event: MouseEvent): void {
        console.log('parent down', event.target);
        this.down = Date.now();
      }
    
    
      @HostListener('mouseup', ['$event'])
      onMouseUp( event: MouseEvent): void {
        console.log('parent up');
        // Assume that if it's released within 100 ms, it's a click.
        if (this.down && Date.now() - this.down < 100) {
          this.manualClick();
          this.down = undefined;
        }
      }
    }
    

    Since we don't listen to the click event, it's fine now:

    1. You press on border and stop propagation in the directive, so parent.mousedown NEVER HAPPENS.
    2. You drag the mouse outside the child and into the parent element.
    3. You release the mouse, which triggers parent.mouseup -> document.mouseup. Since parent.mousedown wasn't triggered, down = undefined, so our "click logic" doesn't count this as a click.
    4. The event bubbles up to document.mouseup, where you finally handle it in your directive.

    Now, there's a small potential issue here: when you click in the child element, you will trigger our parent's click logic, because child.mousedown-> parent.mousedown is followed by child.mouseup -> parent.mouseup. If you don't want this to happen, you can stop propagation of the mousedown event in the child:

    // child.component.ts
      @HostListener('mousedown', ['$event'])
      onMouseDown( event: MouseEvent): void {
        console.log('child down');
        event.stopPropagation();
        event.preventDefault();
      }