I developed a solution that does not need 'setTimeout' and does not force you to use the 'mouseup' event instead of the click event. This is user friendly because the click event "gives the user a chance to abort a click by moving the mouse off of the button before releasing the mouse." (comment by piccy)
Problem
As stated in the answer by Vinod, this is a problem in the chronology of the events:
- mousedown: The button registers a mousedown event.
- focusout: Registered due to the mousedown on the button. In this unique scenario, the focusout handler makes the button move to another position.
- mouseup: Due to the changed position of the button, it does not register a mouseup event. Thus, a click event is also not registered because that would require a mousedown followed by a mouseup on the same element.
Solution
My solution is a directive that exposes a delayed focusout event that happens after the mousedown and mouseup event. Therefore, the click event is registered before the event handler for the (delayed) focusout event changes the position of the button.
This is done by a BehaviourSubject storing whether the mouse is currently down or not. When a focusout event is registered while the mouse is down, we do not trigger the delayed focusout event immediately (otherwise we would end up with the same old problem). Instead, we wait for the mouse going back up again and then emit the delayed focusout event. This leads to the following order:
- mousedown
- focusout (ignore this event)
- mouseup
- delayed focusout + click
Code solution
The directive is used like this:
<input appDelayedFocusout (delayedFocusout)="yourLayoutChangingHandler()">
My directives implementation makes use of the until-destroy library to prevent memory leaks from never ending subscriptions but feel free to modify.
import {Directive, EventEmitter, HostListener, OnInit, Output} from '@angular/core';
import {BehaviorSubject, fromEvent} from 'rxjs';
import {UntilDestroy, untilDestroyed} from '@ngneat/until-destroy';
import {filter, map, take} from 'rxjs/operators';
/**
* This directive exposes a special variant of the 'focusout' event. The regular 'focusout' event has a quirk:
* Imagine the user clicks on some button on the page. This triggers the following events in the following order:
* mousedown, focusout, mouseup. But the focusout event handler might change the layout of the website so that
* the button on which the mousedown event occurred moves around. This leads to no mouseup event registered on
* that button. Therefore a click event is also not registered because a click event consists of
* a mousedown AND a mouseup event on that button. In order to fix that problem, this directive exposes a delayed focusout
* event that is triggered AFTER the mousedown and mouseup events. When the delayed focusout event handler changes
* positions of buttons, click events are still registered as you would expect.
*/
@UntilDestroy()
@Directive({
selector: '[appDelayedFocusout]'
})
export class DelayedFocusoutDirective implements OnInit {
@Output() delayedFocusout = new EventEmitter<boolean>();
isMouseDownSubject = new BehaviorSubject(false);
ngOnInit(): void {
fromEvent(document.body, 'mousedown').pipe(untilDestroyed(this))
.subscribe(() => this.isMouseDownSubject.next(true));
fromEvent(document.body, 'mouseup').pipe(untilDestroyed(this))
.subscribe(() => this.isMouseDownSubject.next(false));
}
@HostListener('focusout') onFocusout() {
// If the mouse is currently down, we subscribe to the the event of
// 'mouse being released' to then trigger the delayed focusout.
// If the mouse is currently not down, we can trigger the delayed focusout immediately.
if (this.isMouseDown()) {
this.mouseRelease().subscribe(() => {
// This code is executed once the mouse has been released.
this.delayedFocusout.emit(true);
});
} else {
this.delayedFocusout.emit(true);
}
}
/**
* Emits the value true once the mouse has been released and then completes.
* Also completes when the mouse is not released but this directive is being destroyed.
*/
mouseRelease() {
return this.isMouseDownSubject.pipe(
untilDestroyed(this),
// Just negate isDown to get the value isReleased.
map(isDown => !isDown),
// Only proceed when the the mouse is released.
filter(isReleased => isReleased),
take(1)
);
}
isMouseDown() {
return this.isMouseDownSubject.value;
}
}