Incredibly flexible and reusable UI controls for Angular

Hello everyone, I want to share with you my approach to creating UI controls that will allow you to create them in a matter of minutes. In doing so, we will use a declarative approach, which means that each component will be responsible for its own behavior and will be as independent as possible.

What are UI controls?

First, let’s define that UI controls mean any components that have a model or depend on it, Combobox, Checkbox, Checkbox Group, Chip, etc. everything you usually use with NgModel or FormControl.

Retreat

I worked for several years on creating and maintaining an internal library of components in my company, the library contained many different components and directives. We had many projects dependent on the library, and there were often cases when it was necessary to provide the opportunity to customize one or another control without expanding its original API. This was due to various experiments in the UX department, and it turned out that in two different projects the combobox could have different elements inside or have different behavioral logic.

Such cases were not uncommon, and I began to think about a solution so that the controls would have basic behavior, but could be easily extended by simply adding one component inside the tag of another. In this article we will look at this approach, as well as the library in which I took everything that is necessary to create them.

What did you want to achieve?

Ideally, I would like the component library to have a pre-created component that would have a simple API, cover most of the requirements and could be easily built into any form, for example a combobox could be used like this:

<combobox [items]=[items] [ngModel]="model"></combobox>

But at the same time, the component library would export all its internal elements that could be used separately to assemble your own combobox using just bare HTML, for example like this:

<combobox-host>
  <input inputDirective/>
  <clear-button/>
  <dropdown>
    <option *ngFor="let item of items" [value]="item">
      {{ item.label }}
    </option>
  </dropdown>
</combobox-host>

Those. when a library user needed to create his own combobox, he could do it simply by collecting the necessary elements inside the HTML, and everything began to work as a single whole.

Standard approach

The above approach will not be possible in the component libraries that you may come across, the whole problem lies in synchronizing the state of child elements.

For example, let’s look at the Select component from the Angular Material library.

<mat-select>
    <mat-option *ngFor="let food of foods" [value]="food.value">
      {{food.viewValue}}
    </mat-option>
</mat-select> 

When the user clicks on a list item, we must do the following:

  • Mark item as selected

  • Update the Input field value

  • And update the NgControl model

In all public component libraries that I have seen, the same principle is always used, the main component (in this case mat-select) is responsible for everything related to state synchronization. In this case, it is this component that will collect the list of all elements in dropdownwill subscribe to the click event and will fulfill the requirements above (update the value of the input field, model and list item state).

Those. in this case, all components are strictly dependent on the logic specified inmat-select and to add something of your own inside mat-select component, for example replace input or option to your own, add a clear button or any other element, you will need to change its typescript code for the state update to work correctly.

Also, such components become difficult to maintain over time due to the large amount of code and different conditions for updating the state collected in one single place.

di-controls approach

As mentioned above, I moved my approach to a separate library called di-controls . Let’s look at how this approach differs.

The main idea is that each control element that depends on the model has access to this very model, can update it and decide for itself what state needs to be displayed based on it.

For example, in the Select component, there are 3 model-dependent components, these are:

If each element knows the model, then in fact it does not need anything else to work correctly.

di-controls allows you to create controls that by default can work with NgModel And FormControl, as well as synchronize the model between controls connected using Dependency Injection, so that they display the required state. All that remains for you is to implement your business logic, everything else will be done di-controls.

Creating your own Select

Now let’s look at an example of creating your own select. And we’ll start with Input.

It’s a good idea to give your users access to the native tag inputso that if they wish, they can use various masks and have access to the native properties of the tag, so let’s implement a directive instead of a component.

First of all, the library di-controls has 4 classes for implementing various parts of your controls; they can work with each other and synchronize their state. You can read more about them in documentation.

To implement the input directive, we need to inherit the directive class from DIControl class. This is the main class for implementing most controls.

import { Directive, Input, ElementRef, HostListener, inject } from '@angular/core';
import { DIControl, injectHostControl } from 'di-controls';

@Directive({
  selector: 'input[inputString]',
  standalone: true,
})
export class InputStringDirective<T = unknown> extends DIControl<T> {
  @Input()
  stringifyFn: (value: T) => string = String;
  
  protected readonly inputElement: HTMLInputElement = inject(ElementRef).nativeElement;

  constructor() {
    super({
      // Инжектим родитеский контрол если он существует
      // что бы синхронизировать модели
      host: injectHostControl({ optional: true }),
      // При входящем обновлении обновляем значение input тега
      onIncomingUpdate: (value: T | null) => {
        this.inputElement.value = value ? this.stringifyFn(value) : '';
      },
    });
  }

  @HostListener('input')
  protected onInput(): void {
    // При вводе нового значения обновляем модель
    this.updateModel(this.inputElement.value as unknown as T);
  }

  @HostListener('blur')
  protected onBlur(): void {
    // Устанавливаем состояние touched для нашего NgControl
    this.touch();
  }
} 

DIControl and other classes available from the library give you access to additional methods and hooks, such as updateModel to update the model or touch to update the control state. They also accept additional parameters inside the call superlet’s look at them a little.

  • Property host accepts a parent control which can be obtained via Dependency Injection using the function injectHostControl so the input model will always be in sync with the parent control.

  • Hook onIncomingUpdate allows you to call custom code when the model has been updated from outside, for example updating via FormControl.setValue or update from parental controls. Updates via updateModel do not call this hook.

We also added stringifyFn which can help with casting various values ​​to a string, such as objects.

The next thing we need is an Option (list element). A list item is a state control; it can be selected or not. To create such controls we need to use the class DIStateControlwhich provides additional functionality for implementing such controls.

import {ChangeDetectionStrategy, Component, HostListener, inject} from '@angular/core';
import {DICompareHost, DIStateControl, injectHostControl} from 'di-controls';

@Component({
  selector: 'option',
  standalone: true,
  template: `<ng-content></ng-content>`,
  styles: [
    `
      :host {
        display: block;
        cursor: pointer;
        padding: 8px 16px;

        &:hover {
          background-color: #e4ecff;
        }

        // Меняем стиль на основании состояния
        &[aria-checked="true"] {
          color: #fff;
          background-color: #8dafff;
        }
      }
    `,
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OptionComponent<T> extends DIStateControl<T> {
  constructor() {
    super({
      // Инжектим родитеский контрол если он существует
      // что бы синхронизировать модели
      host: injectHostControl({ optional: true }),
      // Инжектим хост компонент который имплементирует интерфейс DICompareHost
      // который содержит compareFn для сравнения иммутабельных объектов
      compareHost: inject(DICompareHost, { optional: true }),
    });
  }

  @HostListener('click')
  onClick() {
    // Устанавливаем выделение на клик
    this.check();
  }
} 

Here you can notice a new property compareHostit is used DIStateControl in order to correctly determine checked state when you work with immutable objects, then we will implement it inside our combobox.

It is also worth paying attention to styles, for example, to indicate the selected element we use aria-checked="true" attribute to be set DIStateControl for your tag.

Now let’s create a main component that will combine and make all the components above work together. To simplify the example I will use position: absolute for implementation dropdownbut in real projects this could also be the overlay system from Angular CDK.

To implement our main component, we can use the class DIControlwe should also implement the interface DICompareHostwhich we inject inside option component.

import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
  DICompareFunction,
  DICompareHost,
  DIControl,
  DIStateControl,
  provideCompareHost,
  provideHostControl,
} from 'di-controls';
import { InputStringDirective } from './input-string.directive';

@Component({
  selector: 'my-select',
  standalone: true,
  imports: [CommonModule, InputStringDirective],
  template: `
    <input inputString [stringifyFn]="stringifyFn" readonly="true" (focus)="open()" (blur)="touch()" />

    <div class="dropdown" *ngIf="opened">
      <ng-content></ng-content>
    </div>
  `,
  styles: [
    `
      :host {
        position: relative;
        display: inline-block;
      }

      input {
        cursor: pointer;
      }

      .dropdown {
        position: absolute;
        display: flex;
        flex-direction: column;
        width: 100%;
        border: 1px solid #ccc;
        border-radius: 4px;
        background: #fff;
        z-index: 1;
      }
    `,
  ],
  providers: [
    // Провайдим компонент как хост, что бы дочерние контролы
    // могли его найти и взаимодействовать с ним
    provideHostControl(SelectComponent),
    // Так же провайдим его как DICompareHost, для обеспечения
    // доступа к compareFn
    provideCompareHost(SelectComponent),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SelectComponent<T> extends DIControl<T> implements DICompareHost<T>
{
  @Input()
  compareFn: DICompareFunction<T> = (a, b) => a === b;

  @Input()
  stringifyFn: (value: T) => string = String;

  protected opened: boolean = false;

  constructor() {
    super({
      onChildControlChange: (control: DIControl<T>) => {
        // Закрывает дропдаун при выборе элемента из списка
        if (control instanceof DIStateControl) {
          this.close();
        }
      },
    });
  }

  open(): void {
    this.opened = true;
  }

  close(): void {
    this.opened = false;
  }
}

The first thing you should pay attention to is the section providershere we will provide the component as a host control and how DICompareHost so that child components can find it.

Inside the template we pass stringifyFn to the directive inputString to convert objects to a string, and also make an input field readonly, because in the component select the input value cannot be changed by the user.

It’s also worth noting that we are closing dropdown via hook onChildControlChangethis is a necessary measure due to the simplified implementation dropdown, in reality there should be a mechanism for determining the click “outside”. Event blur We also cannot use the input because it will work before the list element is clicked in this way dropdown would close without selecting an element.

Having created all the components, we can use them as follows:

<my-select [(ngModel)]="model" [compareFn]="compareFruits" [stringifyFn]="displayFruit">
  <option *ngFor="let item of items" [value]="item">{{ item.name }}</option>
</my-select>

Looks almost like in Angular Material, the only difference is that now instead of option, your user can forward any other component, radio buttons, checkboxes or any other state control. And if you transfer the logic select component in say select-host component and begin to take <ng-content> instead of hardcode input fields and dropdown in the template, then your users will be able to throw anything inside your component and achieve complete customization without compromising the main implementation.

In the example, we implemented a fairly simple component tree, but using the library di-controls you can create more complex things, your host controls can inject other host controls using the function injectHostControl({ skipSelf: true })this way you can sync the model across large component trees and create more complex things in a few lines of code!

Conclusion

The considered method is indeed very flexible; whether to give your users such customization or not depends only on you, the library does not limit you in any way.

The speed at which you create new controls is much higher, and the components themselves are much more stable.

Testing will also be much easier, because working with the model and states are already covered inside the library di-controls you only need to cover your own business logic.

I hope my experience in creating a library of UI components was useful to you and will be useful in creating your own! I also advise you to visit the documentation, which contains many different examples of components and usage.

Github – https://github.com/skoropadas/di-controls
Documentation – https://skoropadas.github.io/di-controls
Stackblitz Demo – https://stackblitz.com/edit/di-controls-select?file=src%2Fmain.ts

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *