Prevent Scrolling In A Parent Element Using Directives In Angular 4.4.6
For me, one of the most frustrating user experience (UX) issues in a web application is when I'm scrolling something like a drop-down menu with my mouse-wheel; and, when I hit the local maximum scroll for the drop-down menu, the parent page (or element) begins to scroll and moves the drop-down menu out of view. As a user, this is almost never what I intended to do. And, as a web developer, I'd like to be able to prevent this kind of cascading scroll action. In an Angular 4.4.6 application, we can add this kind of scroll wrangling to our templates with an encapsulated attribute Directive.
Run this demo in my JavaScript Demos project on GitHub.
When I first tried to approach this problem, my instinct was to look at the "scroll" event since "scrolling" was the thing we were trying to prevent. However, the "scroll" event poses two problems in this context: first, it doesn't bubble up through the Document Object Model (DOM), so it's hard to catch without event delegation; and, second, it can't be canceled anyway, which makes the first point completely moot.
It turns out, it's not the "scroll" event that we want to prevent. At least not directly. To prevent unwanted scrolling, we need to prevent the default behavior of the event that preceded a scroll action. In modern browsers, this is the "wheel" event. But, the keyboard can also cause scrolling with keys like Space, ArrowUp, and PageDown (to name a few).
NOTE: If you Google around for scripts like this, you will see a lot of examples that mention the event names: "DOMMouseScroll", "mousewheel", and "MozMousePixelScroll". From what I can see, these are only needed to target really old browsers that are no longer in use. According to the Mozilla Developer Network (MDN), the "wheel" event has across-the-board support, going back to IE9. At the very least, "wheel" seems to work for me in Chrome, Firefox, and Safari (all desktop browsers).
Now, in Angular, when it comes to intercepting and manipulating browser events, it's not enough to just think about the events - we also have to think about Change Detection. By default, when you add an event binding inside an Angular application, you are doing so inside the Angular Zone.js instance. This means that Angular's change-detection algorithm is aware of your event bindings and, Angular will trigger a change-detection digest whenever your event handlers are invoked.
If we're binding to something like a "wheel" event, we can end up triggering a tremendous number of change-detection digests for something that will never actually affect the view-model (making change-detection irrelevant). As such, when we go to listen for "wheel" and "keydown" events in the following directive, we have to set the bindings up outside of the "Angular Zone". This will keep our event handlers quarantined away from Angular's change-detection mechanism.
To explore this concept in Angular 4.4.6, I created an attribute Directive called TrapScrollDirective. By default, this directive will trap and contain wheel events (ie, prevent wheel events from scrolling any ancestor elements). But, it can also trap key events if the optional [trapKeyScroll] attribute is provided.
NOTE: In the following directive, I only care about the vertical scroll direction because, let's face it, horizontal scrolling in an application - at least a Desktop application - is generally an anti-pattern for User Experience (UX). Plus, things that scroll horizontally rarely have cascading-scroll concerns.
// Import the core angular services.
import { Directive } from "@angular/core";
import { ElementRef } from "@angular/core";
import { NgZone } from "@angular/core";
import { OnChanges } from "@angular/core";
import { OnDestroy } from "@angular/core";
import { OnInit } from "@angular/core";
import { SimpleChanges } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
enum Direction {
UP = "up",
DOWN = "down",
NONE = "none"
}
@Directive({
selector: "[trapScroll]",
inputs: [ "trapScroll", "trapKeyScroll" ]
})
export class TrapScrollDirective implements OnInit, OnChanges, OnDestroy {
public trapScroll: boolean | string;
public trapKeyScroll: boolean | string;
private element: HTMLElement;
private zone: NgZone;
// I initialize the trap-scroll directive.
constructor( elementRef: ElementRef, zone: NgZone ) {
this.element = elementRef.nativeElement;
this.zone = zone;
}
// ---
// PUBLIC METHODS.
// ---
// I get called every time the inputs properties are set.
public ngOnChanges( changes: SimpleChanges ) : void {
// Normalize the inputs. Since the inputs can be passed-in as either string-based
// attributes or as property values, we need to funnel both types of input into a
// set of Booleans so that the rest of our logic can be properly typed.
this.trapScroll = this.normalizeInputAsBoolean( this.trapScroll );
this.trapKeyScroll = this.normalizeInputAsBoolean( this.trapKeyScroll );
if ( "trapKeyScroll" in changes ) {
// If the trapping of keyboard-based scrolling is turned on, we want to give
// the element a tabIndex so that it can be focused. This will allow us to
// bind keyboard events directly to the element (as opposed to having to bind
// them to the global object). This will also give the element a :focus
// outline, which is good for accessibility (but can be overridden in the
// parent component styles).
if ( this.trapKeyScroll ) {
this.element.tabIndex = -1; // Focus without tab-based navigation.
} else {
this.element.removeAttribute( "tabIndex" );
}
}
}
// I get called once when the directive is being unmounted.
public ngOnDestroy() : void {
this.element.removeEventListener( "wheel", this.handleEvent, false );
this.element.removeEventListener( "keydown", this.handleEvent, false );
}
// I get called once after the inputs have been bound for the first time.
public ngOnInit() : void {
// Normally, we would add event handlers like this in the host bindings. However,
// if we use the Angular event bindings, they will be run inside of the Angular
// Zone.js instance. Which means that Angular will trigger a change-detection
// digest FOR EVERY WHEEL EVENT (even if we try to detach this directive's change
// detection reference). As such, we need to fall back to the DOM-native event
// binding AND run them OUTSIDE OF THE ANGULAR ZONE. This way, Angular won't try
// to trigger any change detection when our event-handlers are called.
this.zone.runOutsideAngular(
() : void => {
// NOTE: All modern browsers support "wheel". As such, we'll apply this
// as a progressive enhancement and not worry about older browsers.
this.element.addEventListener( "wheel", this.handleEvent, false );
this.element.addEventListener( "keydown", this.handleEvent, false );
}
);
}
// ---
// PRIVATE METHODS.
// ---
// I determine if the given event should be prevented. We'll want to do this if the
// event won't cause local scrolling and may bubble up to cause a scrolling action in
// a parent element.
private eventShouldBePrevented( event: WheelEvent | KeyboardEvent ) : boolean {
var target = <HTMLElement>event.target;
var direction = this.getDirectionFromEvent( event );
// Check for embedded scrolling opportunities.
while ( target !== this.element ) {
// If the event will cause scrolling in an embedded element, then we DO NOT
// want to prevent the default behavior of the event.
if ( this.isScrollableElement( target ) && ! this.isScrolledInMaxDirection( target, direction ) ) {
return( false );
}
target = <HTMLElement>target.parentNode;
}
// If we've made it this far, there weren't any embedded scrollable elements to
// inspect. As such, we can now examine the container. If the event will cause
// scrolling in container element, then we DO NOT want to prevent the default
// behavior of the event.
return( this.isScrolledInMaxDirection( target, direction ) );
}
// I get the direction from the given event.
private getDirectionFromEvent( event: WheelEvent | KeyboardEvent ) : Direction {
if ( event instanceof WheelEvent ) {
return( this.getDirectionFromWheelEvent( event ) );
} else {
return( this.getDirectionFromKeyboardEvent( event ) );
}
}
// I return the normalized scroll direction of the given keyboard event.
private getDirectionFromKeyboardEvent( event: KeyboardEvent ) : Direction {
switch ( event.key ) {
case " ":
return( event.shiftKey ? Direction.UP : Direction.DOWN );
// @ts-ignore: TS7027: Unreachable code detected.
break;
case "ArrowUp":
case "Home":
case "PageUp":
return( Direction.UP );
// @ts-ignore: TS7027: Unreachable code detected.
break;
case "ArrowDown":
case "End":
case "PageDown":
return( Direction.DOWN );
// @ts-ignore: TS7027: Unreachable code detected.
break;
default:
return( Direction.NONE );
// @ts-ignore: TS7027: Unreachable code detected.
break;
}
}
// I return the normalized scroll direction of the given wheel event.
private getDirectionFromWheelEvent( event: WheelEvent ) : Direction {
var delta = ( event.deltaY || event.detail );
return( ( delta <= 0 ) ? Direction.UP : Direction.DOWN );
}
// I handle both Wheel and Keyboard events, and prevent the default behaviors if the
// events would cause scrolling at a higher point in the DOM tree.
// --
// CAUTION: Using fat-arrow binding for class method.
private handleEvent = ( event: WheelEvent | KeyboardEvent ) : void => {
if ( ! this.isTrappingEvent( event ) ) {
return;
}
// Regardless of whether or not we're going to allow this event to be applied
// locally, we want to stop the event from propagating above this container. This
// way, we make sure that an ancestor instance of [trapScroll], higher up in the
// Document Object Model (DOM) tree, doesn't accidentally interfere with the
// default behavior being applied locally.
// --
// CAUTION: This will prevent the ability to perform some kinds of event
// delegation. However, in Angular, event delegation is not used very often.
event.stopPropagation();
// If the given event won't produce a local scroll in the current element or one
// of its local descendants, then let's prevent the default behavior so that the
// event doesn't creating scrolling at a higher level in the DOM.
if ( this.eventShouldBePrevented( event ) ) {
event.preventDefault();
}
}
// I determine if the given element is a Form element that is relevant to key-based
// scrolling events.
private isFormElement( element: HTMLElement ) : boolean {
return(
( element.tagName === "TEXTAREA" ) ||
( element.tagName === "INPUT" ) ||
( element.tagName === "SELECT" )
);
}
// I determine if the given element is scrollable.
private isScrollableElement( element: HTMLElement ) : boolean {
// If the element has an overflow that hides the content, then the scrollHeight
// is still reported as larger than the clientHeight even though no scrolling on
// the element can be performed.
if ( getComputedStyle( element ).overflowY === "hidden" ) {
return( false );
}
// If the scrollHeight is the same as the clientHeight, it should mean that
// there is no content that is outside the visible bounds of the given element.
// Meaning, the element is only scrollable if these values don't match.
return( element.scrollHeight !== element.clientHeight );
}
// I determine if the element is currently scrolled to the maximum value in the
// given direction.
private isScrolledInMaxDirection( element: HTMLElement, direction: Direction ) : boolean {
return(
( ( direction === Direction.UP ) && this.isScrolledToTheTop( element ) ) ||
( ( direction === Direction.DOWN ) && this.isScrolledToTheBottom( element ) )
);
}
// I determine if the current element is scrolled all the way to the bottom.
private isScrolledToTheBottom( element: HTMLElement ) : boolean {
return( ( element.clientHeight + element.scrollTop ) >= element.scrollHeight );
}
// I determine if the current element is scrolled all the way to the top.
private isScrolledToTheTop( element: HTMLElement ) : boolean {
return( ! element.scrollTop );
}
// I determine if the given event is being trapped by the current element.
private isTrappingEvent( event: WheelEvent | KeyboardEvent ) : boolean {
if ( ! this.trapScroll ) {
return( false );
}
if ( event instanceof KeyboardEvent ) {
if ( ! this.trapKeyScroll ) {
return( false );
}
var target = <HTMLElement>event.target;
// Dealing with embedded form elements is rather tricky. Some of the keys
// work as you might expect while other keys, like PageUp and Home, exhibit
// some funky behavior, acting on the page, not on the target element. As
// such, we'll just allow all keyboard events in a form element to work as
// the browser originally intended. And, for the most part, they already
// trap key events.
if ( ( event instanceof KeyboardEvent ) && this.isFormElement( target ) ) {
return( false );
}
// If this is a keyboard event, but the key isn't one that denotes a
// direction, then we won't trap it. This way, we only trap what we need
// to and we let everything else bubble up through the DOM.
if ( this.getDirectionFromKeyboardEvent( event ) === Direction.NONE ) {
return( false );
}
}
return( true );
}
// I return a Boolean coercion for the given Input value.
private normalizeInputAsBoolean( value: any ) : boolean {
return(
// If the associated input attribute was included without any value, it will
// be passed-in as a string. As such, we want to consume the empty string as
// an implicit truthy.
( value === "" ) ||
// If the associated input attribute is being used to set a property, then we
// want to consume it as a Truthy value.
!! value
);
}
}
There's a lot going on here. But, the gist of it is that I inspect "wheel" and "keydown" events at the directive container level. And, if I need the events to precipitate a scroll locally, I let them through. However, if the given event won't lead to a local scroll, I prevent its default behavior so that it doesn't trigger a scroll action higher up in the Document Object Model (DOM).
To see this in action, I've added the [trapScroll] and [trapKeyScroll] attributes to a few scrollable elements in my root component:
// Import the core angular services.
import { Component } from "@angular/core";
import { DoCheck } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "my-app",
styleUrls: [ "./app.component.less" ],
template:
`
<div trapScroll trapKeyScroll class="control-case">
<p>
I trap <strong>wheel & keyboard events</strong>
</p>
<textarea rows="5" columns="50"
>This textarea should be allowed to scroll, within a trap container.
</textarea>
<input type="text" size="50" />
<div class="overflower">
This is overflowing ...<br />
<br /><br /><br /><br /><br />
THis is the bottom.
</div>
</div>
<p class="spacer">
Scroll down to find scrollable elements.
</p>
<!-- By default [trapScroll] will only trap the mouse wheel. -->
<div trapScroll class="outer">
<p>
I trap <strong>wheel events</strong>.
</p>
<!-- Adding [trapKeyScroll] will also trap keyboard scrolling. -->
<div trapScroll trapKeyScroll class="inner">
<p>
I trap <strong>wheel & keyboard events</strong>
</p>
<ul class="spacer">
<li>Space</li>
<li>Shift+Space</li>
<li>ArrowUp</li>
<li>ArrowDown</li>
<li>PageUp</li>
<li>PageDown</li>
<li>Home</li>
<li>End</li>
</ul>
</div>
<p class="spacer">
This is some content.
</p>
</div>
`
})
export class AppComponent implements DoCheck {
// I get called on every change-detection digest. By hooking into this life-cycle
// event method, we can see that the event-bindings in the TrapScrollDirective
// DO NOT TRIGGER change-detection digests in component tree.
public ngDoCheck() : void {
console.log( "ngDoCheck() in App Component." );
}
}
It's hard to tell what's going on in the template. But, the important thing to see is that my AppComponent is hooking into the change-detection life-cycle by providing an ngDoCheck() method. This will be invoked every time the change-detection algorithm runs so we can clearly see that our trapScroll directive operates efficiently outside of the Angular Zone. In fact, if we run this app in the browser and scroll one of the trapScroll containers, we get the following output:
When the app is bootstrapped, the ngDoCheck() method get invoked as the template is reconciled with the initial state of the AppComponent. But, as you can see, when I scroll the element with the [trapScroll] attribute directive, no additional ngDoCheck() methods are invoked. This is because our directive's event handlers were bound outside of the Angular Zone and therefore are not integrated into the change-detection algorithm.
What you can't really see in this screenshot is how the container is trapping the scroll locally and preventing the parent element and the body element from scrolling. For that, you really have to try the demo for yourself or watch the video.
Implementing this kind of scroll-trapping behavior is fairly complicated. I am sure I have some bugs in my attribute Directive and some things that I could have done better. But, it was fun to think about it in in the context of the DOM tree and in the context of Angular's change-detection algorithm. At least now, I have an easy way to create a better user experience (UX) for widgets like drop-down menus and fixed-height content areas.
Want to use code from this post? Check out the license.
Reader Comments
hi,
I have used your example to make a div scrollable. But i have some href inside that div which is creating issue can you please help me out in this.
@All,
So, it looks like this technique - of trapping the scroll behavior in a container - has a positive impact on a Chrome browser bug:
www.bennadel.com/blog/3544-trapping-the-wheel-event-may-prevent-chrome-browser-bug-in-which-the-scroll-wheel-stops-working-in-overflow-container.htm
Essentially, by manipulating the
wheel
event, I can help prevent the Chrome browser from being confused as to which element is "expected" (by the user) to receive the scroll implementation.A masterful piece of coding.
I dropped the directive code into my app and it worked perfectly!
(tslint recommended @Input() instead of Directive({inputs: ...}), though.)
An enhancement I'd like to see is the ability to selectively apply trapScroll via a run-time decision in Typescript.
Thanks for posting this solution!
@Tim,
Awesome, glad you found this helpful! That's really interesting that ts-lint wants the inline-decorators. That's just a stylistic choice, so whichever you prefer is good for me. I personally like to see all my meta-data at the top in once place (helps prime my brain for how the rest of the class is going to be used). But, to each their own.