Follow along link highlighter using RxJS and Angular

Connie Leung - Jan 23 '23 - - Dev Community

Introduction

This is day 22 of Wes Bos's JavaScript 30 challenge and I am going to use RxJS and Angular to create a highlighter that follows a link when cursor hovers it. The follow along link highlighter updates CSS width, height and transform when mouseenter event occurs on the links of Angular components.

In this blog post, I describe how to use RxJS fromEvent to listen to mouseenter event of anchor elements and update the BehaviorSubject in Highlighter service. Angular components observe the BehaviorSubject and emit CSS width, height and transform to an Observable stream. The stream resolves in the inline template by async pipe and the follow along link highlighter effect occurs.

Create a new Angular project

ng generate application day22-follow-along-link-highlighter
Enter fullscreen mode Exit fullscreen mode

Create Highlighter feature module

First, we create a Highlighter feature module and import it into AppModule. The feature module encapsulates HighlighterPageComponent, HighlighterMenuComponent, HighlighterContentComponent and HighlightAnchorDirective.

Import HighlighterhModule in AppModule

// highlighter.module.ts

import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { HighlightAnchorDirective } from './directives/highlight-anchor.directive';
import { HighlighterContentComponent } from './highlighter-content/highlighter-content.component';
import { HighlighterMenuComponent } from './highlighter-menu/highlighter-menu.component';
import { HighlighterPageComponent } from './highlighter-page/highlighter-page.component';

@NgModule({
  declarations: [
    HighlighterPageComponent,
    HighlightAnchorDirective,
    HighlighterMenuComponent,
    HighlighterContentComponent,
  ],
  imports: [
    CommonModule
  ],
  exports: [
    HighlighterPageComponent
  ]
})
export class HighlighterModule { }
Enter fullscreen mode Exit fullscreen mode
// app.module.ts

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppComponent } from './app.component';
import { CoreModule } from './core';
import { HighlighterModule } from './highlighter';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    HighlighterModule,
    CoreModule,
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
Enter fullscreen mode Exit fullscreen mode

Declare Highlighter components in feature module

In Highlighter feature module, we declare three Angular components, HighlighterPageComponent, HighlighterMenuComponent and HighlighterContentComponent to build the application.

src/app
├── app.component.ts
├── app.module.ts
├── core
│   ├── core.module.ts
│   ├── index.ts
│   └── services
│       └── window.service.ts
└── highlighter
    ├── directives
    │   └── highlight-anchor.directive.ts
    ├── helpers
    │   └── mouseenter-stream.helper.ts
    ├── highlighter-content
    │   └── highlighter-content.component.ts
    ├── highlighter-menu
    │   └── highlighter-menu.component.ts
    ├── highlighter-page
    │   └── highlighter-page.component.ts
    ├── highlighter.interface.ts
    ├── highlighter.module.ts
    ├── index.ts
    └── services
        └── highlighter.service.ts
Enter fullscreen mode Exit fullscreen mode

HighlighterPageComponent acts like a shell that encloses HighlighterMenuComponent and HighlighterContentComponent. For your information, <app-highlighter-page> is the tag of HighlighterPageComponent.

// highlighter-page.component.ts

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { HighlighterService } from '../services/highlighter.service';

@Component({
  selector: 'app-highlighter-page',
  template: `
    <ng-container>    
      <app-highlighter-menu></app-highlighter-menu>
      <app-highlighter-content></app-highlighter-content>
      <ng-container *ngIf="highlightStyle$ | async as hls">
        <span class="highlight" [ngStyle]="hls"></span>
      </ng-container>
    </ng-container>
  `,
  styles: [`...omitted due to brevity ...`],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class HighlighterPageComponent {
  highlightStyle$ = this.highlighterService.highlighterStyle$

  constructor(private highlighterService: HighlighterService) {}
}
Enter fullscreen mode Exit fullscreen mode

HighlighterService is a simple service that stores CSS width, height and transform of follow along link highlighter in a BehaviorSubject.

// highlighter.interface.ts
export interface HighlighterStyle {
    width: string,
    height: string,
    transform: string,
}
Enter fullscreen mode Exit fullscreen mode
// highlighter.service.ts

import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { HighlighterStyle } from '../highlighter.interface';

@Injectable({
  providedIn: 'root'
})
export class HighlighterService {
  private readonly highlighterStyleSub = new BehaviorSubject<HighlighterStyle>({
      width: '0px',
      height: '0px',
      transform: ''
  });
  readonly highlighterStyle$ = this.highlighterStyleSub.asObservable();

  updateStyle(style: HighlighterStyle) {
    this.highlighterStyleSub.next(style);
  }
}
Enter fullscreen mode Exit fullscreen mode

HighlighterMenuComponent encapsulates a menu and each menu item encloses an anchor element whereas HighlighterContentComponent is consisted of several paragraphs with 19 embedded anchor elements.

// highlighter-menu.component.ts

import { ChangeDetectionStrategy, Component, ElementRef, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { Subscription } from 'rxjs';
import { WINDOW } from '../../core';
import { createMouseEnterStream } from '../helpers/mouseenter-stream.helper';
import { HighlighterService } from '../services/highlighter.service';

@Component({
  selector: 'app-highlighter-menu',
  template: `
    <nav>
      <ul class="menu">
        <li><a href="" #home>Home</a></li>
        <li><a href="" #order>Order Status</a></li>
        <li><a href="" #tweet>Tweets</a></li>
        <li><a href="" #history>Read Our History</a></li>
        <li><a href="" #contact>Contact Us</a></li>
      </ul>
    </nav>
  `,
  styles: [`...omitted due to brevity...`],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class HighlighterMenuComponent implements OnInit, OnDestroy {
  @ViewChild('home', { static: true, read: ElementRef })
  home!: ElementRef<HTMLAnchorElement>;

  @ViewChild('order', { static: true, read: ElementRef })
  order!: ElementRef<HTMLAnchorElement>;

  @ViewChild('tweet', { static: true, read: ElementRef })
  tweet!: ElementRef<HTMLAnchorElement>;

  @ViewChild('history', { static: true, read: ElementRef })
  history!: ElementRef<HTMLAnchorElement>;

  @ViewChild('contact', { static: true, read: ElementRef })
  contact!: ElementRef<HTMLAnchorElement>;

  subscription!: Subscription;

  constructor(private highlighterService: HighlighterService, @Inject(WINDOW) private window: Window) {}

  ngOnInit(): void {}

  ngOnDestroy(): void {
    this.subscription.unsubscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode
// highlighter-content.component.ts

import { AfterViewInit, ChangeDetectionStrategy, Component, Inject, OnDestroy, QueryList, ViewChildren } from '@angular/core';
import { Subscription } from 'rxjs';
import { WINDOW } from '../../core';
import { HighlightAnchorDirective } from '../directives/highlight-anchor.directive';
import { createMouseEnterStream } from '../helpers/mouseenter-stream.helper';
import { HighlighterService } from '../services/highlighter.service';

@Component({
  selector: 'app-highlighter-content',
  template: `
    <div class="wrapper">
      <p>Lorem ipsum dolor sit amet, <a href="">consectetur</a> adipisicing elit. Est <a href="">explicabo</a> unde natus necessitatibus esse obcaecati distinctio, aut itaque, qui vitae!</p>
      <p>Aspernatur sapiente quae sint <a href="">soluta</a> modi, atque praesentium laborum pariatur earum <a href="">quaerat</a> cupiditate consequuntur facilis ullam dignissimos, aperiam quam veniam.</p>
      <p>Cum ipsam quod, incidunt sit ex <a href="">tempore</a> placeat maxime <a href="">corrupti</a> possimus <a href="">veritatis</a> ipsum fugit recusandae est doloremque? Hic, <a href="">quibusdam</a>, nulla.</p>
      <p>Esse quibusdam, ad, ducimus cupiditate <a href="">nulla</a>, quae magni odit <a href="">totam</a> ut consequatur eveniet sunt quam provident sapiente dicta neque quod.</p>
      <p>Aliquam <a href="">dicta</a> sequi culpa fugiat <a href="">consequuntur</a> pariatur optio ad minima, maxime <a href="">odio</a>, distinctio magni impedit tempore enim repellendus <a href="">repudiandae</a> quas!</p>
    </div>
  `,
  styles: [`...omitted due to brevty...`],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class HighlighterContentComponent implements AfterViewInit, OnDestroy {

  @ViewChildren(HighlightAnchorDirective)
  anchors!: QueryList<HighlightAnchorDirective>;

  subscription!: Subscription;

  constructor(private highlighterService: HighlighterService, @Inject(WINDOW) private window: Window) { }

  ngAfterViewInit(): void {}

  ngOnDestroy(): void {
    this.subscription.unsubscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

There are 19 anchor elements in this component; it is tedious to include template reference variables and reference them by 19 @ViewChild decorators. Therefore, I declare a HighlightAnchorDirective and pass the directive to @ViewChildren decorator to obtain all references to anchor elements.

// highlight-anchor.directive.ts

import { Directive, ElementRef } from '@angular/core';

@Directive({
  selector: 'a'
})
export class HighlightAnchorDirective {
  nativeElement!: HTMLAnchorElement;
  constructor(el: ElementRef<HTMLAnchorElement>) { 
    this.nativeElement = el.nativeElement;
  }
}
Enter fullscreen mode Exit fullscreen mode

Next, I delete boilerplate codes in AppComponent and render HighlighterPageComponent in inline template.

// app.component.ts

import { Component } from '@angular/core';
import { Title } from '@angular/platform-browser';

@Component({
  selector: 'app-root',
  template: `<app-highlighter-page></app-highlighter-page>`,
  styles: [`
    :host {
      display: block;
    }
  `],
})
export class AppComponent {
  title = '👀👀👀 Day 22 Follow along link highlighter';

  constructor(titleService: Title) {
    titleService.setTitle(this.title);
  }
}
Enter fullscreen mode Exit fullscreen mode

Add window service to listen to scroll event

In order to detect scrolling on native Window, I write a window service to inject to ScrollComponent to listen to scroll event. The sample code is from Brian Love’s blog post here.

// core/services/window.service.ts

import { isPlatformBrowser } from "@angular/common";
import { ClassProvider, FactoryProvider, InjectionToken, PLATFORM_ID } from '@angular/core';

/* Create a new injection token for injecting the window into a component. */
export const WINDOW = new InjectionToken('WindowToken');

/* Define abstract class for obtaining reference to the global window object. */
export abstract class WindowRef {
  get nativeWindow(): Window | Object {
    throw new Error('Not implemented.');
  }
}

/* Define class that implements the abstract class and returns the native window object. */
export class BrowserWindowRef extends WindowRef {

  constructor() {
    super();
  }

  override get nativeWindow(): Object | Window {
    return window;    
  }
}

/* Create an factory function that returns the native window object. */
export function windowFactory(browserWindowRef: BrowserWindowRef, platformId: Object): Window | Object {
  if (isPlatformBrowser(platformId)) {
    return browserWindowRef.nativeWindow;
  }
  return new Object();
}

/* Create a injectable provider for the WindowRef token that uses the BrowserWindowRef class. */
const browserWindowProvider: ClassProvider = {
  provide: WindowRef,
  useClass: BrowserWindowRef
};

/* Create an injectable provider that uses the windowFactory function for returning the native window object. */
const windowProvider: FactoryProvider = {
  provide: WINDOW,
  useFactory: windowFactory,
  deps: [ WindowRef, PLATFORM_ID ]
};

/* Create an array of providers. */
export const WINDOW_PROVIDERS = [
  browserWindowProvider,
  windowProvider
];
Enter fullscreen mode Exit fullscreen mode

Then, we provide WINDOW injection token in CoreModule and import CoreModule to AppModule.

// core.module.ts

import { NgModule } from '@angular/core';

import { CommonModule } from '@angular/common';
import { WINDOW_PROVIDERS } from './services/window.service';

@NgModule({
  declarations: [],
  imports: [
    CommonModule
  ],
  providers: [WINDOW_PROVIDERS]
})
export class CoreModule { }
Enter fullscreen mode Exit fullscreen mode
// app.module.ts

... other import statements ...
import { CoreModule } from './core';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ... other imports ...
    CoreModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
Enter fullscreen mode Exit fullscreen mode

Create helper to make mouseenter stream

Both HighlighterMenuComponent and HighlighterContentComponent listen to mouseenter event to order to emit CSS properties the follow along link highlighter. Therefore, I create a function that accepts HTMLAnchorElements and returns a merge stream of mouseenter events.

import { ElementRef, QueryList } from '@angular/core';
import { fromEvent, map, merge } from 'rxjs';
import { HighlightAnchorDirective } from '../directives/highlight-anchor.directive';

export function createMouseEnterStream(
  elementRefs: ElementRef<HTMLAnchorElement>[] | QueryList<HighlightAnchorDirective>, 
  window: Window
) {
    const mouseEnter$ = elementRefs.map(({ nativeElement }) => 
      fromEvent(nativeElement, 'mouseenter')
        .pipe(
          map(() => {
            const linkCoords = nativeElement.getBoundingClientRect();
            return {
              width: linkCoords.width,
              height: linkCoords.height,
              top: linkCoords.top + window.scrollY,
              left: linkCoords.left + window.scrollX
            };
          })
        ));

    return merge(...mouseEnter$)
      .pipe(
        map((coords) => ({
          width: `${coords.width}px`,
          height: `${coords.height}px`,
          transform: `translate(${coords.left}px, ${coords.top}px)`
        })),
      );    
 }
Enter fullscreen mode Exit fullscreen mode
  • fromEvent(nativeElement, ‘mouseenter’) – listens to mouseenter event of anchor element
  • map – finds the dimensions and top-left point of the anchor element
  • elementRefs maps to mouseEnter$ that is an array of Observable
  • merge(…mouseEnter$) – merges mouseenter Observables
  • map – returns CSS width, height and transform of the anchor element

Use RxJS and Angular to implement HighlighterMenuComponent

I am going to define an Observable, subscribe it and update the BehaviorSubject in the service.

Use ViewChild to obtain references to anchor elements

@ViewChild('home', { static: true, read: ElementRef })
home!: ElementRef<HTMLAnchorElement>;

@ViewChild('order', { static: true, read: ElementRef })
order!: ElementRef<HTMLAnchorElement>;

@ViewChild('tweet', { static: true, read: ElementRef })
tweet!: ElementRef<HTMLAnchorElement>;

@ViewChild('history', { static: true, read: ElementRef })
history!: ElementRef<HTMLAnchorElement>;

@ViewChild('contact', { static: true, read: ElementRef })
contact!: ElementRef<HTMLAnchorElement>;
Enter fullscreen mode Exit fullscreen mode

Declare subscription instance member and unsubscribe in ngDestroy()

subscription = new Subscription();

ngOnDestroy(): void {
    this.subscription.unsubscribe();
}
Enter fullscreen mode Exit fullscreen mode

Subscribe the observable and update BehaviorSubject in HighlighterService.

// highlighter-menu.component.ts

ngOnInit(): void {
    this.subscription = createMouseEnterStream(
        [this.home, this.order, this.tweet, this.history, this.contact], 
        this.window
    ).subscribe((style) => this.highlighterService.updateStyle(style));
}
Enter fullscreen mode Exit fullscreen mode

Use RxJS and Angular to implement HighlighterContentComponent

Use ViewChildren to obtain references to anchor elements

@ViewChildren(HighlightAnchorDirective)
anchors!: QueryList<HighlightAnchorDirective>;
Enter fullscreen mode Exit fullscreen mode

Declare subscription instance member and unsubscribe in ngDestroy()

subscription = new Subscription();

ngOnDestroy(): void {
    this.subscription.unsubscribe();
}
Enter fullscreen mode Exit fullscreen mode

Create mouseenter stream, subscribe it and update the CSS styles to the BehaviorSubject in the service.

// highlighter-content.component.ts

ngAfterViewInit(): void {
    this.subscription = createMouseEnterStream(this.anchors, this.window)
      .subscribe((style) => this.highlighterService.updateStyle(style));
}
Enter fullscreen mode Exit fullscreen mode

The subject invokes HighlighterService to update the BehaviorSubject.

Move the highlighter in HighlighterPageComponent

In HighlighterPageComponent, the constructor injects HighlighterService and I assign this.highlighterService.highlighterStyle$ to highlightStyle$ instance member.

highlightStyle$ = this.highlighterService.highlighterStyle$
Enter fullscreen mode Exit fullscreen mode

In inline template, async pipe resolves highlightStyle$ and updates CSS styles of <span> element. Then, the span element highlights the text of the hovered anchor element.

// highlighter-page.component.ts

<ng-container *ngIf="highlightStyle$ | async as hls">
    <span class="highlight" [ngStyle]="hls"></span>
</ng-container>
Enter fullscreen mode Exit fullscreen mode

Final Thoughts

In this post, I show how to use RxJS and Angular to build a highlighter that moves to the hovered anchor element. Child components create Observables to pass CSS properties to shared HighlighterService. Parent component observes the observable and updates the CSS styles of the span element to produce the effect.

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.

Resources:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .