Demystifying Taiga UI root component: portals pattern in Angular

Post Editor

Just before new year we announced our new Angular UI kit library Taiga UI. If you go through Getting started steps, you will see that you need to wrap your app with the tui-root component. Let's see what it does and explore what portals are and how and why we use them.

6 min read
post

Demystifying Taiga UI root component: portals pattern in Angular

Just before new year we announced our new Angular UI kit library Taiga UI. If you go through Getting started steps, you will see that you need to wrap your app with the tui-root component. Let's see what it does and explore what portals are and how and why we use them.

post
post
6 min read
6 min read

Just before new year, Roman, my colleague, announced our new Angular UI kit library Taiga UI. If you go through Getting started steps, you will see that you need to wrap your app with the tui-root component. Let's see what it does and explore what portals are and how and why we use them.

What is a portal?

Imagine you have a select component. It has a drop-down block with suggestions. If we keep it at the same position in DOM as the hosting component, we will run into all sorts of trouble. Items pop through and containers can chop off content:

Verticality issues are often solved with z-index, effectively starting World War Z in your app. It's not uncommon to see values like 100, 10000, 10001. But even if you manage to get it right — overflow: hidden would still get you there. So what can we do? Instead of having drop-down near its host we can show it in a dedicated container on top of everything. Then your application content can live in its own isolated context eliminating z-index problems. This container is exactly what a portal is. And it is what the Taiga UI root component sets up for you, among other things. Let's look at its template:

<tui-scroll-controls></tui-scroll-controls>
<tui-portal-host>
	<div class="content"><ng-content></ng-content></div>
	<tui-dialog-host></tui-dialog-host>
	<ng-content select="tuiOverDialogs"></ng-content>
	<tui-notifications-host></tui-notifications-host>
	<ng-content select="tuiOverNotifications"></ng-content>
</tui-portal-host>
<ng-content select="tuiOverPortals"></ng-content>
<tui-hints-host></tui-hints-host>
<ng-content select="tuiOverHints"></ng-content>

Generic and dedicated portals

Both tui-dialog-host and tui-portal-host are portals in their nature. But they work differently. Let's explore the second one first. Taiga UI uses it mostly to display drop-downs. But it's a generic container. It is controlled by a very simple service:

@Injectable({
  providedIn: 'root',
})
export class TuiPortalService {
  private host: TuiPortalHostComponent;

  add<C>(
    componentFactory: ComponentFactory<C>, 
    injector: Injector
  ): ComponentRef<C> {
    return this.host.addComponentChild(componentFactory, injector);
  }

  remove<C>({hostView}: ComponentRef<C>) {
    hostView.destroy();
  }

  addTemplate<C>(
    templateRef: TemplateRef<C>,
    context?: C
  ): EmbeddedViewRef<C> {
    return this.host.addTemplateChild(templateRef, context);
  }

  removeTemplate<C>(viewRef: EmbeddedViewRef<C>) {
    viewRef.destroy();
  }
}

And the component itself is rather straightforward. All it does is show templates and dynamic components on top of everything. No other logic is included (except a little position: fixed helper for iOS). It means that positioning, closing and the rest is handled by portal items on their own. It's a good idea to have a generic portal for special cases. Like a fixed «Scroll to top» button displayed above content or anything else you, as a library user might need.

If we were to architect a drop-down — we would need to come up with a positioning solution. We have several options here:

  1. Position drop-down once and prevent scrolling while it's open. This is what material does by default.
  2. Position once and close if scrolling occurred. That's how native drop-downs behave.
  3. Follow host position when it changes

We went with the third option. It's not that trivial as it turned out. You cannot really get two positions in sync, even with requestAnimationFrame. Because once you query the host position — it triggers a layout recalculation. So by the time the next frame comes and drop-down is positioned — the host already changes location a little bit. This causes visible jumps, even on fast machines. We got around that by using absolute positioning, rather than fixed. Because the portal container wraps the entire page, position values stay the same during scroll. If the host is in a fixed container, though, it would still jump. But we can detect that when we open the drop-down and use fixed positioning for it as well.

And then there's this:

If the host leaves the visible area — we need to close the drop-down. That's a job for Obscured service. It detects when the host is fully obscured by anything and closes drop-down in that case.

Dialogs

For dedicated portals study we can take a look at dialogs. Toast notifications and hints are very similar but there are some interesting topics to discuss with modals.

This is how dialog host looks like:

<section
   *ngFor="let item of dialogs$ | async"
   polymorpheus-outlet
   tuiFocusTrap
   tuiOverscroll="all"
   class="dialog"
   role="dialog"
   aria-modal="true"
   [attr.aria-labelledby]="item.id"
   [content]="item.component"
   [context]="item"
   [@tuiParentAnimation]
></section>
<div class="overlay"></div>

Instead of being a generic host it has an ngFor loop over particular items. This allows us to bundle some logic in, like focus trap and page scroll blocking. There is also a clever use of dependency injection here, allowing dialogs to be design and data model agnostic. Host collects observables with dialogs through a dedicated multi token, merges these streams and shows the result. That way you can have multiple designs for dialogs in the same app. Taiga UI has two built-in designs — base and mobile. But you can easily add your own. Let's see how.

Dialog service returns Observable. When you subscribe to it, modal popup is shown, when you terminate subscription it is closed. Dialog can also send back data through that stream. First, we design our dialog component. All that’s important here, really, is that you can inject POLYMORPHEUS_CONTEXT in constructor. It would contain an object with content and observer for a particular dialog instance. You can close dialog from within by calling complete on observer and you can send back data using next method. Plus all the options you will provide to the service that we will create by extending an abstract class:

const DIALOG = new PolymorpheusComponent(MyDialogComponent);
const DEFAULT_OPTIONS: MyDialogOptions = {
  label: '',
  size: 's',
};

@Injectable({
  providedIn: 'root',
})
export class MyDialogService extends AbstractTuiDialogService<MyDialogOptions> {
  protected readonly component = DIALOG;
  protected readonly defaultOptions = DEFAULT_OPTIONS;
}

In it we provide default config and a component to use and we're all set.

Dialogs, like everything in Taiga UI use ng-polymorpheus for customizable content. You can read more about making interface free, flexible components with it in this article.

Focus trapping is handled by the tuiFocusTrap directive. Since we have drop-downs later in DOM and we can have multiple dialogs open at the same time — we don't care if focus goes farther in the DOM. If it went somewhere prior to dialog though — we return focus back with a few helpers from @taiga-ui/cdk:

@HostListener('window:focusin.silent', ['$event.target'])
onFocusIn(node: Node) {
  if (containsOrAfter(this.elementRef.nativeElement, node)) {
    return;
  }

  const focusable = getClosestKeyboardFocusable(
    this.elementRef.nativeElement,
    false,
    this.elementRef.nativeElement,
  );

  if (focusable) {
    focusable.focus();
  }
}

Blocking page scroll is dealt with by combination of a directive and some logic inside the root component. Root just hides scrollbars when a dialog is open, while Overscroll directive takes care of touch and wheel scroll. There's a CSS rule for overscroll behavior. However it's not sufficient. It doesn't help when dialog is small enough that it doesn't have its own scroll. That's why we have a directive with some additional logic stopping scroll if it will happen in some patent node.

Bonus: what else does tui-root do?

As far as portals go — this covers most of it. Let's also take a quick look at what else is bundled with the root component. You saw in the template that it has tui-scroll-controls. These are custom scrollbars that control global scroll. You may have also noticed named content projections like <ng-content select="tuiOverDialogs"></ng-content>. With those you can slide some content in-between layers of Taiga UI if you need. For example, if you run another library for toasts or dialogs and want them properly placed vertically.

It also registers several event manager plugins in the DI. You can read about them in a dedicated article. It is important that TuiRootModule goes after BrowserModule so they are registered at the right order. But don't worry — if you get it wrong you will see an assertion message in the console.

That wraps it up for portals and the root component. Taiga UI is open-source and you can check it out on GitHub and npm. You can also browse the demo portal with documentation and play with it using this StackBlitz starter. Stay tuned for more articles on interesting features we have!

Discuss with community

Share

About the author

author_image

I’m a devoted Angular developer and a musician. I use one thing to help me not to starve while doing another and I love doing both. I work on Angular UI Kit at Tinkoff and I like sharing my findings.

author_image

About the author

Alex Inkin

I’m a devoted Angular developer and a musician. I use one thing to help me not to starve while doing another and I love doing both. I work on Angular UI Kit at Tinkoff and I like sharing my findings.

About the author

author_image

I’m a devoted Angular developer and a musician. I use one thing to help me not to starve while doing another and I love doing both. I work on Angular UI Kit at Tinkoff and I like sharing my findings.

NxAngularCli
NxAngularCli
NxAngularCli

Featured articles

Angularpost
4 March 20218 min read
Angular Universal: real app problems

Angular Universal is an open-source project that extends the functionality of @angular/platform-server. The project makes server-side rendering possible in Angular. This article will discuss the issues and possible solutions we encountered while developing a real application with Angular Universal.

Angularpost
4 March 20218 min read
Angular Universal: real app problems

Angular Universal is an open-source project that extends the functionality of @angular/platform-server. The project makes server-side rendering possible in Angular. This article will discuss the issues and possible solutions we encountered while developing a real application with Angular Universal.

Read more
AngularpostAngular Universal: real app problems

4 March 2021

8 min read

Angular Universal is an open-source project that extends the functionality of @angular/platform-server. The project makes server-side rendering possible in Angular. This article will discuss the issues and possible solutions we encountered while developing a real application with Angular Universal.

Read more
Angularpost
3 March 20215 min read
View State Selector  - Angular design pattern

As a web developer you may have noticed a repetitive boiler plate code of displaying a loader while an asynchronous request is being processed, then switching to the main view or displaying an error. Personally, I noticed these repetitions both in my code and other developers I work with. And even worse than the repetitive code is the fact that there are no indications for missing state views (such as unhandled errors or a missing loader). <div *ngIf="data$ | async as data"> <ng-container *ng

Angularpost
3 March 20215 min read
View State Selector  - Angular design pattern

As a web developer you may have noticed a repetitive boiler plate code of displaying a loader while an asynchronous request is being processed, then switching to the main view or displaying an error. Personally, I noticed these repetitions both in my code and other developers I work with. And even worse than the repetitive code is the fact that there are no indications for missing state views (such as unhandled errors or a missing loader). <div *ngIf="data$ | async as data"> <ng-container *ng

Read more
AngularpostView State Selector  - Angular design pattern

3 March 2021

5 min read

As a web developer you may have noticed a repetitive boiler plate code of displaying a loader while an asynchronous request is being processed, then switching to the main view or displaying an error. Personally, I noticed these repetitions both in my code and other developers I work with. And even worse than the repetitive code is the fact that there are no indications for missing state views (such as unhandled errors or a missing loader). <div *ngIf="data$ | async as data"> <ng-container *ng

Read more
RxJSpost
26 February 20213 min read
RxJS: Why memory leaks occur when using a Subject

It's not uncommon to see the words 'unsubscribe', 'memory leaks', 'subject' in the same phrase when reading upon RxJS-related materials. In this article, we're going to tackle this fact and by the end of it you should gain a better insight as to why memory leaks occur.

RxJSpost
26 February 20213 min read
RxJS: Why memory leaks occur when using a Subject

It's not uncommon to see the words 'unsubscribe', 'memory leaks', 'subject' in the same phrase when reading upon RxJS-related materials. In this article, we're going to tackle this fact and by the end of it you should gain a better insight as to why memory leaks occur.

Read more
RxJSpostRxJS: Why memory leaks occur when using a Subject

26 February 2021

3 min read

It's not uncommon to see the words 'unsubscribe', 'memory leaks', 'subject' in the same phrase when reading upon RxJS-related materials. In this article, we're going to tackle this fact and by the end of it you should gain a better insight as to why memory leaks occur.

Read more