Demystifying Taiga UI root component: portals pattern in Angular

waterplea

Alex Inkin

Posted on January 26, 2021

Demystifying Taiga UI root component: portals pattern in Angular

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>
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Drop-downs

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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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!

💖 💪 🙅 🚩
waterplea
Alex Inkin

Posted on January 26, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related