Hover link and open dropdown using RxJS and Angular
Tags: angular, tutorial, rxjs, webdev
Published: 2023-02-06 01:57:33

## 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
```bash
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

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

``` typescript
// 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.

``` bash
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
```

``` 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.

``` typescript
// 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.

```typescript
// 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.

```typescript
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.

``` html
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>
```

```html
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.

``` typescript
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 &lt;li&gt; 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 &lt;li&gt; elements

```typescript
@ViewChildren(CoolLinkDirective)
links!: QueryList<CoolLinkDirective>;
```

I am going to iterate the links to register `mouseenter` and `mouseleave` events, toggle CSS classes of the &lt;li&gt; elements and subscribe the observables.

Use `ViewChild` to obtain references to HTML elements

```typescript
@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()

```typescript
subscription = new Subscription();

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

`ngAfterViewInit` provides the RxJS logic to emit and subscribe `mouseenter` and `mouseleave` events.

```typescript
// 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
```typescript
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 &lt;li&gt; element
- hoverLink(nativeElement) – a custom decorator that encapsulates the logic to add trigger-enter-active and trigger-enter classes to the &lt;li&gt; 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
```typescript
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 &lt;li&gt; element
- subscribe(() => { … }) – subscribe the observable to remove trigger-enter-active and trigger-enter class from the &lt;li&gt; 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