Click and slide HTML elements using RxJS and Angular

Reading Time: 5 minutes

 57 total views

Introduction

This is day 27 of Wes Bos’s JavaScript 30 challenge and I am going to use RxJS and Angular to click and slide a series of div elements. When mouse click occurs, I add a CSS class to the parent <div> element to perform scale transformation. When mouse is up or it exits the browser, I remove the class to undo the CSS transformation.

In this blog post, I describe how to use RxJS fromEvent to listen to mousedown event and emit the event to concatMap operator. In the callback of concatMap, it streams mousemove events, updates the scrollLeft property of the div element and flattens the inner Observables. Moreover, the Angular component resolves another Observable in the inline template in order to toggle CSS class in ngClass property.

let's go

Create a new Angular project

ng generate application day27-click-and-drag

Create Slider feature module

First, we create a Slider feature module and import it into AppModule. The feature module encapsulates SliderComponent with a list of <div> elements.

Import SliderModule in AppModule

// slider.module.ts

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SliderComponent } from './slider/slider.component';

@NgModule({
  declarations: [
    SliderComponent
  ],
  imports: [
    CommonModule
  ],
  exports: [
    SliderComponent
  ]
})
export class SliderModule { }
// app.module.ts

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

import { AppComponent } from './app.component';
import { SliderModule } from './slider';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    SliderModule,
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Declare Slider component in feature module

In Slider feature module, we declare SliderComponent to build the application. SliderComponent depends on inline template and SCSS file because styling is long in this tutorial.

src/app
├── app.component.ts
├── app.module.ts
└── slider
    ├── index.ts
    ├── slider
    │   ├── slider.component.scss
    │   └── slider.component.ts
    └── slider.module.ts
// slider.component.ts

import { ChangeDetectionStrategy, Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { Observable, of } from 'rxjs';

@Component({
  selector: 'app-slider',
  template: `
    <div class="items" [ngClass]="{ active: active$ | async }" #items>
      <div *ngFor="let index of panels" class="item">{{index}}</div>
    </div>
  `,
  styleUrls: ['./slider.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class SliderComponent implements OnInit, OnDestroy {

  @ViewChild('items', { static: true, read: ElementRef })
  slider!: ElementRef<HTMLDivElement>;

  active$!: Observable<boolean>;

  panels = [...Array(25).keys()].map(i => i < 9 ? `0${i + 1}` : `${i + 1}`);

  ngOnInit(): void {
    this.active$ = of(false);
  }

  ngOnDestroy(): void {
  }
}

SliderComponent generates 25 <div> elements numbered between 01 and 25. When I click any <div> element, I can slide from left to right and vice versa. Moreover, the parent <div> of the <div> elements includes an active CSS class when sliding occurs.

this.active$ is a boolean Observable, it resolves and toggles the active CSS class of the <div> element. When the class is found in the element , scale transformation occurs and background color changes.

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

// app.component.ts

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

@Component({
  selector: 'app-root',
  template: '<app-slider></app-slider>',
  styles: [`
    :host {
      display: block;
    }
  `]
})
export class AppComponent {
  title = 'Day 27 Click and Drag';

  constructor(titleService: Title) {
    titleService.setTitle(this.title);
  }
}

Use RxJS and Angular to implement SliderComponent

I am going to rewrite active$ Observable and create a click-and-slide subscription in ngOnInit method.

Use ViewChild to obtain reference to div element

@ViewChild('items', { static: true, read: ElementRef })
slider!: ElementRef<HTMLDivElement>;

Declare subscription instance member and unsubscribe in ngDestroy()

subscription = new Subscription();

ngOnDestroy(): void {
    this.subscription.unsubscribe();
}
// slider.component.ts

ngOnInit(): void {
    const sliderNative = this.slider.nativeElement;
    const mouseDown$ = fromEvent(sliderNative, 'mousedown');
    const mouseLeave$ = fromEvent(sliderNative, 'mouseleave');
    const mouseUp$ = fromEvent(sliderNative, 'mouseup');
    const stop$ = merge(mouseLeave$, mouseUp$);
    const mouseMove$ = fromEvent(sliderNative, 'mousemove');

    this.active$ = merge(mouseDown$.pipe(map(() => true)), stop$.pipe(map(() => false)))
      .pipe(startWith(false));

    this.subscription = mouseDown$.pipe(
        filter((moveDownEvent) => moveDownEvent instanceof MouseEvent),
        map((moveDownEvent) => moveDownEvent as MouseEvent),
        concatMap((moveDownEvent) => {
          const startX = moveDownEvent.pageX - sliderNative.offsetLeft;
          const scrollLeft = sliderNative.scrollLeft;          
          return mouseMove$.pipe(
            filter((moveEvent) => moveEvent instanceof MouseEvent),
            map((moveEvent) => moveEvent as MouseEvent),
            tap((moveEvent) => moveEvent.preventDefault()),
            map((e) => {
              const x = e.pageX - sliderNative.offsetLeft;
              const walk = (x - startX) * 3;
              sliderNative.scrollLeft = scrollLeft - walk;
            }),
            takeUntil(stop$)
          );
        }),
      ).subscribe();
}
  • const mouseDown$ = fromEvent(sliderNative, ‘mousedown’) – listens to mousedown event of the div elements
  • const mouseLeave$ = fromEvent(sliderNative, ‘mouseleave’) – listens to mouseleave event of the div elements
  • const mouseUp$ = fromEvent(sliderNative, ‘mouseup’) – listens to mouseup event of the div elements
  • const mouseMove$ = fromEvent(sliderNative, ‘mousemove’) – listens to mousemove event of the div elements
  • const stop$ = merge(mouseLeave$, mouseUp$); – stop click-and-slide when mouse exits the browser or mouse up

Toggle active class

this.active$ = merge(
     mouseDown$.pipe(map(() => true)), stop$.pipe(map(() => false))
).pipe(startWith(false))
  • mouseDown$.pipe(map(() => true) – when mouse is clicked, add active class
  • stop$.pipe(map(() => false)) – when click and slide stops, remove active class
  • merge(mouseDown$.pipe(map(() => true)), stop$.pipe(map(() => false))) – merge multiple streams to toggle active class
  • startWith(false)) – the initial value of this.active$ observable is false

Click and Slide HTML elements

mouseDown$.pipe(
   filter((moveDownEvent) => moveDownEvent instanceof MouseEvent),
   map((moveDownEvent) => moveDownEvent as MouseEvent),
   concatMap((moveDownEvent) => {
      const startX = moveDownEvent.pageX - sliderNative.offsetLeft;
      const scrollLeft = sliderNative.scrollLeft;          
      return mouseMove$.pipe(
          filter((moveEvent) => moveEvent instanceof MouseEvent),
          map((moveEvent) => moveEvent as MouseEvent),
          tap((moveEvent) => moveEvent.preventDefault()),
          map((e) => {
              const x = e.pageX - sliderNative.offsetLeft;
              const walk = (x - startX) * 3;
              sliderNative.scrollLeft = scrollLeft - walk;
          }),
          takeUntil(stop$)
      );
   })
)
  • mouseDown$.pipe(…) – observe mousedown event
  • filter((moveDownEvent) => moveDownEvent instanceof MouseEvent) – filter event is MouseEvent
  • map((moveDownEvent) => moveDownEvent as MouseEvent) – cast event as MouseEvent
  • concatMap(…) – create a new Observable that flattens mouseMove$ inner Observables
  • mouseMove$.pipe(…) – when sliding occurs, I update the scrollLeft property of div element
  • tap((moveEvent) => moveEvent.preventDefault()) – invoke preventDefault method of the mouse event
  • takeUntil(stop$) – sliding stops when mouse exits browser or mouse up

Final Thoughts

In this post, I show how to use RxJS and Angular to click and slide div elements until mouse up or mouse leave occurs. fromEvent observes mousedown event and emits the event to concatMap to stream mousemove events. For each mousemove event, scrollLeft property of the div element is calculated and concatMap flattens the Observable. Then, the div element can scroll left or right.

Moreover, when mouse down occurs, the Observable emits true to add the active class to div element. Otherwise, the Observable emits false to remove the class.

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:

  1. Github Repo: https://github.com/railsstudent/ng-rxjs-30/tree/main/projects/day27-click-and-drag
  2. Live demo: https://railsstudent.github.io/ng-rxjs-30/day27-click-and-drag/
  3. Wes Bos’s JavaScript 30 Challenge: https://github.com/wesbos/JavaScript30

Sticky navigation bar after scroll using RxJS and Angular

Reading Time: 6 minutes

 41 total views

Introduction

This is day 23 of Wes Bos’s JavaScript 30 challenge and I am going to use RxJS and Angular to create a sticky navigation bar after window scrolls past the header. When the header is not in the viewport, I use RxJS behaviorSubject to update boolean flag to add CSS class to HTML element of child components. The CSS class creates sticky navigation bar in one child component and transforms the <div> container of another one.

In this blog post, I describe how to use RxJS fromEvent to listen to window scroll event, update inline style of body element and the behaviorSubject in StickyNav service. Angular components then observe the behaviorSubject, resolve the Observable in the inline template and toggle CSS class in ngClass property.

let's go

Create a new Angular project

ng generate application day24-sticky-nav

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
];

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 { }
// app.module.ts

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

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

Create StickyNav feature module

First, we create a StickyNav feature module and import it into AppModule. The feature module encapsulates StickyNavPageComponent, StickyNavHeaderComponent and StickyNavContentComponent.

Import StickyNavModule in AppModule

// sticky-nav.module.ts

import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { StickyNavContentComponent } from './sticky-nav-content/sticky-nav-content.component';
import { StickyNavHeaderComponent } from './sticky-nav-header/sticky-nav-header.component';
import { StickyNavPageComponent } from './sticky-nav-page/sticky-nav-page.component';

@NgModule({
  declarations: [
    StickyNavPageComponent,
    StickyNavHeaderComponent,
    StickyNavContentComponent
  ],
  imports: [
    CommonModule
  ],
  exports: [
    StickyNavPageComponent
  ]
})
export class StickyNavModule { }
// app.module.ts

import { AppComponent } from './app.component';
import { CoreModule } from './core';
import { StickyNavModule } from './sticky-nav';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    StickyNavModule,
    CoreModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Declare Sticky navigation bar components in feature module

In StickyNav feature module, we declare three Angular components, StickyNavPageComponent, StickyNavHeaderComponent and StickyNavContentComponent to build the application.

src/app
├── app.component.ts
├── app.module.ts
├── core
│   ├── core.module.ts
│   ├── index.ts
│   └── services
│       └── window.service.ts
└── sticky-nav
    ├── index.ts
    ├── services
    │   └── sticky-nav.service.ts
    ├── sticky-nav-content
    │   └── sticky-nav-content.component.ts
    ├── sticky-nav-header
    │   └── sticky-nav-header.component.ts
    ├── sticky-nav-page
    │   └── sticky-nav-page.component.ts
    └── sticky-nav.module.ts

StickyNavPageComponent acts like a shell that encloses StickyNavHeaderComponent and StickyNavContentComponent. For your information, <app-stick-nav-page> is the tag of StickyNavPageComponent.

// sticky-nav-page.component.ts

import { ChangeDetectionStrategy, Component } from '@angular/core';

@Component({
  selector: 'app-sticky-nav-page',
  template: `
    <ng-container>
      <app-sticky-nav-header></app-sticky-nav-header>
      <app-sticky-nav-content></app-sticky-nav-content>
    </ng-container>
  `,
  styles: [`
    :host {
      display: block;
    }
  `],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class StickyNavPageComponent {}

StickyNavHeaderComponent encapsulates a header image and a menu that turns into a sticky navigation bar. StickyNavContentComponent is consisted of several paragraphs and random images.

// sticky-nav-header.component.ts

import { ChangeDetectionStrategy, Component, ElementRef, Inject, OnInit, ViewChild } from '@angular/core';
import { Observable, of } from 'rxjs';
import { WINDOW } from '../../core';
import { StickyNavService } from '../services/sticky-nav.service';

@Component({
  selector: 'app-sticky-nav-header',
  template: `
    <header>
      <h1>A story about getting lost.</h1>
    </header>
    <nav id="main" #menu [ngClass]="{ 'fixed-nav': shouldFixNav$ | async }">
      <ul>
        <li class="logo"><a href="#">LOST.</a></li>
        <li><a href="#">Home</a></li>
        <li><a href="#">About</a></li>
        <li><a href="#">Images</a></li>
        <li><a href="#">Locations</a></li>
        <li><a href="#">Maps</a></li>
      </ul>
    </nav>
  `,
  styles: [`
    :host {
      display: block;
    }

    ... omitted due to brevity ...

    nav.fixed-nav {
      position: fixed;
      box-shadow: 0 5px 0 rgba(0,0,0,0.1);
    }
      
    .fixed-nav li.logo {
      max-width: 500px;
    }
  `],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class StickNavHeaderComponent implements OnInit {
  @ViewChild('menu', { static: true, read: ElementRef })
  nav!: ElementRef<HTMLElement>;

  shouldFixNav$!: Observable<boolean>;

  constructor(@Inject(WINDOW) private window: Window, private service: StickyNavService) { }

  ngOnInit(): void {
    this.shouldFixNav$ = of(true);
  }
}

shouldFixNav$ is a boolean Observable, it resolves and toggles the fixed-nav CSS class of the <nav> element. When the class is found in the element , header image disappears and the navigation bar is positioned at the top of the page.

// sticky-nav-content.component.ts

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { StickyNavService } from '../services/sticky-nav.service';

@Component({
  selector: 'app-sticky-nav-content',
  template: `
    <div class="site-wrap" [ngClass]="{ 'fixed-nav': shouldFixNav$ | async }">
      ... paragraphs and images are omitted due to brevity...
    </div>
  `,
  styles: [`
    :host {
      display: block;
    }

    .fixed-nav.site-wrap {
      transform: scale(1);
      border: 2px solid black;
    }

    ... omitted due to brevity ...
  `],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class StickNavContentComponent {
  shouldFixNav$ = this.service.shouldFixNav$;

  constructor(private service: StickyNavService) { }
}

Similar to StickyNavHeaderComponent, StickNavContentComponent performs scale transformation and adds border around the <div> container when the container includes fixed-nav CSS class.

// sticky-nav.service.ts

import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class StickyNavService {
  private readonly shouldFixNavSub = new BehaviorSubject<boolean>(false);
  readonly shouldFixNav$ = this.shouldFixNavSub.asObservable();

  addClass(value: boolean) {
    this.shouldFixNavSub.next(value);
  }
}

StickNavContentComponent injects StickyNavService service to the constructor to share the boolean Observable. StickyNavService creates a new Observable using shouldFixNavSub as the source. The inline template resolves the Observable in order to toggle the CSS class.

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

// app.component.ts

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

@Component({
  selector: 'app-root',
  template: '<app-sticky-nav-page></app-sticky-nav-page>',
  styles: [`
    :host {
      display: block;
    }
  `]
})
export class AppComponent {
  title = 'Day 24 Sticky Nav';

  constructor(titleService: Title) {
    titleService.setTitle(this.title);
  }
}

Use RxJS and Angular to implement StickyNavHeaderComponent

I am going to rewrite shouldFixNav$ and update the BehaviorSubject in tap.

Use ViewChild to obtain reference to nav element

@ViewChild('menu', { static: true, read: ElementRef })
nav!: ElementRef<HTMLElement>;
// stick-nav-header.component.ts

ngOnInit(): void {
    const navNative = this.nav.nativeElement;
    const body = navNative.closest('body');

    this.shouldFixNav$ = fromEvent(this.window, 'scroll')
      .pipe(
        map(() => this.window.scrollY > navNative.offsetTop),
        tap((result) => {
          if (body) {
            body.style.paddingTop = result ? `${navNative.offsetHeight}px` : '0';
          }            
          this.service.addClass(result);
        }),
        startWith(false)
      );
}
  • fromEvent(this.window, ‘scroll’) – listens to scroll event of the window
  • map(() => this.window.scrollY > navNative.offsetTop) – determines whether or not the navigation bar is out of the viewport
  • tap() – update paddingTop property of the body element and the BehaviorSubject in StickyNavService
  • startWith(false) – emits the initial value of the Observable

Final Thoughts

In this post, I show how to use RxJS and Angular to create a sticky navigation bar. fromEvent observes window scrolling to detect when the navigation bar is not in the viewport. When it occurs, the Observable emits true to add the fixed-nav class to HTML elements. Otherwise, the Observable emits false to remove the class and revert the sticky effect.

The header component communicates with the shared service to toggle the value of the BehaviorSubject. Sibling content component injects the service to obtain the Observable to toggle the class of the div container.

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:

  1. Github Repo: https://github.com/railsstudent/ng-rxjs-30/tree/main/projects/day24-sticky-nav
  2. Live demo: https://railsstudent.github.io/ng-rxjs-30/day24-sticky-nav/
  3. Wes Bos’s JavaScript 30 Challenge: https://github.com/wesbos/JavaScript30

Follow along link highlighter using RxJS and Angular

Reading Time: 7 minutes

 29 total views

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.

let's go

Create a new Angular project

ng generate application day22-follow-along-link-highlighter

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 { }

// 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 { }

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

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) {}
}

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,
}

// 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);
  }
}

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();
  }
}
// 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();
  }
}

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;
  }
}

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);
  }
}

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
];

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 { }
// app.module.ts

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

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

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)`
        })),
      );    
 }
  • 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>;

Declare subscription instance member and unsubscribe in ngDestroy()

subscription = new Subscription();

ngOnDestroy(): void {
    this.subscription.unsubscribe();
}

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));
}

Use RxJS and Angular to implement HighlighterContentComponent

Use ViewChildren to obtain references to anchor elements

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

Declare subscription instance member and unsubscribe in ngDestroy()

subscription = new Subscription();

ngOnDestroy(): void {
    this.subscription.unsubscribe();
}

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));
}

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$

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>

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:

  1. Github Repo: Source code
  2. Live demo: Demo in Github Page
  3. Wes Bos’s JavaScript 30 Challenge: https://github.com/wesbos/JavaScript30