Skip to main content
Ben Nadel at Scotch On The Rock (SOTR) 2010 (London) with: Dan Lancelot
Ben Nadel at Scotch On The Rock (SOTR) 2010 (London) with: Dan Lancelot

Creating A Mouse-Over Hesitation Directive In Angular 7.1.4

By on

Almost a decade ago, I used jQuery's custom event types to synthesize a "hesitate" event. This "hesitate" event was triggered when a user moused-into a target element and then remained inside the bounding-box of the element for some period of time without performing any other actions. As a fun end-of-week code kata, I thought this "hesitate" event functionality is something that we could easily wrap up in an Angular 7.1.4 Directive. And, what's more, because Angular Directives allow for input bindings, we can make the "hesitation duration" a configurable property of the Directive on a per-instance basis.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

The concept behind this Angular Directive is simple. If a user mouses into a given element and then leaves their mouse hovered-over this element without clicking for some period of time, we want the Directive to emit a (hesitate) event. We can then use this (hesitate) event to update the user interface (UI) in some way; such as showing a tooltip, pressuring the user into a given action, or opening up new set of options.

To see what I mean, imagine that we have a "Buy" button on the page. And, if the user mouses-over the "Buy" button but doesn't click for some period of time, we can show a message urging the user to following through with the purchase:

// Import the core angular services.
import { Component } from "@angular/core";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

@Component({
	selector: "my-app",
	styleUrls: [ "./app.component.less" ],
	template:
	`
		<div>
			<button (hesitate)="showMessage()" [duration]="1000" (click)="hideMessage()">
				Buy It $29.99
			</button>

			<span *ngIf="isShowingMessage">
				<strong>Come on!</strong> Buy it already!
			</span>
		</div>
	`
})
export class AppComponent {

	public isShowingMessage: boolean;

	// I initialize the app component.
	constructor() {

		this.isShowingMessage = false;

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I hide the peer-pressure message.
	public hideMessage() : void {

		this.isShowingMessage = false;

	}


	// I get called when a change-detection cycle has been triggered.
	// --
	// NOTE: We are binding to this life-cycle method so that we can demonstrate that
	// the event-handlers bound inside of the (hesitate) directive are not causing any
	// unnecessary change-detection cycles until the (hesitate) event is triggered.
	public ngDoCheck() : void {

		console.log( "ngDoCheck() - Change detection triggered." );

	}


	// I show the peer-pressure message.
	public showMessage() {

		this.isShowingMessage = true;

	}

}

In this App component, we're binging to the (hesitate) event in order to show the, "Come on! Buy it already!" message. We're also providing a custom duration of 1,000 ms, which determines how long the user must hover over the button before we emit the (hesitate) event.

Now, there is no native "hesitate" event in the browser. As such, in order to emit a "hesitate" output binding event, we have to synthesize it using other native browser events. In this case, we're going to use the "mouseenter" and "mouseleave" events in conjunction with a Timer.

The default Angular configuration automatically uses Zone.js to monkey-patch things like .addEventListener() and .setTimeout(). Which means that, if we naively bind to the "mouseenter" event and initialize Timers, we're going to end up triggering more change-detection cycles than is warranted by the application.

In reality, these extra change-detection cycles won't matter. But, in the interest of optimization and code-kata practice, we can use the core NgZone instance to refine how our events and timers are managed. Specifically, we drop out of the NgZone until it is time to emit the "hesitate" event. Then, we can step back into the core NgZone and invoke our EventEmitter.

Here's the HesitateDirective that I came up with:

// Import the core angular services.
import { Directive } from "@angular/core";
import { ElementRef } from "@angular/core";
import { EventEmitter } from "@angular/core";
import { OnDestroy } from "@angular/core";
import { OnInit } from "@angular/core";
import { NgZone } from "@angular/core";
import { Renderer2 } from "@angular/core";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

@Directive({
	selector: "[hesitate]",
	inputs: [ "duration" ],
	outputs: [ "hesitateEvents: hesitate" ],
	// Let's export this Directive instance so that the calling context can explicitly
	// cancel a pending hesitation during a more intricate set of user interactions.
	exportAs: "hesitation"
})
export class HesitateDirective implements OnInit, OnDestroy {

	public duration: number;
	public hesitateEvents: EventEmitter<void>;

	private elementRef: ElementRef;
	private renderer: Renderer2;
	private unlisteners: Function[] | null;
	private timer: any; // TypeScript gets confused if we try to type this.
	private zone: NgZone;

	// I initialize the hesitate directive.
	constructor(
		elementRef: ElementRef,
		renderer: Renderer2,
		zone: NgZone
		) {

		this.elementRef = elementRef;
		this.renderer = renderer;
		this.zone = zone;

		this.duration = 2000;
		this.hesitateEvents = new EventEmitter();
		this.timer = 0;
		this.unlisteners = null;

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I cancel any pending hesitation timer.
	// --
	// NOTE: This is method is PUBLIC so that it may be consumed as part of the EXPORTED
	// API in the View of the calling context.
	public cancel() : void {

		clearTimeout( this.timer );

	}


	// I get called once when the host element is being unmounted.
	public ngOnDestroy() : void {

		this.cancel();

		// If we have event-handler bindings, unbind them all.
		if ( this.unlisteners ) {

			for ( var unlistener of this.unlisteners ) {

				unlistener();

			}

		}

	}


	// I get called once after the host element has been mounted and the inputs have been
	// bound for the first time.
	public ngOnInit() : void {

		// Instead of using host bindings, which would trigger change-detection digests
		// when the events are triggered, we want to drop-down out of the core NgZone so
		// that we can setup our event-handlers without adding processing overhead.
		this.zone.runOutsideAngular(
			() => {

				this.unlisteners = [
					this.renderer.listen( this.elementRef.nativeElement, "mouseenter", this.handleMouseenter ),
					this.renderer.listen( this.elementRef.nativeElement, "mousedown", this.handleMousedown ),
					this.renderer.listen( this.elementRef.nativeElement, "mouseleave", this.handleMouseleave )
				];

			}
		);

	}

	// ---
	// PRIVATE METHODS.
	// ---

	// I handle the mousedown event inside the host element.
	// --
	// CAUTION: Currently OUTSIDE the core NgZone.
	private handleMousedown = ( event: MouseEvent ) : void => {

		// If the user shows any mouse-activity (other than enter/leave) inside the host
		// element, we want to cancel the hesitation timer. Such mouse activity indicates
		// non-hesitation intent on behalf of the user.
		this.cancel();

	};


	// I handle the mouseevent event inside the host element.
	// --
	// CAUTION: Currently OUTSIDE the core NgZone.
	private handleMouseenter = ( event: MouseEvent ) : void => {

		// When the user enters the host, start the hesitation timer. This timer will be
		// fulfilled if the user remains inside of the host without performing any other
		// meaningful actions.
		this.timer = setTimeout( this.handleTimerThreshold, this.duration );

	};


	// I handle the mouseleave event inside the host element.
	// --
	// CAUTION: Currently OUTSIDE the core NgZone.
	private handleMouseleave = ( event: MouseEvent ) : void => {

		this.cancel();

	};


	// I handle the timer threshold event.
	// --
	// CAUTION: Currently OUTSIDE the core NgZone.
	private handleTimerThreshold = () : void => {

		// Once the hesitation timer threshold has been surpassed, we want to trigger an
		// output event. This time, however, we want to trigger Angular change-detection.
		// As such, we have set up into the Angular zone for the emission.
		this.zone.runGuarded(
			() => {

				this.hesitateEvents.emit();

			}
		);

	}

}

As you can see, when the HesitateDirective is being initialized, I setup the event-handlers using the .runOutsideAngular() Zone method. This method ensures that triggering these event-handlers doesn't also trigger the Angular change-detection life-cycle. Then, when the hesitation timer is triggered, we step back into the Angular Zone using the .runGuarded() method. This method handles synchronous errors and ensures that the subsequent .emit() call is picked-up by the a change-detection life-cycle.

By performing this NgZone dance, we limit the degree to which Angular has to respond to browser events. Since we know that the encapsulated "mouseenter", "mousedown", and "mouseleave" events (and the Timer) won't change state in the calling context, we can safely bind these outside of the core NgZone. However, since the EventEmitter method, .emit(), may change state in the calling context, we have to invoke it inside of the NgZone so that such possible state changes are reconciled against the view templates.

That said, if we run this Angular demo and rest the mouse over the "But It" button, we get the following output:

The hesitate directive emits hesitate events in Angular 7.1.4 if the user mouses over and then puases for some period of time.

As you can see, when I mouse over the Button and then leave my mouse in place for over 1,000 ms, the HesitateDirective kicks in and emits the (hesitate) event. The App component then binds to that (hesitate) event and shows the peer pressure message to the user.

Anyway, this was just a fun exploration of Directives in Angular 7.1.4. Most of the time, in a Directive, you don't have to worry about Zone interactions. But, in this case, since we are synthesizing an event from several native Browser events, we want to take care not to trigger unnecessary change-detection cycles for events that we know won't change state in the greater application.

Want to use code from this post? Check out the license.

Reader Comments

15,674 Comments

@All,

I wanted to follow-up this up with a quick experiment to try and create a custom DOM-events plug-in that can configure host-event-bindings to run outside of the core Angular Zone:

www.bennadel.com/blog/3551-creating-a-dom-events-plug-in-that-configures-host-bindings-outside-of-the-angular-zone-in-angular-7-1-4.htm

.... this will allow developers to leverage the clean and elegant template-based event bindings; but, do so with more control over when a change-detection digest is triggered.

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel