Menu

Nebular meets Angular CDK

January 31st, 2019

A short while ago our team released a stable version of Nebular. Nebular is an Angular library that simplifies complex rich UI applications development. It consists of the following modules: Theme, Authentication, and Security.

During the multiple release candidates, we’ve experienced some challenges, and I want to tell you about the most interesting ones.

Nebular meets Angular CDK

Why we built Nebular with Angular CDK

As you know, it’s much easier to build user interfaces using component libraries. One of the vital parts of these libraries is floating components — components which are rendered over the other components and may overlap them, such as Dialogs, Context Menus, Toasts, Tooltips, etc. Nebular library is not an exclusion and requires such components too.

The first one decided to build was a Popover component. The idea of this component is to render content in an area around the host element. Here is an image just to illustrate:

Nebular meets Angular CDK
Check full Popover documentation here

We decided to create it as an Angular directive, that accepts the content as an @Input, something like this:

<button [nbPopover]="hello">Open Popover</button>
<ng-template #hello>
  <span>Hello, Popover!</span>
</ng-template>

The result was quite successful, and from our experiments, we also discovered the potential to use it as the basis for other components, such as Context Menu, Tooltip and so on. To give you some technical details, we implemented positioning using native JavaScript API, or in other words — element.getBoundingClientRect(). This allowed us to calculate the location of the Popover based on the host element position. Another cool feature we added is an AdjustmentStrategy. The key point of the strategy is to provide the capability to relocate the Popover’s position depending on available space around the host element. For example, if we placed it in position on top and there is not enough space in the viewport to render it there, the AdjustmentStrategy will try rendering it below the host.

We were satisfied with the results and started building the rest of the components based on our Popover implementation. It was fine during the first steps but going further we figured out that it has some limitations that we haven’t thought of initially. It was good for rendering floating panels around the host element like tooltips and context menus, but unfortunately, it wasn’t so successful at rendering global ones such as Toasts and Dialogs. At this point, we came to the understanding that the solution required is extensive and its implementation is going to be time-consuming. That’s why we started looking for third-party alternatives rather than building it from scratch.

Angular CDK Integration

We were aware that Angular provides a component development kit (CDK), so obviously, we decided to look there first. It turns out that it has a lot of basic concepts that are very useful for our cases. Let me explain which of them helped us the most.

Render Angular components dynamically on top of other components

Have you ever faced the situation when your dropdown is cut off by some parent element with overflow: hidden? Well, it is a quite common issue that we can only resolve by rendering the component somewhere at the top of the document tree, for example right after the <body> opening tag, and position the element as absolute.

This is a similar issue we were trying to resolve here in Nebular. At first, we implemented a custom solution. Nebular has a root component that wraps all the entire app. This root component has the capability to render components dynamically before the rest content using ComponentFactoryResolver provided by Angular’s core module.

But, by having the CDK in place we were able to resolve it much more elegantly, by utilizing the Portals & Overlays concept. Let’s have a look.

The Portal is a Component type or TemplateRef which can be dynamically rendered in one of the predefined slots on the page called PortalOutlets. Here’s a simple example:

this.loginPortal = new ComponentPortal(LoginComponent);

<ng-template [cdkPortalOutlet]="loginPortal"></ng-template>

The Overlay, in its turn, is an implementation of the PortalOutlet. The key point is that it renders the provided Portal as floating content somewhere on the page. Plus it supports position strategies so that we can describe how the content is placed in the document.

constructor(protected overlay: Overlay) {}

const overlayRef = overlay.create();
const userProfilePortal = new ComponentPortal(UserProfile);

overlayRef.attach(userProfilePortal);

Using this concept we can create a component quite simply. Here’s a quick example for Tooltip:

@Directive({ selector: '[nbTooltip]' })
export class NbTooltipDirective implements OnInit {

  @Input() content: string = '';

  // inject Angular CDK overlay service
  constructor(protected overlay: Overlay) {}

  ngOnInit() {
    // create overlay
    const overlayRef = this.overlay.create({ … });
  }

  show() {
    // create tooltip Portal
    const tooltipPortal = new ComponentPortal(NbTooltipComponent);

    // attach Portal to overlay
    const tooltipRef = overlayRef.attach(tooltipPortal);

    // render provided content
    tooltipRef.instance.content = this.content;
  }
}

The implementation looks pretty easy. We’re just injecting the CDK Overlay, instantiating it, creating a portal with our custom component and attaching it to the overlay reference previously created. In the end, we set the tooltip instance content by passing a string to the created component. The usage of the tooltip directive would be the following:

<span nbTooltip="This is a tooltip!">Hover on me</span>

The last thing left to get our tooltip ready is to add some code that will show/hide the tooltip as a reaction on the user interaction. And this is where Overlay Triggers come in play.

Show and hide dynamic content based on user interaction

The task of showing and hiding floating elements doesn’t look all that complicated. Even so, we need to create an abstraction to ensure the capability to adapt it to all components easily. CDK doesn’t have such functionality, however, we were able to easily extend it with our own TriggerStrategy notion. The idea of TriggerStrategy is to be able to subscribe to the show and hide events, but the events will be fired differently depending on the Strategy’s implementation — hover, click, focus, etc. The interface is pretty simple:

export interface TriggerStrategy {
  readonly show: Observable<Event>;
  readonly hide: Observable<Event>;
}
view raw

For our use cases, we developed the following implementations: ClickTriggerStrategy, HintTriggerStrategy and FocusTriggerStrategy.

Let’s have a look at the HintTriggerStrategy implementation:

export class NbHintTriggerStrategy extends NbTriggerStrategy {
  show: Observable<Event> = observableFromEvent<Event>(this.host, 'mouseenter');
  hide: Observable<Event> = observableFromEvent(this.host, 'mouseleave');
}

Show event will be fired if a user starts to move the mouse over the host element, and hide will be fired when moving the mouse out of the host element. Easy as that. The rest of the trigger strategies are implemented in a similar way. Floating components just register the required trigger strategy and show/hide as a reaction to trigger events.

Moving and Trapping browser focus between Angular components

Accessibility is a vital part of any modern web app. And Nebular is no exception. During the development of the Dialog component, we faced the following accessibility challenges: when a user opens a dialog by clicking on some button, the browser focus remains on the button. That means if you press space multiple times, you will create more dialogs. This definitely is not good. When your operating system shows a confirmation alert you just press the enter to confirm it. You don’t need to move the mouse or use tab to focus the OK button. The same behavior has to be applied to the dialog. The focus has to be moved to the first focusable element in the created dialog.

Here the Angular CDK accessibility module came in handy. It is supplied with the FocusTrap helper, which provides the ability to trap the focus within an element. Just what we needed with the Dialog component. Its usage is quite straightforward:

export class DialogComponent implements OnInit {
  protected focusTrap: FocusTrap;

  constructor(protected focusTrapFactory: FocusTrapFactory,
              protected elementRef: ElementRef) {
  }

  ngOnInit() {
    // create new focus trap
    this.focusTrap = this.focusTrapFactory.create(this.elementRef.nativeElement);

    // focus first focusable element inside dialog component
    this.focusTrap.focusInitialElement();
  }
}

That’s it! After rendering the DialogComponent, the first focusable element inside of it will receive the focus. But here we have another issue — we have to restore the focused element after closing the dialog. This functionality is not a part of Angular CDK, and that’s why we extended it and implemented the focus restoring functionality ourselves:

export class NbFocusTrap extends FocusTrap {
  protected previouslyFocusedElement: HTMLElement;

  constructor() {
    // …
    this.saveFocusedElement();
  }

  restoreFocus() {
    this.previouslyFocusedElement.focus();
  }

  protected saveFocusedElement() {
    this.previouslyFocusedElement = this.document.activeElement;
  }
}

And its usage becomes the following:

export class DialogComponent implements OnInit, OnDestroy {
  protected focusTrap: NbFocusTrap;

  constructor(protected focusTrapFactory: FocusTrapFactory,
              protected elementRef: ElementRef) {
  }

  ngOnInit() {
    // create new focus trap
    this.focusTrap = this.focusTrapFactory.create(this.elementRef.nativeElement);

    // focus first focusable element inside dialog component
    this.focusTrap.focusInitialElement();
  }

  ngOnDestroy() {
    this.focusTrap.restoreFocus();
  }
}

As you can see, it has now become quite easy to trap the focus inside component and then release it when the component has been destroyed. This behavior is required for interactive components that may appear on the page and then disappear after a user’s interaction.

Handle scroll and dimensions of an embedded app

One of the features of Nebular is the ability to be rendered in another application as an embedded app. For example, as a part of some other Angular or even non-Angular app. That’s why scroll handling and app measuring have to be done on the application container level and not at window level. Unfortunately, Angular CDK services such as ScrollDispatcher and ViewportRuler measure everything at window level. Luckily, there is no problem with providing custom implementations, which were very helpful in our case.

For example, the ScrollDispatcher adapter looks like this:

@Injectable()
export class NbScrollDispatcherAdapter extends ScrollDispatcher {

  scrolled(auditTimeInMs?: number): Observable<CdkScrollable | void> {
    return this.nebularScrollService.onScroll();
  }
}

And then provided like this:

@NgModule({
  providers: [
    { provide: ScrollDispatcher, useClass: NbScrollDispatcherAdapter },
  ],
})
export class NbCdkAdapterModule {}

As you can see, Angular CDK has quite good extensibility thanks to the dependency injection.

Conclusion

To recap, I have to say that the integration of Angular CDK was a very good investment. It saved us a considerable amount of time and gave us the capability to stop reinventing the wheel and concentrate fully on the development of components and Nebular-specific features. Moreover, Angular CDK expands easily enough, which means we can adapt it for our needs.

Useful Links