Polyfilling The Second-Click Of A RouterLink Fragment In Angular 7.1.1
UPDATE: In the comments of this post, Vasiliy Mazhekin points out that the second-click of the fragment can be enabled if you set the Router ExtraOptions configuration property of "onSameUrlNavigation" to "reload". This configuration will cause the navigation events to fire even when navigating to the same URL. And, since the fragment feature is powered by the navigation events, this will cause the fragment to work as one would expect.
Angular 7.1.1 has basic fragment (aka, anchor) support for the RouterLink directive. However, it only works on the first click of the RouterLink. That is, it only works when RouterLink click causes a change in the Router navigation. This means that a click of the RouterLink for a second time (or any subsequent time) is ignored because the URL doesn't actually change. To polyfill this use-case, we can supply our own RouterLink directive that augments the existing behavior such that the second-click, which is ignored by the native RouterLink, is picked up by our RouterLink directive and will scroll the user down to the anchor target using the provided ViewportScroller service.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
This polyfill works by leveraging the interplay of the Router, the RouterLink directive, and the DOM (Document Object Model) tree. Specifically, it takes advantage of two behavioral facts of the Angular 7 implementation:
If the RouterLink is going to intercept the user's click, it prevents the default behavior of the click event. This is true even if the Router isn't going to perform any navigation.
Some of the Router events are emitted after the RouterLink click-handler is executed but before the click-event bubbles up to the next layer of the DOM tree.
For this second point, we can see this behavior if we add a click-handler on both a RouterLink node and its parent node and then turn on router-tracing:
As you can see, the NavigationStart event is triggered as part of the native RouterLink click-handler (which will execute before our click-handler). Then, the rest of the Router events are emitted after our custom click-handler is invoked, but before the click event bubbles up to our click-handler on the parent node.
Give this behavior, we can track the click-event at the RouterLink level and then inspect the click-event once it bubbles up one layer in the DOM tree. At that point, we can polyfill the fragment behavior if two characteristics are present:
The RouterLink has prevented the default behavior of the click-event. This would indicate that the RouterLink knew that the click was relevant. Which means, it may be relevant to us as well.
No Router navigation events have been emitted. This would indicate that the URL didn't change, despite the fact that RouterLink was clicked. This, combined with the point above, is exactly the case in which the fragment link doesn't work.
To do all this, I have created a FragmentPolyfillDirective that selects on a[routerLink][fragment] and adds some click-handling behavior:
// Import the core angular services.
import { Directive } from "@angular/core";
import { ElementRef } from "@angular/core";
import { Event as NavigationEvent } from "@angular/router";
import { OnDestroy } from "@angular/core";
import { OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { Subscription } from "rxjs";
import { ViewportScroller } from "@angular/common";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Directive({
selector: "a[routerLink][fragment]",
inputs: [ "fragment" ],
host: {
"(click)": "handleClick( $event )"
}
})
export class FragmentPolyfillDirective implements OnInit, OnDestroy {
public fragment: string;
private clickEvent: any;
private elementRef: ElementRef;
private eventsSubscription: Subscription;
private router: Router;
private viewportScroller: ViewportScroller;
// I initialize the fragment polyfill directive.
constructor(
elementRef: ElementRef,
router: Router,
viewportScroller: ViewportScroller
) {
this.elementRef = elementRef;
this.router = router;
this.viewportScroller = viewportScroller;
this.clickEvent = null;
this.fragment = "";
}
// ---
// PUBLIC METHODS.
// ---
// I handle the click on the router-link.
public handleClick( event: any ) : void {
// If there is no fragment associated with this routerLink, just ignore click.
if ( ! this.fragment ) {
return;
}
// Because Angular 6+ has basic fragment support already, the goal of this
// polyfill is only to augment the existing support with 2nd-click functionality.
// That is, to scroll the user to the target element even if the click doesn't
// result in a navigational change. In order to do this, we have to track two
// different facets of the click-event life-cycle:
// --
// One - We have to make sure the click event has its default behavior prevented.
// This will indicate that the core routerLink directive (on this same element)
// has intercepted the click and is intending to hijack the normal behavior.
// --
// Two - We have to make sure that the click event has not resulted in a Router
// navigation. This will indicate that the URL has not changed, which is exactly
// the edge-case that we need to polyfill.
// --
// These sound basic; but, are actually somewhat difficult to do at this same
// level of the DOM. As such, we're going to keep track of the click event and
// then inspect once again when the click bubbles up one level in the DOM tree.
this.clickEvent = event;
}
// I handle the click of the router-link one level-up in the DOM tree.
public handleClickAtParentLevel = () : void => {
// At this point, if the event is still being tracked (ie, no Router navigation
// has been detected) and the event's default behavior is being prevented (ie,
// the native Router link is hijacking the user experience), then we know we have
// the edge-case we need to polyfill.
if ( this.clickEvent && this.clickEvent.defaultPrevented ) {
console.warn( "Using fragment polyfill." );
this.clickEvent = null;
this.viewportScroller.scrollToAnchor( this.fragment );
}
}
// I get called when the directive is being unmounted.
public ngOnDestroy() : void {
// Only clean-up if the destroy is called after the init.
if ( this.eventsSubscription ) {
this.eventsSubscription.unsubscribe();
this.elementRef.nativeElement.parentNode.removeEventListener( "click", this.handleClickAtParentLevel, false );
this.clickEvent = null;
}
}
// I get called once after the inputs have been bound for the first time.
public ngOnInit() : void {
// This polyfill works by tracking the Router Link event as it bubbles-up in the
// DOM tree (one level). However, we only want to polyfill the edge-case in which
// a click does NOT RESULT IN A NAVIGATION change (as that will be handled by the
// Router itself). As such, we want to stop tracking the click-event whenever we
// observe any of the navigation events.
// --
// NOTE: This approach works because all of the Navigation events (other than
// NavigationStart) are triggered AFTER the local click event-handlers, but
// BEFORE THE EVENT BUBBLES UP to the next level of the DOM tree (where our
// secondary event-handler will be waiting).
this.eventsSubscription = this.router.events.subscribe(
() => {
this.clickEvent = null;
}
);
// Start listening for the click event one level up in the DOM.
this.elementRef.nativeElement.parentNode.addEventListener( "click", this.handleClickAtParentLevel, false );
}
}
As you can see, this polyfill directive adds two click-handlers: one on the RouterLink element and one on the parent node. On the RouterLink element, it tracks the click event; then, on the parent node, it inspects the click event for the characteristics outlined above. And, if we detect the edge-case in which the native RouterLink doesn't work, we explicitly scroll the viewport down to the anchor target.
Now, we just have to include this directive wherever we also include the RouterModule directives:
// Import the core angular services.
import { BrowserModule } from "@angular/platform-browser";
import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";
// Import the application components and services.
import { AppComponent } from "./app.component";
import { FragmentPolyfillDirective } from "./fragment-polyfill.directive";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@NgModule({
imports: [
BrowserModule,
RouterModule.forRoot(
[],
{
// Tell the router to use the hash instead of HTML5 pushstate.
useHash: true,
// Enable the Angular 6+ router features for scrolling and anchors. The
// polyfill will assume that the basic fragment support already exists.
scrollPositionRestoration: "enabled",
anchorScrolling: "enabled",
enableTracing: false
}
)
],
declarations: [
AppComponent,
// You'll need to include this directive in a shared module; or, anywhere you
// also include the RouterModule directives.
FragmentPolyfillDirective
],
providers: [
// CAUTION: We don't need to specify the LocationStrategy because we are setting
// the "useHash" property in the Router module above (which will be setting the
// strategy for us).
// --
// {
// provide: LocationStrategy,
// useClass: HashLocationStrategy
// }
],
bootstrap: [
AppComponent
]
})
export class AppModule {
// ...
}
Now, with the polyfill directive in place, we can see the fragment clicks in action:
This is much easier to see in the video; but, the first click of any fragment is managed by the native RouterLink directive. The second click - and any subsequent click - is then intercepted by our polyfill directive and the viewport is scrolled explicitly.
Hopefully Angular will add support for subsequent clicks of a fragment-oriented RouterLink directive. However, in the meantime, I hope this polyfill directive offers people some help.
Want to use code from this post? Check out the license.
Reader Comments
@Ben,
thanks for creating this.
does it also supports in angular 5 or not ?
else how we can implement same in angular 5 ?
i am using your existing Jump-To-Anchor Fragment Polyfill In Angular 5.2.0.
This polyfill is easy replaced with option: scrollPositionRestoration: 'enabled',
@Vasiliy,
Wow, very interesting! I just confirmed this for myself and it does, indeed work. I have no idea why, though :) Let me look at the source code to understand why.
@All,
As a quick follow-up, here's some links that I found that go into this feature a little more in-depth:
It looks like the
onSameUrlNavigation
property is designed to re-trigger the Navigation Events (and, in conjunction withrunGuardsAndResolvers
router property, re-run Guards and Resolvers for a route).So, this is different than the somewhat related concept of the
routeReuseStrategy
, which determines whether or not Angular should destroy and re-create a component when local router-parameters change.@Vasiliy,
I've added a comment to the top of the post with your insights. Thank you very much for showing us the more straightforward approach!
i have mistaken -> onSameUrlNavigation: 'reload'
@Vasiliy,
Thank you again for your insights - I have tried to document it for my own reference:
www.bennadel.com/blog/3545-enabling-the-second-click-of-a-routerlink-fragment-using-onsameurlnavigation-reload-in-angular-7-1-3.htm
I also appreciate knowing about the
onSameUrlNavigation
setting as there may be other users for it that aren't immediately obvious to me.I think it will be better to use click handler with
capture
phase to avoid this parent click handling.