Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at cf.Objective() 2009 (Minneapolis, MN) with: Jean Ducrot
Ben Nadel at cf.Objective() 2009 (Minneapolis, MN) with: Jean Ducrot

Creating A Jump-To-Anchor Fragment Polyfill In Angular 5.2.0

By Ben Nadel on

On top of navigating from view-to-view, the Angular 5 Router provides a lot of advanced features like lazy-loading modules, data-resolvers, and route-guards. But, ironically, it doesn't support one of the oldest and most basic browser navigation features: jumping to an anchor farther down on a given page. The GitHub issue for this "bug" has been tracked for about a year; but, no "official" fixes have come out. As such, people have been trying to come up with work-arounds, generally revolving around subscribing to changes in the ActivatedRoute's fragment property. I, too, need a work-around for this in my Router deep-dive; so, I too wanted to try and come up with "polyfill" for this behavior.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

If you look at the various suggestions in the GitHub issue above, they either use an alternate syntax hack (like trying to use the "href" property); or, they add additional logic to the root component that monitors the fragment and tries to reconcile it against the checked-view content. In each of these cases, the actual code of the application has be changed in order to implement the jump-to-anchor workaround.

I wanted to try and go a different route (no pun intended). Instead of adding any new logic to my components, I wanted to see if I could create a "polyfill" approach - something that would work seamlessly behind the scenes; and then, could be easily removed once Angular added official support for fragment navigation.

To do this, I thought about what situations actually cause the browser to jump down (or up) to a different location on the selected view. As best as I can remember, the fragment-navigation will be applied if there is:

So, essentially, the following selectors under the right circumstances:

  • a[name]
  • [id]

With this in mind, we can create an Angular Directive that matches on the composite value of the two selectors:

selector: "[id], a[name]"

This directive can then monitor the active fragment value. And, if the fragment value ever matches the directive's own input value (either "id" or "name"), the directive can ask the Window to scroll its Element Reference into view. With such a directive, we could implement jump-to-anchor functionality without actually changing any of the code in our application.

To do this, I created a FragmentPolyfillModule that encapsulates the future-removable code. This module provides two core facets:

  • The ([id],a[name]) directive that binds to rendered anchors.
  • A "WindowScroller" implementation that powers the actual jump to the targeted element.

I broke these two pieces apart so that you could provide a customer scroller implementation on top of the supplied directive. Out of the box, I'm using the browser's native .scrollIntoView() method. However, you could easily provide custom logic. Or, even use something like a jQuery plug-in.

Now, before we look at the module, let's look at the application code that consumes the fragment-based navigation. First, the root component:

  • // Import the core angular services.
  • import { Component } from "@angular/core";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Component({
  • selector: "my-app",
  • styleUrls: [ "./app.component.less" ],
  • template:
  • `
  • <p>
  • <a routerLink="/">Home View</a><br />
  • <br />
  •  
  • <a routerLink="/app/a">A View</a> &mdash;
  • <a routerLink="/app/a" fragment="top">A View #top</a> &mdash;
  • <a routerLink="/app/a" fragment="bottom">A View #bottom</a><br />
  •  
  • <a routerLink="/app/b">B View</a> &mdash;
  • <a routerLink="/app/b" fragment="top">B View #top</a> &mdash;
  • <a routerLink="/app/b" fragment="bottom">B View #bottom</a><br />
  • </p>
  •  
  • <p>
  • <strong>Home View</strong>
  • </p>
  •  
  • <router-outlet></router-outlet>
  • `
  • })
  • export class AppComponent {
  • // ...
  • }

As you can see, the root component uses a combination of "routerLink" and "fragment" properties to jump from view-to-view. Both "View A" and "View B" are essentially copies of each other:

  • // Import the core angular services.
  • import { Component } from "@angular/core";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Component({
  • selector: "a-view",
  • styleUrls: [ "./a-view.component.less" ],
  • template:
  • `
  • <hr id="top" />
  •  
  • <p>
  • <strong>A View</strong>
  • </p>
  •  
  • <p class="content">
  • <a routerLink="." fragment="bottom">Jump to bottom</a>
  • </p>
  •  
  • <a name="bottom"></a>
  •  
  • <p>
  • This is the bottom of <strong>A-view</strong>.
  • <a routerLink="." fragment="top">Back to top</a>.
  • </p>
  • `
  • })
  • export class AViewComponent {
  • // ...
  • }

As you can see, we are again using "routerLink" and "fragment" properties to define anchor navigation. But, in this case, we're also providing two anchor implementations: one anchor tag with a "name" property and one HR tag with an "id" property.

The main take-away here should be that there is nothing special about any of these components. They consume the Router directives in a natural way - no extra code, no obvious work-arounds.

Now, let's look at the application module:

  • // Import the core angular services.
  • import { BrowserModule } from "@angular/platform-browser";
  • import { NgModule } from "@angular/core";
  • import { RouterModule } from "@angular/router";
  • import { Routes } from "@angular/router";
  •  
  • // Import the application components and services.
  • import { AppComponent } from "./app.component";
  • import { AViewComponent } from "./a-view.component";
  • import { BViewComponent } from "./b-view.component";
  • import { FragmentPolyfillModule } from "./fragment-polyfill.module";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • var routes: Routes = [
  • {
  • path: "app",
  • children: [
  • {
  • path: "a",
  • component: AViewComponent
  • },
  • {
  • path: "b",
  • component: BViewComponent
  • }
  • ]
  • },
  •  
  • // Redirect from the root to the "/app" prefix (this makes other features, like
  • // secondary outlets) easier to implement later on.
  • {
  • path: "",
  • pathMatch: "full",
  • redirectTo: "app"
  • }
  • ];
  •  
  • @NgModule({
  • bootstrap: [
  • AppComponent
  • ],
  • imports: [
  • BrowserModule,
  • FragmentPolyfillModule.forRoot({
  • smooth: true
  • }),
  • RouterModule.forRoot(
  • routes,
  • {
  • // Tell the router to use the HashLocationStrategy.
  • useHash: true,
  • enableTracing: false
  • }
  • )
  • ],
  • declarations: [
  • AppComponent,
  • AViewComponent,
  • BViewComponent
  • ],
  • providers: [
  • // CAUTION: We don't need to specify the LocationStrategy because we are setting
  • // the "useHash" property in the Router module above.
  • // --
  • // {
  • // provide: LocationStrategy,
  • // useClass: HashLocationStrategy
  • // }
  • ]
  • })
  • export class AppModule {
  • // ...
  • }

If you look at the "imports" section, you can see that I am import the FragmentPolyfillModule:

FragmentPolyfillModule.forRoot({ smooth: true })

This module is seamlessly providing the work-around for the fragment-based navigation. Now, when Angular core eventually adds the missing functionality - assuming they don't change the syntax - all we have to do is remove the FragmentPolyfillModule from the imports section and the application continues to work as expected.

Now that we've laid the ground-work for the integration, let's take a look at the module. For the sake of simplicity, I've included all aspects of the module in a single file. The downside of this approach is that the order of the classes is not ideal. Normally, I'd love to have the NgModule at the top to give a sense of what the file is providing; but, I had to put it at the bottom so that it could consume the other classes.

The order of the classes is as follows:

  • WindowScroller - This is the physical-scrolling implementation that will be injected into the fragment-target directives.
  • FragmentTargetDirective - This is the directive that binds to fragment-targets ([id], a[name]) and requests scrolling.
  • FragmentPolyfillModule - This is the module that packages it all together.

Here's the module code:

  • // Import the core angular services.
  • import { ActivatedRoute } from "@angular/router";
  • import { Directive } from "@angular/core";
  • import { ElementRef } from "@angular/core";
  • import { Inject } from "@angular/core";
  • import { InjectionToken } from "@angular/core";
  • import { ModuleWithProviders } from "@angular/core";
  • import { NgModule } from "@angular/core";
  • import { OnDestroy } from "@angular/core";
  • import { OnInit } from "@angular/core";
  • import { Subscription } from "rxjs/Subscription";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • export interface WindowScrollerOptions {
  • smooth: boolean;
  • }
  •  
  • export var WINDOW_SCROLLER_OPTIONS = new InjectionToken<WindowScrollerOptions>( "WindowScroller.Options" );
  •  
  • // I provide the dependency-injection token for the window-scroller so that it can be
  • // more easily injected into the FragmentTarget directive. This allows other developers
  • // to provide an override that implements this Type without have to deal with the silly
  • // @Inject() decorator.
  • export abstract class WindowScroller {
  • abstract scrollIntoView( elementRef: ElementRef ) : void;
  • }
  •  
  • // I provide an implementation for scrolling a given Element Reference into view. By
  • // default, it uses the native .scrollIntoView() method; but, it can be overridden to
  • // use something like a jQuery plug-in, or other custom implementation.
  • class NativeWindowScroller implements WindowScroller {
  •  
  • private behavior: "auto" | "smooth";
  • private timer: number;
  •  
  • // I initialize the window scroller implementation.
  • public constructor( @Inject( WINDOW_SCROLLER_OPTIONS ) options: WindowScrollerOptions ) {
  •  
  • this.behavior = ( options.smooth ? "smooth" : "auto" );
  • this.timer = null;
  •  
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I scroll the given ElementRef into the client's viewport.
  • public scrollIntoView( elementRef: ElementRef ) : void {
  •  
  • // NOTE: There is an odd race-condition that I cannot figure out. The initial
  • // scrollToView() will not work when the BROWSER IS REFRESHED. It will work if
  • // the page is opened in a new tab; it only fails on refresh (WAT?!). To fix this
  • // peculiarity, I'm putting the first scroll operation behind a timer. The rest
  • // of the scroll operations will initiate synchronously.
  • if ( this.timer ) {
  •  
  • this.doScroll( elementRef );
  •  
  • } else {
  •  
  • this.timer = setTimeout(
  • () : void => {
  •  
  • this.doScroll( elementRef );
  •  
  • },
  • 0
  • );
  •  
  • }
  •  
  • }
  •  
  • // ---
  • // PRIVATE METHOD.
  • // ---
  •  
  • // I perform the scrolling of the viewport.
  • private doScroll( elementRef: ElementRef ) : void {
  •  
  • elementRef.nativeElement.scrollIntoView({
  • behavior: this.behavior,
  • block: "start"
  • });
  •  
  • }
  •  
  • }
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Directive({
  • selector: "[id], a[name]",
  • inputs: [ "id", "name" ]
  • })
  • export class FragmentTargetDirective implements OnInit, OnDestroy {
  •  
  • public id: string;
  • public name: string;
  •  
  • private activatedRoute: ActivatedRoute;
  • private elementRef: ElementRef;
  • private fragmentSubscription: Subscription;
  • private windowScroller: WindowScroller;
  •  
  • // I initialize the fragment-target directive.
  • constructor(
  • activatedRoute: ActivatedRoute,
  • elementRef: ElementRef,
  • windowScroller: WindowScroller
  • ) {
  •  
  • this.activatedRoute = activatedRoute;
  • this.elementRef = elementRef;
  • this.windowScroller = windowScroller;
  •  
  • this.id = null;
  • this.fragmentSubscription = null;
  • this.name = null;
  •  
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I get called once when the directive is being destroyed.
  • public ngOnDestroy() : void {
  •  
  • ( this.fragmentSubscription ) && this.fragmentSubscription.unsubscribe();
  •  
  • }
  •  
  •  
  • // I get called once after the inputs have been bound for the first time.
  • public ngOnInit() : void {
  •  
  • this.fragmentSubscription = this.activatedRoute.fragment.subscribe(
  • ( fragment: string ) : void => {
  •  
  • if ( ! fragment ) {
  •  
  • return;
  •  
  • }
  •  
  • if (
  • ( fragment !== this.id ) &&
  • ( fragment !== this.name )
  • ) {
  •  
  • return;
  •  
  • }
  •  
  • this.windowScroller.scrollIntoView( this.elementRef );
  •  
  • }
  • );
  •  
  • }
  •  
  • }
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • interface ModuleOptions {
  • smooth?: boolean;
  • }
  •  
  • @NgModule({
  • exports: [
  • FragmentTargetDirective
  • ],
  • declarations: [
  • FragmentTargetDirective
  • ]
  • })
  • export class FragmentPolyfillModule {
  •  
  • static forRoot( options?: ModuleOptions ) : ModuleWithProviders {
  •  
  • return({
  • ngModule: FragmentPolyfillModule,
  • providers: [
  • {
  • provide: WINDOW_SCROLLER_OPTIONS,
  • useValue: {
  • smooth: ( ( options && options.smooth ) || false )
  • }
  • },
  • {
  • provide: WindowScroller,
  • useClass: NativeWindowScroller
  • }
  • ]
  • });
  •  
  • }
  •  
  • }

Now, because this module is providing directives, it means that this module has to be imported into any other module that uses anchor tags (and wants to use the polyfill). Of course, if you import this into your application's "Shared Module" (and then re-export it), this won't be an issue.

Once this is all in place, we can use fragments in our router logic. Fragment navigation isn't something that I can easily capture in a screenshot (watch the video). But, if I open this application in the browser and click on one of the "#top" link, you can see that my browser scrolls down to the correct [id]="top" element:


 
 
 

 
 Creating a jump-to-fragment polyfill for the Angular 5 router. 
 
 
 

Again, it's much easier to see in the video; but, in this screenshot, you can see that the page has been scrolled to just above the horizontal-rule (hr). This horizontal rules has an "id" property of "top", which means that it is bound to the FragmentTargetDirective, which, in turn, triggers the scrolling.

This isn't a perfect solution. It creates a small number of subscriptions to the Fragment stream. And, there are edge-cases in which a scroll will be triggered unexpectedly (for example if you suddenly reveal an element with an ID that matches the current fragment). However, neither of these issues concern me very much. And, to me, the trade-off is quite worth not having any additional logic in the application code. Now, the polyfill can be in place for as long as it's needed; and then, can be removed when it is no longer needed.

I love that you can bind a directive to any selector in the DOM. It is such a powerful feature of Angular.




Reader Comments

Great idea, but could use a rewrite. Takes ages to follow and implement. It's about anchor links and shouldn't take an hour to do :) Please revise, it's really a good piece otherwise

Reply to this Comment

OK, I've implemented it. Was actually really easy - just had to ignore all the text :P ;)

Here's a helper for the TL;DR generation:

Step 1) Import the polyfill as is. Fix the type error by removing number decelerator

Step 2) Change existing "#" href links to <a routerLink="/yourUrl/whatever" fragment="anchorName"

Step 3) Thank this guy for a really cool, nicely working polyfill

Thanks, Ben! :) Works like a charme

Reply to this Comment

Just one more comment (it's so tempting to comment and think in Schwarzenegger's voice.."to the CHOPPER!!! everybody get to the choopppperrrrr")

Just an observation: Anchors are old-school. They have been around for decades and are super useful, even in SPAs with long pages (everyone scrolls these days on mobile, it is expected behavior, in 2005 when everyone was against scrolling I could have understood it).

It's ---incredible--- how the Angular team dropped the ball on this one. I've tried a few things before, from intercepting #s. And previously, before I dropped Ionic [just a terrible, bloated, badly documented framework - I recommend everyone to stay away from it], I did it differently again. It's just a pain to see the Angular team not fixing this faster. It's been several years since the community requested this and has been even an issue in Angular1. You cannot have a framework in which there are these weird issues popping up and not being fixed. Angular is great, and I am sticking with it, but the time it takes to create a simple navigation compared to basic html (seconds vs hours) is not acceptable.

Thanks again, Ben. Made my life much easier. The polyfill is a great approach!

Reply to this Comment

Step 4) If you get the error below during build, just add an "export" in front of the NativeWindowScroller class in the polyfill. Ben, please correct me if I'm wrong.

ERROR in Error during template compile of 'AppModule'
References to a non-exported class are not supported in decorators but NativeWindowScroller was referenced. in 'FragmentPolyfillModule'
'FragmentPolyfillModule' references non-exported class NativeWindowScroller at src\fragment-polyfill.module.ts(33,1) Consider exporting 'NativeWindowScroller'.

Reply to this Comment

P.S. For those who do not wish to use the "#" Path Strategy (which I guess is most, because it IS ugly and confuses users), just set

useHash: false

it will still work.

Ben, again, please correct if wrong :)
Sorry for the spam, just working through it

Reply to this Comment

@Mark,

No worries -- this stuff is complicated, especially when trying to jump around a feature that should "just work." Regarding the "export" before the NativeWindowScroller, you would need that if you were breaking things up into different files. In this particular demo, it was all just in one file, so the export shouldn't be needed, as far as I know. But, if you also have it all in one file, perhaps its an Angular version issue? In any way, I appreciate you documenting the error.

It's a shame you've had trouble with Ionic. I haven't used it myself, but I've heard good things. It's on my list of things to try ... eventually :)

Speaking of trying to build in features, the next thing I'm struggling with is maintaining scroll offsets when you hit the Back / Forward button. In a "normal" web page, the browser just does this for you because it renders cached pages (my understanding). But, with an SPA framework like Angular, the browser can't do that because the framework is doing all the DOM construction.

Digging into all of this stuff has been super frustrating, but also exciting.

Reply to this Comment

@Mark,

Oh, and it's _never_ wrong to want to yell, "get to the choopppperrrrr!".

Do it! Do it! Come on, kill me! I'm over here!

Reply to this Comment

@Mark,

Oh, and regarding "useHash", in the Router configuration, you are correct. This feature is broken with both the hash and non-hash driven navigation. And, the polyfill should work in both cases (from the brief testing I did).

As an aside, the reason I use "useHash" for all my demos is that they have to run on GitHub, where I have no server. So, if I used the pushState approach, it would work on first load, but would result in a 404 on any page refresh. So, I agree that it is less attractive; but, is somewhat necessary for my particular context.

Reply to this Comment

@Ben,

honestly, Ionic was probably the biggest waste of time in my entire life. I started with it about 2 years ago. Went through the beta phase of ionic 2, terrible, terrible documentation, buggy code etc. It has gotten better over time, but honestly, I don't see any purpose in keeping it. It is basically an accumulation of things like hammer, swiper etc. and then adds A TON of bugs to it, that you struggle to fix because of the bad documentation and bloat. It took me a week to get rid of it, but I saved more than 300KB! and still have most of the things (e.g. swiper) I need. Incredible. Time. Waste. And. Bloat. And. Bugs!

For the few components it offers (you know, the occasional cute looking switch button) it is so not worth it. Just design it yourself, it's not that hard, and you save so much time and file size.

The problem with ionic and other terrible frameworks is, that they make you use all of their stuff, it's so easy to write <ion-slides> instead of implementing swiper yourself (10 sec vs say 2min), but ultimately you regret it because you -will- drop it in the end once you need performance, and then you spend a week for getting rid of it all and fixing the CSS issues.

Since I dropped ionic my fps for scrolling and other actions went through the roof.

Also, I no longer have everything encapsulated in a separate div, rather than the body. On many mobile browsers this causes a loss of screen space, as for example on samsung devices, the default browser comes with a tabbed navigation at the bottom screen that will not disappear if body doesn't scroll. Everything ionic does is make your life painful.

Honestly think about what you really need. Performance and small file sizes.

What you don't need: Bad documentation, breaking updates, terrible support and terrible reaction time by the team. And then on top, it will become difficult to find out what's causing the bug.. is it ionic or Angular? Most of the time it was ionic. But you loose that time on top.

Never. again.

:))

Reply to this Comment

@Ben,

right, regarding the 404, I just match-all ** and redirect to index.html. Works nice on S3/Cloudfront, haven't tried on github yet.. this way, for development, you at least won't get an annoying 404..

Reply to this Comment

I'm struggling to see the need for the Directive.
I have implemented something similar, listen to the ActivatedRoute fragment's, then do a document.querySelector, and scroll to the element if it exists. Don't see the need to have a directive wrapping every [id] element

Reply to this Comment

@Brian,

If you want to add the code to your application, then I agree - you don't need to the directive. My intention was to be able to provide something that was completely transparent (ie, only added as module, but is not actually added to - for example - your root component's logic).

That said, I do think the directive approach does have an added bonus: being able to link to an element that is not on the page the moment after the route changes. Imagine that you are linking to a view that is data-driven and the data takes some time to load and therefore your [id] target is not on the page immediately. You could work that logic into your post-fragment-change logic, no doubt; but, by inverting the relationship, you can allow the anchor to "scroll to itself" when the content loads.

Both ways have pros and cons, I am sure.

Reply to this Comment

@Mark,

That's super interesting about the tabbed interface not disappearing unless the body scrolls - I had never considered that, but that would be super annoying for sure.

I guess part of what I always thought Ionic was for was to help with "native" app development (ie, packaging your web-app inside a WebView inside a native app wrapper). Maybe that's cause Cordova / PhoneGap and Ionic were often used together?

Right now, I'm hoping to start looking at "Progressive Web Apps", which I think overlaps a lot with the whole PhoneGap concept. That said, I'm on iOS, and only Safari can "save to the home screen" and Safari doesn't support Progressive Web Apps yet ... meh, Apple!

Reply to this Comment

Thanks for the code and post Ben, just wanted to point out that I had to inject this directly into my lazy-loaded child module to get it to work in that module - it wouldn't work if I just injected it into the root module.

Reply to this Comment

@Ben,

Very interesting! I have to admit I have no experience or insights when it comes to lazy-loaded modules. Do you have a sense of why you had to inject it directly into the lazy-loaded components? Do they not inherit from the root dependency-injection container? I know that a lazy-loaded module has it _own_ DI container that is below the root one. But, I don't have a good sense of how they all interact.

Reply to this Comment

Thank you very much "Mr. BEN NADEL". The post is very useful, i have been thinking of a way to do it until i taught of researching on google which i actually did and found your website link with the exact and best solution to my problem. You are really good. thumbs up! and keep it up.

Thanks to others that commented as well, your comments are very useful too.

Reply to this Comment

Gave it a try but seemed to have no luck (@Mark's suggestions came in useful).
However apparently the Angular may finally be applying their wisdom to bring the webs oldest native interaction to the modern web. I to am baffled by it's omittance given it's even widely used in their docs.
https://github.com/angular/angular/pull/20030

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.