{"community_link":"https://community.indepth.dev/t/agnostic-components-in-angular/755"}

Agnostic components in Angular

When you’re building a reusable components library, API is very important. On one hand, you want a tidy, reliable solution, on the other — you need to take care of lots of specific use cases. Learn how to make components that work with everything and look like anything!

Agnostic components in Angular

When you’re building a reusable components library, API is very important. On one hand, you want a tidy, reliable solution, on the other — you need to take care of lots of specific use cases. And it’s not just about working with diverse data. Your components must be flexible visually. Besides, it has to be easy to update and deliver across projects.

These components must be customizable like no other. And it shouldn’t be hard to do — they will be used by senior and junior developers alike. Also, since one of the reasons to make such a library is to reduce code duplication, configuration must not turn into a copy-paste exercise.

Data-agnostic components

Say you’re making a drop-down menu button. What would be its API? Surely it would need an array of items for the menu. Probably, the first run would be an interface like the following:

interface MenuItem {
    readonly text: string;
    readonly onClick(): void;
}

Fairly quickly, you’d add an option to disable an item. Then designers would come up with a menu with icons. Then they will draw them on the other side for the fellow project. The interface keeps growing, taking more and more cases into account. Great variety of flags turn your component into the United Nations assembly.

Looking familiar?

Generics to the rescue. If you build your component to not care about the data model at all — this problem seizes to exist. Instead of calling item.onClick it could emit clicked item. What to do with it is up to the library users. They are free to call item.onClick or organize their model differently.

In the case of disabled state, a component would use a handler. It’s a function that takes generic item as an argument and returns whether it is disabled or not. Handling different visual options would be explored later.


For a ComboBox, you could come up with an interface with string for display value and a property with the real value behind it. This seems reasonable. After all, when user types into ComboBox, options must be narrowed down by the string input.

interface ComboBoxItem {
    readonly text: string;
    readonly value: any;
}

You would soon uncover limitations of this approach once design pops up where string is not enough. Besides, form contains a wrapper instead of the real value and filtering is not always limited to string representation. You could filter by phone number contacts that show names. And each new component like that brings new interface even if the underlying model is the same.

Here you can also use generics and provide a stringify handler to the component: (item: T) => string. A simple String(item) would work by default. This way, you can even use classes for options by defining toString method. As mentioned earlier, string is not always enough to filter. It’s another good case for handlers — we could pass a matching function to ComboBox. It would take user input and item as arguments and return true if item fits.

Another example where interface is often used — unique id. You might get your value in one request and options later in another. Then you will have a copy of the selected item in the array, not the same reference. Instead of an id property, you could have an identity matching function. It would take two items as arguments with the default value of simple triple = comparison.

Many components, such as tabs, radio buttons, variety of lists don’t need to be aware of the data model they work with. Abstraction over it allows to create extendable code not tied to concrete realization. This means there will be no breaking changes with added features. Users will not have to adapt their data and components fit together like Lego blocks.

The same options component could go in context menu, Select, MultiSelect. Atomic components effortlessly make up bigger structures. However, generic data must be displayed somehow. Lists could have avatars, different colors, unread messages counters and so on.

Example drop-down with custom design

To make this work, we need to approach presentation in the same manner as we took on generic data.

Design-agnostic components

Angular provides powerful tools to set the looks. We will take ComboBox as an example because its design ranges greatly from case to case. Of course, some level of limitation will be built-in. Component still has to obey general design. Size, default colors, padding — we don’t want to bother users with everything view-related.

You put water in a cup, it becomes the cup.
You put water in a bottle, it becomes the bottle.
You put water in a teapot, it becomes the teapot.
Bruce Lee

Arbitrary data is a lot like water. It’s shapeless, it has nothing concrete about it. Our task is to let users provide a vessel to shape it. In that sense, developing a design-agnostic component looks somewhat like this:

A component is like a fixed size shelf where user puts his template. A basic representation like string value is there by default. And user can pass on more fancy design according to his model.


Let’s see what Angular has to offer in terms of customization:

Interpolation

The most basic instrument is string interpolation. If you pass a string to the menu component, it won’t be suitable for options display. Static string lacks context. But it could work fine as «Nothing is found» text in case the list is empty.

<div>{{content}}</div>

Function interpolation

We mentioned string representation before. The result is also a string but it is determined by input value. In this case, context is the actual list item. This is a dynamic instrument but it doesn’t interpolate into HTML. And does not allow directives or components.

<div>{{content(context)}}</div>

Templates

To make a reusable piece of layout Angular has ng-template. With it, we can designate a block of HTML that expects some input data and pass it to the component. There it would be instantiated with concrete context. We will use the item without knowing its model. Coming up with the right template for his data is a task for developer who uses the component.

<ng-container
    [ngTemplateOutlet]="content"
    [ngTemplateOutletContext]="context"
></ng-container>

Template is a powerful tool, but it has to be defined on some existing component. There lies a big limitation to its reusability. Often same looks must be used in many parts of the application or even shared across applications. In my work an example is account selection control:

Account selection component

Components

The most sophisticated way to alter the view is dynamic components. Angular had a directive for declarative component instantiation for a while now — *ngComponentOutlet. It doesn’t have context but this could be solved with dependency injection. We can define a token for context and add it to the Injector used to create component.

<ng-container
    [ngComponentOutlet]="content"
    [ngComponentOutletInjector]="injector"
></ng-container>

It’s worth mentioning that not only the element we want to display could be used as context. We could also include the circumstances in which we display it:

<ng-template let-item let-focused="focused">
    <!-- ... -->
</ng-template>

For example, the account select changes appearance on focus. The icon background turns gray. Generally speaking, context might include conditions that could potentially influence the looks. This moment is the only interface-like limitation of this approach to development.

In drop-down, focused background is gray instead, so icon background is white

Universal Outlet

Described instruments are available in Angular starting version 5. But we want to be able to switch between them on the fly. To do so we would combine them inside a component, taking content and context as inputs. It would determine the appropriate method from the content type. We need to be able to distinguish between string, number, (context: T) => string | number, TemplateRef<T> and Type<any>. But there are a few nuances we will explain later.

Our component’s template would look something like this:

<ng-container [ngSwitch]="type">
  <ng-container *ngSwitchCase="'primitive'">{{content}}</ng-container>
  <ng-container *ngSwitchCase="'function'">{{content(context)}}</ng-container>
  <ng-container *ngSwitchCase="'template'">
    <ng-container *ngTemplateOutlet="content; context: context"></ng-container>
  </ng-container>
  <ng-container *ngSwitchCase="'component'">
    <ng-container *ngComponentOutlet="content; injector: injector"></ng-container>
  </ng-container>
</ng-container>

Basically, we have a getter for the type to pick the right tool. Here we should point out that there’s no way to tell a function from an arbitrary component. But we will deal with it. To use dynamic components inside lazy modules you’d need that module’s Injector. Otherwise local Injector might not have access to necessary entryComponents if you run your app pre-Ivy. To store it with the component we can make a wrapper class. It would also allow us to use instanceof.

export class ComponentContent<T> {
  constructor(
    readonly component: Type<T>,
    private readonly injector: Injector | null = null,
  ) {}
}

This class would have a method to create Injector with context:

createInjectorWithContext<C>(injector: Injector, context: C): Injector {
    return Injector.create({
        parent: this.injector || injector,
        providers: [{
            provide: CONTEXT,
            useValue: context,
        }],
    });
 }

When it comes to templates, they work fine as is in most cases. But you have to keep in mind that template follows change detection of a view where it was defined, not where it was instantiated. If you pass it up the view tree, changes that could be caused inside it will not be picked by the original view.

To remedy that we will use a directive rather than a plain template. All it will do is keep track of ChangeDetectorRef so it could mark the view for checking when necessary.

Polymorphic templates

In practice, sometimes it is useful to alter view behavior depending on the type of content. Say you want to be able to use a template for something special. But at the same time a general use would be to display an icon. In that case, we could define a default behavior for primitives and functions. Even primitive type could matter. If you have a badge component for numbers, you might want to show it for unread messages on the tab in place of the icon.

Which pill will you choose? Polymorpheus has plenty

To be able to do that, there’s one last thing we need to add — passing default template for primitives. We can add @ContentChild to query content for TemplateRef. In case we found one, we will instantiate it with our primitive as context:

<ng-container *ngSwitchCase="'interpolation'">
  <ng-container *ngIf="!template; else child">{{primitive}}</ng-container>
  <ng-template #child>
    <ng-container
      *ngTemplateOutlet="template; context: { $implicit: primitive }"
    ></ng-container>
  </ng-template>
</ng-container>
Interpolation section of our Outlet with custom template

It is now possible to add styles to interpolation or even pass it along to a special component to display:

<outlet [content]="content" [context]="context">
  <ng-template let-primitive>
    <div class="primitive">{{primitive}}</div>
  </ng-template>
</outlet>
Usage with custom template for primitives

Now it is time to try all this out in practice.

Usage

We will create two components: Tabs and ComboBox. Tabs template would consist of our component outlet repeated with *ngFor. Tab item will be used as context along with active tab information:

<outlet
   *ngFor="let tab of tabs"
   [class.disabled]="disabledItemHandler(tab)"
   [content]="content"
   [context]="getContext(tab)"
   (click)="onClick(tab)"
></outlet>

It is necessary to define basic styles, like font size, colors, underline. But the actual look of each item would be determined by content. Here’s code for the component:

export class TabsComponent<T> {
   @Input()
   tabs: ReadonlyArray<T> = [];

   @Input()
   content: Content = ({$implicit}) => String($implicit);

   @Input()
   disabledItemHandler: (tab: T) => boolean = () => false;

   @Input()
   activeTab: T | null = null;

   @Output()
   activeTabChange = new EventEmitter<T>();

   getContext($implicit: T): IContextWithActive<T> {
       return {
           $implicit,
           active: $implicit === this.activeTab,
       };
   }

   onClick(tab: T) {
       this.activeTab = tab;
       this.activeTabChange.emit(tab);
   }
}

This gives us a component capable of showing arbitrary array as tabs.

We can pass strings to get simple tabs.
Or we can make something more complex.

Pass objects and custom representation to get icons, HTML styling, indicators.


In the case of ComboBox, we need to first make two basic components that it consists of — input with an icon and a drop-down menu. Let’s not spend time on the menu, it is basically the same as tabs with vertical layout and different base styles. As for input, it could look this way:

<input #input [(ngModel)]="value"/>
<content-outlet
   [content]="content"
   (mousedown)="onMouseDown($event, input)"
>
   <ng-template let-icon>
       <div [innerHTML]="icon"></div>
   </ng-template>
</content-outlet>

If we position native input absolutely, it covers outlet and captures all the clicks. This is great if we just want a decorative icon, such as magnifying glass. You can see polymorphic template approach in action. A string would be used as SVG icon source. If we want to show user avatar, for example, we can pass it as a template.

For ComboBox we need an arrow icon, but it has to be interactive. It also must not interfere with focus. To handle that we will add a mouse down event handler on the outlet:

onMouseDown(event: MouseEvent, input: HTMLInputElement) {
    event.preventDefault();
    input.focus();
}

Inside ComboBox, we will pass icon to input as a template, not as string. This allows us to lift it over input with CSS position: relative and subscribe to clicks on it:

<app-input [content]="icon"></app-input>
<ng-template #icon>
   <svg
       xmlns="http://www.w3.org/2000/svg"
       width="24"
       height="24"
       viewBox="0 0 24 24"
       class="icon"
       [class.icon_opened]="opened"
       (click)="onClick()"
   >
       <polyline
           points="7,10 12,15 17,10"
           fill="none"
           stroke="currentColor"
           stroke-linecap="round"
           stroke-linejoin="round"
           stroke-width="2"
       />
   </svg>
</ng-template>

This will get us the desired behavior:

Handling interactive icon

Component code, just like tabs, gets things done without data model knowledge. It looks roughly like this:

export class ComboBoxComponent<T> {
   @Input() items: ReadonlyArray<T> = [];
   @Input() content: Content = ({$implicit}) => String($implicit);
   @Input() stringify = (item: T) => String(item);
   @Input() value: T | null = null;
   
   @Output() valueChange = new EventEmitter<T | null>();
   
   stringValue = '';
   
   get filteredItems(): ReadonlyArray<T> {
       return this.items.filter(item =>
           this.stringify(item).includes(this.stringValue),
       );
   }
}
Form control related code is purposefully omitted to keep examples short. Getter for filteredItems can be optimized with a pure pipe, see this example

Such simple component allows to use any objects inside ComboBox and customization is very flexible. After a few unrelated improvements to the code, it is ready to use. You can configure it to look like anything you want:

The force is with us on this one!

Keep in mind that this view customization can be isolated into its own component so it can be shared across projects.

Takeaway

Agnostic components free you from the need to take every particular case into account. At the same time, your users get a simple instrument to tweak your component to their needs. These solutions are easily reusable. Dropping model dependency makes your code universal, reliable and extendable. And all this is available with a few lines of code, most of the work is delegated to Angular built-in features.

Once you start using this approach you’ll quickly notice a change. It is very handy to think in terms of content, rather than particular templates or strings. Validation errors, tooltips, modals — this approach is great for arbitrary content in general. It’s very easy to mock up interface and test your logic when, for example, to show a pop-up you don’t have to create a component of even define a template. You could host pass a dummy string and get back to it later.

We at Tinkoff have been successfully using this approach for a while now. So we have made it into tiny (1 KB gzip) open-source library called ng-polymorpheus

Interactive demo and sandbox
Do you also want to open-source something, but hate the collateral work? Check out this Angular Open-source Library Starter we’ve created for our projects. It got you covered on continuous integration, pre-commit checks, linting, versioning + changelog, code coverage and all that jazz.