Namek Dev
a developer's log
NamekDev

Angular 2: my solution for dynamic tabs

May 10, 2017
Angular 2: my solution for dynamic tabs

I needed tabs that would not be destroyed by Angular. As powerful as if I would code a normal application for Windows in C#. Here’s my solution.

Goal: everything you would imagine about having tabs

My requirement list shall not be ignored!

  1. few predefined types of tabs - content for each tab can have different component (but doesn’t have to)
  2. each tab has a name, ID and any user data
  3. lazy tab instantiation
  4. ability to procedurally change set of tabs
  5. ability to procedurally change active tab
  6. tabs are closeable
  7. every tab could have a little different looks (like: different icon or text color)
  8. somewhat copyable solution
  9. available references to components which are tab contents

We’ll fulfill those needs one by one. Except the latest one.

TL;DR - The solution in short sentence

It’s “just” ngFor with couple of ngIf:s and [hidden]:s. ngFor maps tab meta data to ngIf:s of ever activated tabs on given condition. Tabs are listed in a service which is available through dependency injection throughout whole application if it’s declared in an application module.

The additional trick here is about retaining back a reference to each tab component. The cause of this is that those tabs have to be instantiated through a declarative template. There is other way though - define some TabDirective that will bind the tab component and tab meta data to together, then catch those directives with @ViewChildren.

Dive into solution

We need:

  1. a service that will contain list of tabs
  2. a use of ngFor directive over that list which will declaratively show or hide tab content
  3. a custom directive that will help connecting tab meta data with the declared component

Let’s start from the ngFor. For example, I’ll define a template containing some tabs:

<div class="card">
  <div class="card-header">
    <ul class="nav nav-pills card-header-pills">
      <li
        *ngFor="let tab of (tabs.tabs$ | async)"
        class="nav-item interactive"
      >
        <a
          class="nav-link"
          [class.active]="(tabs.currentTabId$ | async) == tab.tabId"
          (click)="tabs.switchToTab(tab.tabId)"
        >{{tab.name}}</a>
      </li>
    </ul>
  </div>
  <div class="card-block">
    <div
      *ngFor="let tab of tabs.tabs$ | async"
      [hidden]="(tabs.currentTabId$ | async) != tab.tabId"
    >
      <!-- lazy instantiation of tabs -->
      <template [ngIf]="tab.everActivated">
        <app-marketplace
          [hidden]="(tabs.currentTabId$ | async) != tab.tabId"
          [pid]="pid"
          [marketplaceId]="tab.dataId"
        ></app-marketplace>
      </template>
    </div>
  </div>
</div>

Look closely to the code. There are two ngFor:s.

The tab and the content

The first one loops over tabsService.tabs$ (observable) collection to displays only the tabs. There is no condition about displaying them because all tabs should be visible.

The second ngFor loops over same list again but just here are the tab contents. In this example there is only one component defined - the AppMarketplaceComponent (tagged as app-marketplace). If there were more components (more types of component for tabs) then there would be more <template [ngIf]="tab.everActivated"> declarations.

Lazy instantiation

<template [ngIf]="tab.everActivated"> decides whether the app-marketplace component should be instantiated or not. This line is not concerned about hiding component when tabs are switched! The tab.everActivatedproperty defines whether tab was opened at least once. Here’s the** lazy part** of instantiation.

The hiding part is here:

<app-marketplace
          [hidden]="(tabs.currentTabId$ | async) != tab.tabId"

The [hidden] property hides the component without destroying or deattaching it.

Note: <template [ngIf]="tab.everActivated"> is not a real template, it’s just a way of making conditional instantiation without having a new <div> here. It’s the same way how *ngIf works (notice the asterisk symbol *).

Tab meta data

All info about one tab is described by this interface:

export interface DataTab {
  tabId: string
  type: string
  name: string
  everActivated?: boolean
  dataId?: any
}

where dataId is an optional field you can use it however you want or even decide on not having it.

and type is also somewhat custom. In my app I can have such tab list:

  1. Product List
  2. Product #123
  3. Product #124
  4. Product #125

So you can see I have two types of tabs here:

  1. ProductList
  2. Product

Why would that be useful? For showing special stuff according to type of tab, not a name or id. For instance, a close button (look to the bonus chapter in the end of article).

Service for setting tabs

Though I have multiple different tab lists over the app, lot’s of code is repeated. Hence, I’ve created a base class for all tab panes. I called it DataTabsService, it has methods to add and remove tabs, switch current tab and list all tabs.

First, the example of SettingsTabsService which inherits from the base DataTabsService:

import { Injectable } from "@angular/core";
import { DataTabsService, DataTab, LoadingService } from "app/shared";
import { SettingsService } from "./settings.service";

export const SettingsTabType = {
  Marketplace: "marketplace"
}

@Injectable()
export class SettingsTabsService extends DataTabsService {
  constructor(
    settingsService: SettingsService,
    loadingService: LoadingService
  ) {
    super()

    loadingService.waitPromise(
      settingsService.getMarketplaces()
    )
      .then(marketplaces => {
        for (let m of marketplaces) {
          this.addTab({
            name: `Marketplace ${m.marketplaceId}`,
            type: SettingsTabType.Marketplace,
            tabId: m.marketplaceId,
            dataId: m.marketplaceId
          })
        }

        this.switchToTab(marketplaces[0].marketplaceId)
      })
  }

  switchToMarketplaceTab(marketplaceId: string): void {
    this.switchToTab(marketplaceId)
  }
}

I can asynchronously download a list of marketplaces and then create tabs each for one marketplace, then call switchToTab() for the first one. So, while data is still downloading, then no tab is really activated, neither any component is instantiated.

And, for convienence I have created the switchToMarketplaceTab(marketplaceId) method so users don’t have to dig too much into what piece of data is an ID for these tabs.

The base Data Tabs Service

The code below should be self-explainable:

import { BehaviorSubject } from 'rxjs/BehaviorSubject'
import { Observable } from 'rxjs/Observable'
import { DataTab } from './data-tab.directive'

/* tslint:disable:triple-equals */
const eq = (arg1, arg2) => arg1 == arg2
const neq = (arg1, arg2) => arg1 != arg2
/* tslint:enable:triple-equals */


export abstract class DataTabsService {
  protected _tabs: BehaviorSubject<DataTab[]> = new BehaviorSubject<DataTab[]>([])
  protected _currentTabId: BehaviorSubject<string | null> = new BehaviorSubject<string | null>(null)

  get tabs$(): Observable<DataTab[]> {
    return this._tabs.asObservable()
  }

  get currentTabId$(): Observable<string> {
    return this._currentTabId.asObservable()
  }

  get tabCount(): number {
    return this._tabs.getValue().length
  }


  /**
   * Add a new tab to tab list. Don't switch to the new one.
   * @param info
   */
  addTab(info: DataTab) {
    if (info.everActivated === undefined)
      info.everActivated = false

    let newTabs = this._tabs.value
    newTabs.push(info)
    this._tabs.next(newTabs)
  }

  getTab(tabId: string): DataTab | null {
    return this._tabs.getValue()
      .find(tab => eq(tab.tabId, tabId)) || null
  }

  get tabs(): DataTab[] {
    return this._tabs.getValue()
  }

  get currentTabId(): string | null {
    return this._currentTabId.value
  }

  get currentTab(): DataTab | null {
    return this.currentTabId && this.getTab(this.currentTabId) || null
  }

  /**
   * Set current tab. Don't create anything.
   * @param tabId
   */
  switchToTab(tabId: string): void {
    let tab = this.getTab(tabId)

    if (!tab)
      throw new Error(`there is no tab: ${tabId}`)

    tab.everActivated = true
    this._currentTabId.next(tabId)
  }

  /**
   * Close tab. Don't throw error if tab didn't exist.
   * Automatically switch to another tab if this one was the current.
   * @param tabId
   * @param autoSwitch
   */
  closeTab(tabId: string, autoSwitch = true) {
    let currentTabId = this._currentTabId.value
    let tabs = this._tabs.value
    let newTabs = tabs.filter(tab => neq(tab.tabId, tabId))
    let closingTab = tabs.filter(tab => !neq(tab.tabId, tabId))[0]

    // if _current_ tab was closed, then switch to another tab
    if (autoSwitch && eq(tabId, currentTabId) && newTabs.length > 0) {
      let index = tabs.findIndex((tab) => eq(tab.tabId, tabId))
      let n = newTabs.length

      if (index >= n) {
        index = n - 1
      }

      let newCurrentTab = newTabs[index]

      this.switchToTab(newCurrentTab.tabId)
    }

    // close the tab
    this._tabs.next(newTabs)

    return closingTab
  }

  /**
   * Close currently open tab.
   * Automatically switch to another tab if this one was the current.
   */
  closeCurrentTab() {
    this.closeTab(this._currentTabId.value!)
  }


  findTabByDataId(dataId): DataTab {
    return this._tabs.getValue().find(tab => eq(tab.dataId, dataId))!
  }

  switchToTabByDataId(dataId: number) {
    let tab = this.findTabByDataId(dataId)
    this.switchToTab(tab.tabId)
  }
}

Catching reference to tab components

Now, here’s the additional part. Note that this was not used in above example with marketplaces.

Why would I need that? For instance, a component may have a method that we would want to call. It’s not declarative approach, yes, it’s rather procedural. In past, I was switching tabs in subcomponent this way. Nowadays, I define services, as shown above so I don’t need it anymore.

We shall define such field in component which contains all those tabs:

@ViewChildren(DataTabDirective)
viewTabs: QueryList<DataTabDirective>

where the directive is:

import { Directive, Input, ViewContainerRef } from '@angular/core'


@Directive({
  selector: '[tab]'
})
export class DataTabDirective {
  @Input('tab') public tab: DataTab

  theComponent: any

  constructor(public viewRef: ViewContainerRef) {
    this.theComponent = (<any>this._view)._element.component
  }
}

However, this was my solution until some changes in Angular. It’s broken now. Follow this topic if you need:

→ How to access the host component from a directive?

The issue here is that we do not know the type of component. If we would know, then injecting it to the directive would be simple.

Bonus: How to make tabs look more appealing

How? Inside the anchor for each tab you can define various icons.

<li
  *ngFor="let tab of (tabs$ | async)"
  class="nav-item
>
  <a
    class="nav-link"
    [class.active]="(currentTabId$ | async) == tab.tabId"
    (click)="switchToTab(tab.tabId)"
  >
    <span *ngIf="tab.type == 'all-products'"><i class="fa fa-list-alt"></i> &nbsp;</span>
    <span *ngIf="tab.type == 'categories'"><i class="fa fa-filter"></i> &nbsp;</span>
    <span *ngIf="tab.type == 'product'"><i class="fa fa-inbox"></i> &nbsp;</span>
    
    <span>{{tab.name}}</span>
  </a>
</li>

or define a close button for specific tab type:

<button
  *ngIf="tab.type == 'product'"
  class="close" (click)="closeProductTab(tab.tabId)"
>&times;</button>

which should be placed right after closing the  .

Summary

Whole this solution was created as a workaround because we don’t want to destroy contents of our tabs while switching between them.

It would also be nice to have routing for it. And guess what - it’s possible with another couple of ngIf:s. But I didn’t cover this part here since router is constantly changing and up to this point it was never good enough.

To follow the general problem there are some issues on Angular’s GitHub:

angular2, Daj Się Poznać, Get Noticed 2017, web
comments powered by Disqus