Hover link and open dropdown using RxJS and Angular

Connie Leung - Feb 6 '23 - - Dev Community

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
Enter fullscreen mode Exit fullscreen mode

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 { }
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 { StripeModule } from './stripe';

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

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
Enter fullscreen mode Exit fullscreen mode
// 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;
}
Enter fullscreen mode Exit fullscreen mode

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 {}
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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 ...
        ])
      )
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
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>
Enter fullscreen mode Exit fullscreen mode

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');
                  }
                })
              )
            )
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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

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);
    });
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
  • 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)
Enter fullscreen mode Exit fullscreen mode
  • 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:

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