Dynamic tabs with Angular 6+ and ng-bootstrap

0

One of our Angular applications required the use of dynamic tabs. Since the project already uses ng-bootstrap components and bootstrap for the styling, I decided to use the tabset component from ng-bootstrap.

The tabs needed to fulfil the following requirements:
– A tab can be opened and closed.
– Tabs can display different components depending on the menu item clicked.
– There can only be one open tab for a given component.
– If a menu item is clicked and a tab with this component already exist, that tab should get focus.
– When navigating to a component by URL, this component should be opened in a tab.

The finished product when following the steps below looks like this:

picture4

I decided to make use of the router-outlet provided by Angular because we need to be able to navigate by URL. If this is not one of your requirements you could decide to use dynamic components to fill your tabs.

In order to make this example work you need a project with Angular 6+ (I used Angular 7.2) and Angular routing. If you use the Angular CLI you should answer “y” to the question “would you like to add Angular routing? “, otherwise you will need to manually add the routing.

Steps:

1. Download bootstrap and ng-bootstrap into your Angular project by entering the following commands in your command line, while you are in your project folder:

npm install bootstrap@<required bootstrap version>
npm install --save @ng-bootstrap/ng-bootstrap@<required ng-bootstrap version>

Make sure you install compatible versions of ng-bootstrap and bootstrap. At https://www.npmjs.com/package/@ng-bootstrap/ng-bootstrap you can see which versions are compatible with your version of Angular.

2. Add bootstrap to the styles in the angular.json file, so the styles property will become something like:

"styles": ["src/styles.css","node_modules/bootstrap/dist/css/bootstrap.min.css"]

3. Add the NgbModule to your imports in your app.module.ts file:

  import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
    imports: [BrowserModule, AppRoutingModule, NgbModule],

4. Create a main-content component and a menu component. You can do this manually or by using the Angular CLI command:

ng generate component <componentName>
There is no need to put anything inside the components yet, you can keep the generated content or keep them empty for now. Put both components in your app component HTML so you have a (sidebar) menu and a place to render your tabs.

app.component.html:

<div class="app-container">
  <app-menu class="menu-bar"></app-menu>
  <app-main-content class="main-content-container"></app-main-content>
</div>

app.component.css:

  .app-container {
    display: flex;
    flex-direction: row;
    width: 100%;
    height: 100vh;
  }

  .menu-bar {
    width: 420px;
    background-color: #cae4db;
  }

  .main-content-container {
    width: 100%;
  }

Your app should now look like this when you run it (zoomed-in):

picture1 (4)

5. Create the different components you want to display in your tabs. For this example I created a component called ‘movies’ and a component called ‘songs’. These components don’t need to be connected to anything yet.

6. Create an interface for the tabs, called ITab.

tab.model.ts:

export interface ITab {
  name: string;
  url: string;
}

7. Create a singleton service called tab.service. With the Angular CLI you can do this by running the following command in your project folder:

ng generate service tab

8. The tab service is where we will keep our tabs. We will define the titles and relative URLs of our possible tabs here (you could also choose to store them somewhere else). Make sure you precede the URL with a forward slash (‘/’), so “/movies” instead of just “movies”. Otherwise we will have trouble comparing them to the navigation URL later.

tab.service.ts:

  import { Injectable } from '@angular/core';
  import { ITab } from './tab.model';

  @Injectable({
    providedIn: 'root',
  })
  export class TabService {
    tabs: ITab[] = [];
    tabOptions: ITab[] = [{ name: 'Movies', url: '/movies' }, { name: 'Songs',   url: '/songs' }];

    constructor() {}
  }

9. Create methods to add and delete a tab in the tab.service.

  addTab(url: string) {
    const tab = this.getTabOptionByUrl(url);
    this.tabs.push(tab);
  }

  getTabOptionByUrl(url: string): ITab {
    return this.tabOptions.find(tab =&gt; tab.url === url);
  }

  deleteTab(index: number) {
    this.tabs.splice(index, 1);
  }

10. Get the tabOptions from your tabService in the menuComponent.

menu.component.ts:

export class MenuComponent implements OnInit {
  menuOptions = [];
  constructor(private tabService: TabService) {}

  ngOnInit() {
    this.menuOptions = this.tabService.tabOptions;
  }
}

11. Add menu options to your menu for each of your tab options. Use the openTab method that receives the URL and calls the addTab method of the tabService.

menu.component.html:

<nav class="menu">
    <ul class="menu-options-list">
      <li *ngFor="let option of menuOptions" class="menu-option"   (click)="openTab(option.url)">
        {{option.name}}
      </li>
    </ul>
  </nav>

menu.component.css:

.menu {
  padding-top: 20px;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.menu-options-list {
  list-style-type: none;
  padding: 0;
  margin: 0;
}

.menu-option {
  padding: 10px 0px 10px 50px;
}

.menu-option:hover {
  background-color: #7a9d96;
  cursor: pointer;
}

menu.component.ts:

  openTab(url: string) {
    this.tabService.addTab(url);
  }

Your app should now have a menu option for each of the components you want to display in your tabs. It should look similar to this:

picture1-5 (2)

12. Get the tabs from the tabService in the mainContentComponent.

main-content.component.ts:

export class MainContentComponent implements OnInit {
  tabs = [];
  constructor(private tabService: TabService) {}

  ngOnInit() {
    this.tabs = this.tabService.tabs;
  }
}

13. Add a closeTab method that calls the tabService deleteTab method. Also call event.preventDefault to prevent the tabs from being refreshed after clicking on the header.

main-content.component.ts:

  closeTab(index: number, event: Event) {
    this.tabService.deleteTab(index);
    event.preventDefault();
  }

14. Add the ngb-tabset component from ng-bootstrap to the main-content HTML file and use a loop to add a tab for each of the tabs in the tabService. The tab title will be the name of the tab and for now we will use the URL as the tab content.

main-content.component.html:

<div class="main-content">
    <ngb-tabset>
      <ngb-tab *ngFor="let tab of tabs ; let index = index">
        <ng-template ngbTabTitle>
          <span>{{tab.name}}</span>
            <span (click)="closeTab(index, $event)">&times;</span>
          </ng-template>
        <ng-template ngbTabContent>{{tab.url}}</ng-template>
      </ngb-tab>
    </ngb-tabset>
  </div>

Now when we click on one of the menu options, a new tab should open that displays the URL of that tab. We can also close these tabs by clicking on the “x” in the corner of the tab. Your app should look like this:

 picture2 (2)

15. Add the components you want to display in your tabs to the routes in the appRoutingModule. In this example that will be the movies and the songs component. Use the same URLs that you have specified in the tabOptions in the tabService.

app-routing.module.ts:

  const routes: Routes = [
    {
      path: 'movies',
      component: MoviesComponent,
    },
    {
      path: 'songs',
      component: SongsComponent,
    },
  ];

16. Display the router-outlet in the tab content in the MainContentComponent instead of the URL of the tab.

main-content.component.html:

<ng-template ngbTabContent>
    <router-outlet></router-outlet>
  </ng-template>

17. Add the router from angular/core to the constructor of the menuComponent. In the openTab method, navigate to the URL of the tab that is being opened.

menu.component.ts:

constructor(private tabService: TabService, private router: Router) {}
  openTab(url: string) {
    this.tabService.addTab(url);
    this.router.navigateByUrl(url);
  }

Now when we click a menu option, the corresponding component will be displayed. However a new tab opens every time an option is clicked and the active tab does not switch but remains the same. We will now use the URL to determine the active tab.

18. Add the router to the constructor of the main-content. In ngOnInit, subscribe to the routers events. Save the urlAfterRedirects from the event in a variable called activeTabUrl when the event is of type NavigationEnd. NavigationEnd means the router is done navigating and redirecting and this is the new URL the app is displaying.

main-content.component.ts:

  activeTabUrl;
  constructor(private tabService: TabService, private router: Router) {}

main-content.component.ts, in the method ngOnInit:

  this.router.events.subscribe(event =&gt; {
    if (event instanceof NavigationEnd) {
      this.activeTabUrl = event.urlAfterRedirects;
    }
  });

19. Bind the activeTabUrl variable to the activeId of ngb-tabset. Set the id of each tab to be the URL of the tab. Each tab is now identified by its URL, and the active tab is identified by the URL the router has navigated to.

main-content.component.html:

<ngb-tabset [activeId]="activeTabUrl">
      <ngb-tab *ngFor="let tab of tabs; let index = index" [id]="tab.url">

Now when opening a new tab, this tab will become the active tab. But because a component can still be displayed in multiple tabs at the same time, all these tabs will become active. Your app should now look like this:

picture3 (2)

20. In the tabService, make sure a tab doesn’t already exist before adding it to the tabs array.

tab.service.ts :

 addTab(url: string) {
    const tab = this.getTabOptionByUrl(url);
    if (!this.tabs.includes(tab)) {
      this.tabs.push(tab);
    }
  }

Now when selecting a menu option you will open a new tab, or go to the tab if it already exists. However, when you select a tab by clicking on the tab header the content of the tab doesn’t change. This is because no routing is taking place when clicking a tab header.

21. Add an onTabChange method to ngb-tabset in the main-content component, that responds to a tabChange event.

main-content.component.html:

<ngb-tabset [activeId]="activeTabUrl" (tabChange)="onTabChange($event)">

In the onTabChange method, navigate to the nextId in the event. The nextId is the id of the tab which header was clicked, which is also the URL of that tab.

main-content.component.ts:

  onTabChange(event) {
    this.router.navigateByUrl(event.nextId);
  }

In your app you can now open tabs by clicking on the corresponding menu item, close tabs by clicking on the ‘x’ in the corner of the tab and change tabs by clicking on the tab headers. But when we try to navigate to a component by typing the URL into the web browser, nothing is displayed.

22. In the mainContentComponent in the ngOnInit method, add a call to the tabService.addTab method with the activeTabUrl as a parameter. Make sure this method is only called when there are no tabs present. This will make sure a new tab is opened when navigation is done by typing the URL (for example: http://localhost:4200/songs) in the web browser.

main-content.component.ts:

  ngOnInit() {
    this.tabs = this.tabService.tabs;

    this.router.events.subscribe(event =&gt; {
      if (event instanceof NavigationEnd) {
        this.activeTabUrl = event.urlAfterRedirects;
        if (this.tabs.length === 0) {
          this.tabService.addTab(this.activeTabUrl);
        }
      }
    });
  }

Now we have fulfilled all the requirements for our tabs! Your app should look like this now:

picture4

This works fine as long as you have components that only display information. However, if you have components with user input (such as forms) this information will be lost every time you navigate to a different component. If you want to persist this data when switching tabs, you will have to store it outside of your component. A couple of suggestions are: a singleton service, the web browser’s session storage or local storage, or a separate storage facility such as ngrx-store or mobx- store.

Do not forget to load the stored information in the ngOnInit method of your components in order to have access to this data.

Sources used for this article:

https://angular.io/cli

https://ng-bootstrap.github.io/#/components/tabset/examples

https://stackoverflow.com/questions/45016020/how-to-select-ngbtabset-tab-as-active-on-load

Sources on dynamic components (as an alternative for using the router):

https://github.com/angular/angular/issues/16849

https://ultimatecourses.com/blog/angular-dynamic-components-forms

About Author

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.