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
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:
- Github Repo: https://github.com/railsstudent/ng-rxjs-30/tree/main/projects/day22-follow-along-link-highlighter
- Live demo: https://railsstudent.github.io/ng-rxjs-30/day22-follow-along-link-highlighter/
- Wes Bos's JavaScript 30 Challenge: https://github.com/wesbos/JavaScript30