Introduction
This is day 26 of Wes Bos's JavaScript 30 challenge where I hover link and open dropdown below it. I am going to use RxJS and Angular to rewrite the challenge from Vanilla JavaScript.
In this blog post, I describe how to use RxJS fromEvent
to listen to mouseenter event and emit the event to concatMap
operator. In the callback of concatMap
, it emits a timer
to add another CSS class after 150 milliseconds. When CSS classes are added, a white dropdown appears below the link. The RxJS code creates the effect of hover link and open dropdown. Moreover, the tutorial also uses RxJS fromEvent
to listen to mouseleave event to remove the CSS classes to close the dropdown.
Create a new Angular project
ng generate application day26-strike-follow-along-link
Create Stripe feature module
First, we create a Stripe feature module and import it into AppModule. The feature module encapsulates StripeNavPageComponent
that is comprised of three links.
Import StripeModule in AppModule
// stripe.module.ts
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { CoolLinkDirective } from './directives/cool-link.directive';
import { StripeNavPageComponent } from './stripe-nav-page/stripe-nav-page.component';
@NgModule({
declarations: [
StripeNavPageComponent,
CoolLinkDirective
],
imports: [
CommonModule
],
exports: [
StripeNavPageComponent
]
})
export class StripeModule { }
// app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { StripeModule } from './stripe';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
StripeModule
],
bootstrap: [AppComponent]
})
export class AppModule { }
Declare Stripe component in feature module
In Stripe feature module, we declare StripeNavPageComponent
to build the application. StripeNavPageComponent
depends on inline template, encapsulated SCSS file and global styles in order to work properly.
src
├── app
│ ├── app.component.ts
│ ├── app.module.ts
│ └── stripe
│ ├── directives
│ │ └── cool-link.directive.ts
│ ├── index.ts
│ ├── services
│ │ └── stripe.service.ts
│ ├── stripe-nav-page
│ │ ├── stripe-nav-page.component.scss
│ │ └── stripe-nav-page.component.ts
│ └── stripe.module.ts
├── styles.scss
// style.scss
nav {
position: relative;
perspective: 600px;
}
.cool > li > a {
color: yellow;
text-decoration: none;
font-size: 20px;
background: rgba(0,0,0,0.2);
padding: 10px 20px;
display: inline-block;
margin: 20px;
border-radius: 5px;
}
nav ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
}
.cool > li {
position: relative;
display: flex;
justify-content: center;
}
.dropdown {
opacity: 0;
position: absolute;
overflow: hidden;
padding: 20px;
top: -20px;
border-radius: 2px;
transition: all 0.5s;
transform: translateY(100px);
will-change: opacity;
display: none;
}
.trigger-enter .dropdown {
display: block;
}
.trigger-enter-active .dropdown {
opacity: 1;
}
.dropdown a {
text-decoration: none;
color: #ffc600;
}
After numerous trials and errors, the CSS styles of navigation bar and dropdowns only work when I put them in the global style sheet.
// stripe-nav-page.component.ts
import { AfterViewInit, ChangeDetectionStrategy, Component, OnDestroy } from '@angular/core';
import { CoolLinkDirective } from '../directives/cool-link.directive';
import { StripeService } from '../services/stripe.service';
@Component({
selector: 'app-stripe-nav-page',
template: `
<ng-container>
<h2>Cool</h2>
<nav class="top" #top>
<div class="dropdownBackground" #background>
<span class="arrow"></span>
</div>
<ul class="cool">
<li class="link">
<a href="#">About Me</a>
<ng-container *ngTemplateOutlet="aboutMe"></ng-container>
</li>
<li class="link">
<a href="#">Courses</a>
<ng-container *ngTemplateOutlet="courses"></ng-container>
</li>
<li class="link">
<a href="#">Other Links</a>
<ng-container *ngTemplateOutlet="social"></ng-container>
</li>
</ul>
</nav>
</ng-container>
<ng-template #aboutMe>
<div class="dropdown">
<div class="bio">
<img src="https://logo.clearbit.com/wesbos.com">
<p>Wes Bos sure does love web development. He teaches things like JavaScript, CSS and BBQ. Wait. BBQ isn't part of web development. It should be though!</p>
</div>
</div>
</ng-template>
<ng-template #courses>
<ul class="dropdown courses">
<ng-container *ngIf="coursesTaught$ | async as coursesTaught">
<li *ngFor="let x of coursesTaught; trackBy: trackByIndex">
<span class="code">{{ x.code }}</span>
<a [href]="x.link">{{ x.description }}</a>
</li>
</ng-container>
</ul>
</ng-template>
<ng-template #social>
<ul class="dropdown">
<ng-container *ngIf="socialAccounts$ | async as socialAccounts">
<li *ngFor="let account of socialAccounts; trackBy: trackByIndex">
<a class="button" [href]="account.link">{{ account.description }}</a>
</li>
</ng-container>
</ul>
</ng-template>
`,
styleUrls: ['./stripe-nav-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class StripeNavPageComponent implements AfterViewInit, OnDestroy {
socialAccounts$ = this.stripeService.getSocial();
coursesTaught$ = this.stripeService.getCourses();
constructor(private stripeService: StripeService) { }
ngAfterViewInit(): void {}
trackByIndex(index: number) {
return index;
}
ngOnDestroy(): void {}
}
Next, I delete boilerplate codes in AppComponent and render StripeNavPageComponent
in inline template.
// app.component.ts
import { Component } from '@angular/core';
import { Title } from '@angular/platform-browser';
@Component({
selector: 'app-root',
template: '<app-stripe-nav-page></app-stripe-nav-page>',
styles: [`
:host {
display: block;
}
`],
})
export class AppComponent {
title = 'Day 26 Stripe Follow Along Nav';
constructor(titleService: Title) {
titleService.setTitle(this.title);
}
}
Declare stripe service to retrieve data
In an enterprise application, courses and personal information are usually kept in a server. Therefore, I define a service that constructs fake requests to remote server. In the methods, the codes wait 250 – 300 milliseconds before returning the data array in Observable.
import { Injectable } from '@angular/core';
import { map, timer } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class StripeService {
getSocial() {
return timer(300)
.pipe(
map(() => ([
{
link: 'http://twitter.com/wesbos',
description: 'Twitter'
},
... other social media accounts ...
])
)
);
}
getCourses() {
return timer(250)
.pipe(
map(() => ([
{
code: 'RFB',
link: 'https://ReactForBeginners.com',
description: 'React For Beginners'
},
... other courses ...
])
)
);
}
}
StripeNavPageComponent
injects the service to retrieve data, uses async pipe to resolve the Observables in the inline template and iterates the arrays to render the HTML elements.
socialAccounts$ = this.stripeService.getSocial();
<ng-container *ngIf="socialAccounts$ | async as socialAccounts">
<li *ngFor="let account of socialAccounts; trackBy: trackByIndex">
<a class="button" [href]="account.link">{{ account.description }}</a>
</li>
</ng-container>
coursesTaught$ = this.stripeService.getCourses();
<ng-container *ngIf="coursesTaught$ | async as coursesTaught">
<li *ngFor="let x of coursesTaught; trackBy: trackByIndex">
<span class="code">{{ x.code }}</span>
<a [href]="x.link">{{ x.description }}</a>
</li>
</ng-container>
Refactor RxJS logic to custom operator
In the complete implementation, ngAfterViewInit becomes bulky after putting in the RxJS logic. To make the logic more readable, I refactor the hover link logic to a custom RxJS operator.
import { Observable, concatMap, tap, timer } from 'rxjs';
export function hoverLink<T extends HTMLElement>(nativeElement: T) {
return function (source: Observable<any>) {
return source.pipe(
tap(() => nativeElement.classList.add('trigger-enter')),
concatMap(() => timer(150)
.pipe(
tap(() => {
if (nativeElement.classList.contains('trigger-enter')) {
nativeElement.classList.add('trigger-enter-active');
}
})
)
)
)
}
}
hoverLink
operator adds trigger-enter class to <li> element and emits the event to concatMap
. concatMap
emits a timer that waits 150 milliseconds before firing to add the second class, trigger-enter-active to the same element.
Use RxJS and Angular to implement hover link and open dropdown effect
Use ViewChildren
to obtain references to <li> elements
@ViewChildren(CoolLinkDirective)
links!: QueryList<CoolLinkDirective>;
I am going to iterate the links to register mouseenter
and mouseleave
events, toggle CSS classes of the <li> elements and subscribe the observables.
Use ViewChild
to obtain references to HTML elements
@ViewChild('top', { static: true, read: ElementRef })
nav!: ElementRef<HTMLElement>;
@ViewChild('background', { static: true, read: ElementRef })
background!: ElementRef<HTMLDivElement>;
Declare subscription instance member and unsubscribe in ngDestroy()
subscription = new Subscription();
ngOnDestroy(): void {
this.subscription.unsubscribe();
}
ngAfterViewInit
provides the RxJS logic to emit and subscribe mouseenter
and mouseleave
events.
// stripe-nav-page.component.ts
private navBarClosure(navCoords: DOMRect) {
return (dropdownCoords: DOMRect) => {
const top = dropdownCoords.top - navCoords.top;
const left = dropdownCoords.left - navCoords.left;
const backgroundNativeElement = this.background.nativeElement;
backgroundNativeElement.style.width = `${dropdownCoords.width}px`;
backgroundNativeElement.style.height = `${dropdownCoords.height}px`;
backgroundNativeElement.style.transform = `translate(${left}px, ${top}px)`;
backgroundNativeElement.classList.add('open');
}
}
ngAfterViewInit(): void {
const translateBackground = this.navBarClosure(this.nav.nativeElement.getBoundingClientRect());
this.links.forEach(({ nativeElement }) => {
const mouseEnterSubscription = fromEvent(nativeElement, 'mouseenter')
.pipe(hoverLink(nativeElement))
.subscribe(() => {
const dropdown = nativeElement.querySelector('.dropdown');
if (dropdown) {
translateBackground(dropdown.getBoundingClientRect());
}
});
const mouseLeaveSubscription = fromEvent(nativeElement, 'mouseleave')
.subscribe(() => {
nativeElement.classList.remove('trigger-enter-active', 'trigger-enter');
this.background.nativeElement.classList.remove('open');
});
this.subscriptions.add(mouseEnterSubscription);
this.subscriptions.add(mouseLeaveSubscription);
});
}
Add CSS classes and open dropdown
const translateBackground = this.navBarClosure(this.nav.nativeElement.getBoundingClientRect());
const mouseEnterSubscription = fromEvent(nativeElement, 'mouseenter')
.pipe(hoverLink(nativeElement))
.subscribe(() => {
const dropdown = nativeElement.querySelector('.dropdown');
if (dropdown) {
translateBackground(dropdown.getBoundingClientRect());
}
});
this.subscriptions.add(mouseEnterSubscription)
- fromEvent(nativeElement, ‘mouseenter’) – observe mouseenter event on the <li> element
- hoverLink(nativeElement) – a custom decorator that encapsulates the logic to add trigger-enter-active and trigger-enter classes to the <li> element
- subscribe(() => { … }) – select the associated dropdown and invoke translateBackground function. translateBackground function translates the white background to the position of the dropdown and opens it
- this.subscriptions.add(mouseEnterSubscription) – append mouseEnterSubscription subscription to this.subscriptions in order to release the memory in ngOnDestroy
Remove CSS classes and close dropdown
const mouseLeaveSubscription = fromEvent(nativeElement, 'mouseleave')
.subscribe(() => {
nativeElement.classList.remove('trigger-enter-active', 'trigger-enter');
this.background.nativeElement.classList.remove('open');
});
this.subscriptions.add(mouseLeaveSubscription)
- fromEvent(nativeElement, ‘mouseleave’) – observe mouseleave event on the <li> element
- subscribe(() => { … }) – subscribe the observable to remove trigger-enter-active and trigger-enter class from the <li> element. Then, remove ‘open’ class to close the dropdown
- this.subscriptions.add(mouseLeaveSubscription) – append mouseLeaveSubscription subscription to this.subscriptions in order to release the memory in ngOnDestroy
- This is it, we have completed the tutorial that opens the associated dropdown when mouse hovers a list item.
Final Thoughts
In this post, I show how to use RxJS and Angular to hover link and open dropdown. fromEvent observes mouseenter event to add the first CSS class and emits the event to concatMap to add the second CSS class after 150 milliseconds. The observable is subscribed to translate the white background and open the dropdown. Moreover, another fromEvent emits mouseleave to remove the previously added classes and close the dropdown.
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/day26-stripe-follow-along-nav
- Live demo: https://railsstudent.github.io/ng-rxjs-30/day26-stripe-follow-along-nav/
- Wes Bos's JavaScript 30 Challenge: https://github.com/wesbos/JavaScript30