While upgrading and rewriting parts of our configuration AngularJS modals at OpenProject, I needed to replace multiple modals with a single one that splits the configuration into separated tabs. Users make changes to their configuration in each of these tabs, switching between them and save all changes alltogether.

Since each previous modal (now tab) already provided saving changes, It makes sense to keep that logic separated in the tabbed components and allow each tab to save their changes whenever the modal is closed.

For splitting a shared content area, Angular CDK offers us portals, and specifically, the DomPortalOutlet to attach different components to a shared host element.

This is perfectly suited for tabs with isolated logic and templates you wish to switch between.

However when using the CDK outlets, each ComponentOutlet creates and destroys component instances whenever attaching and detaching them. That means creating components each time you switch between tabs.

For performance reasons, this will likely be irrelevant due to the tab content being negligible. However, as components are reinstantiated, you will lose all logic isolation if you wish to modify and keep user-provided data in a tab which is being detached.

Of course you can work around that by passing and injecting a data store for each tab, but then processing that data (e.g. assume submitting changes when clicking save) again requires some level of isolation.

Instead, we can create an extended portal outlet which I dubbed TabPortalOutlet that instantiates components as they are required, and keeps their instances around until the outlet is disposed.

This outlet will give you:

  1. a consistent level of isolation: user data is kept and processed in the one relevant component only,
  2. lazy components that are activated only if the user clicks on the tab for the first time,
  3. a mechanism to restrict tabs that need to be processed on saving the outlet, since associated components will have been instantiated.

I have created a stackblitz with a minimal example switching between two components. It shows the example TabViewComponent using the outlet. If you're familiar with how CDK portals work, there will be no surprises.

Let's go through the component. We create a set of exemplary tabs to use with the outlet. The tab outlet requires a unique name and a reference to the component class. You may add more data (such as a label in the example).

1
2
3
4
readonly tabs = [
  { name: 'hello', label: 'Hello tab', componentClass: HelloTabComponent },
  { name: 'world', label: 'World tab', componentClass: WorldTabComponent }
]

We create the portal outlet with a reference to a dom element in the template.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// We're using the TabPortalOutlet similarly to a regular DOM portal outlet.
private tabPortalHost: TabPortalOutlet;

// It requires a DOM element reference to use as the target outlet for the active tab component.
@ViewChild('tabContentOutlet') tabContentOutlet: ElementRef;

// ... snip ...
ngOnInit() {

  // Create the portal outlet.
  this.tabPortalHost = new TabPortalOutlet(
    // Reference to available tabs defined above
    this.tabs,
    // The DOM element used to render the component templates in
    this.tabContentOutlet.nativeElement,
    // And dependencies for the outlet, same as for the DomPortalOutlet
    this.componentFactoryResolver,
    this.appRef,
    this.injector
  );
}

The outlet provides some information about getting and setting the currently active tab.

1
2
3
4
5
6
7
8
9
public isCurrent(name: string) {
  const current = this.tabPortalHost.currentTab;
  return current && current.name === name;
}

public switchTo(name: string) {
  this.tabPortalHost.switchTo(name);
  return false;
}

These two helpers can be used in your template to mark the current active tab. Check out the tab-view.component.html on stackblitz for details.

Lastly, to properly dispose the tab views and clean up the outlet, it is crucial to dispose the outlet when the outer component ist destroyed.

1
2
3
  ngOnDestroy() {
    this.tabPortalHost.dispose();
  }

Now you're thinking with portals! If you're interested in the gory details: