Всем привет, я хочу поделится с вами своим подходом к созданию UI контролов который позволит их создавать за считанные минуты. При этом мы будем использовать декларативный подход, что означает что каждый компонент будет ответственен за свое собственное поведение и будет максимально независимым.

Что такое UI контролы?

Сперва определим что под UI контролами подразумеваются любые компоненты которые имеют модель или зависят от нее, Combobox, Checkbox, Checkbox Group, Chip, и т.д. все что обычно вы используете вместе с NgModel или FormControl.

Отступление

Я несколько лет работал над созданием и поддержкой внутренней библиотеки компонентов в своей компании, библиотека содержала много различных компонентов и директив. Мы имели много зависимых от библиотеки проектов и часто встречались случаи когда необходимо было дать возможность кастомизировать тот или иной контрол при этом не расширяя его изначальное API. Это происходило из за различных экспериментов в отделе UX, и получалось так, что в двух разных проектах комбобокс мог иметь разные элементы внутри или иметь разную логику поведения.

Подобные случаи были не редкость, и я начал думать над ее решением, что бы контролы имели базовое поведение, при этом могли быть легко расширены путем обычного добавления одного компонента внутрь тега другого. В этой статье мы рассмотрим данный подход, а так же библиотеку в которую я вынес все что необходимо для их создания.

Чего хотелось достичь?

В идеале мне хотелось что бы библиотека компонентов имела уже заранее созданный компонент который имел бы простое API, покрывал большинство требования и мог быть легко встроен в любую форму, к примеру комбобокс мог быть использован так:

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

Но при этом, что бы библиотека компонентов экспортировала все его внутренние элементы которые могли бы быть использованы отдельно что бы собрать свой собственный комбобокс только при помощи голого HTML, к примеру вот так:

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

Т.е. когда пользователю библиотеки необходимо создать свой собственный комбобокс он мог бы это сделать просто собрав необходимые элементы внутри HTML, и все начинало работать как единое целое.

Стандартный подход

Подход выше будет не возможен в библиотеках компонентов которые вы можете встретить, вся проблема заключается в синхронизации состояния дочерних элементов.

К примеру давайте рассмотрим компонент Select из библиотеки Angular Material.

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

Когда пользователь кликает на элемент списка, мы должны сделать следующее:

  • Пометить элемент как выбранный

  • Обновить значение Input поля

  • И обновить модель NgControl

Во всех публичных библиотеках компонентов что я видел всегда используется один и тот же принцип, главный компонент (в данном случае mat-select) отвечает за все что относится к синхронизации состояния. В данном случае именно этот компонент соберет список всех элементов в dropdown, подпишется на событие клика и будет выполнять требования выше (обновлять значение input поля, модели и состояние элемента списка).

Т.е. в данном случае все компоненты жестко зависимы от логики прописанной вmat-select и что бы добавить что то свое внутрь mat-select компонента, к примеру заменить input или option на свой, добавить кнопку очистки или любой другой элемент, потребуется изменения его typescript кода что бы обновление состояния работало корректно.

Так же подобные компоненты со временем становится трудно поддерживать из за большого количества кода и разных условий для обновления состояния собранных в одном едином месте.

Подход di-controls

Как упоминалось выше, свой подход я вынес в отдельную библиотеку которая называется di-controls . Давайте рассмотрим чем отличается данный подход.

Основная идея состоит в том что бы каждый элемент контрола который зависит от модели имел доступ к этой самой модели, мог ее обновлять и сам решал какое состояние необходимо отображать на ее основании.

К примеру, в компоненте Select, есть 3 компонента зависимые от модели, это:

  • Option (элемент списка)

  • Input

  • И сам компонент Select

Если каждый элемент знает модель, то по сути ему больше ничего не нужно что бы корректно работать.

di-controls позволяет создавать контролы которые по умолчанию умеют работать с NgModel и FormControl, а так же синхронизировать модель между связанными при помощи Dependency Injection контролами, для того что бы они отображали необходимое состояние. Все что остается вам, это реализовать свою бизнес логику, все остальное сделает di-controls.

Создание своего Select

Теперь давайте рассмотрим пример создания своего собственного селекта. И мы начнем с Input.

Хорошим тоном будет дать вашим пользователям доступ к нативному тегу input, что бы при желании они могли использовать различные маски и иметь доступ к нативным свойствам тега, по этому давайте реализуем директиву вместо компонента.

Прежде всего, библиотека di-controls имеет 4 класса для реализации различных частей ваших контролов, они умеют работать друг с другом и синхронизировать свое состояние. Больше о них вы можете прочитать в документации.

Для реализации директивы инпута, нам необходимо унаследовать класс директивы от DIControl класса. Это основной класс для реализации большинства контролов.

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 и другие доступные из библиотеки классы дают вам доступ к дополнительным методам и хукам, таким как updateModel для обновления модели или touch для обновления состояния контрола. А так же принимают дополнительные параметры внутри вызова super, давайте их немного разберем.

  • Свойство host принимает родительский контрол который может быть получен через Dependency Injection при помощи функции injectHostControl , таким образом модель инпута будет всегда синхронизирована с родительским контролом.

  • Хук onIncomingUpdate позволяет вызывать кастомный код когда модель была обновлена из вне, к примеру обновление через FormControl.setValue или обновление от родительского контрола. Обновления через updateModel не вызывают данный хук.

Мы так же добавили stringifyFn которая может помочь с приведением различных значений к строке, например объектов.

Следующее что нам понадобиться это Option (элемент списка). Элемент списка является контролом состояния он может быть выбран или нет. Для создания таких контролов нам нужно воспользоваться классом DIStateControl, который предоставляет дополнительный функционал для реализации подобных контролов.

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();
  }
} 

Здесь можно заметить новое свойство compareHost, оно используется DIStateControl для того что бы правильно определять checked состояние когда вы работаете с иммутабельным объектами, далее мы его реализуем внутри нашего комбобокса.

Так же стоит обратить внимание на стили, к примеру для индикации выбранного элемента мы используем aria-checked="true" атрибут, который будет установлен DIStateControl для вашего тега.

Теперь давайте создадим основной компонент, который объеденит и заставит работать сообща все компоненты выше. Для того что бы упростить пример я буду использовать position: absolute для реализации dropdown, но в реальных проектах это так же может быть система оверлеев от Angular CDK.

Для реализации нашего главного компонента мы можем воспользоваться классом DIControl, нам так же следует реализовать интерфейс DICompareHost, который мы инжектим внутри option компонента.

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;
  }
}

Первое на что стоит обратить внимание это секцию providers, здесь мы провайдим компонент как хост контрол и как DICompareHost что бы дочерние компоненты могли его найти.

Внутри темплейта мы передаем stringifyFn в директиву inputString что бы приводить к строке объекты, а так же делаем инпут поле readonly, т.к. в компоненте select значение инпута не может быть изменено пользователем.

Так же стоит заметить что мы закрываем dropdown через хук onChildControlChange, эта вынужденная мера из за упрощенной реализации dropdown, в реальности должен существовать механизм определения клика "во вне". Событие blur у инпута мы так же не можем использовать потому что оно сработает раньше чем произойдет клик по элементу списка таким образом dropdown закрылся бы без выбора элемента.

Создав все компоненты, мы можем их использовать следующим образом:

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

Выглядит почти как в Angular Material, разница лишь в том что теперь вместо option, ваш пользователь может пробросить любой другой компонент, радио кнопки, чекбоксы или любой другой контрол состояния. А если вы перенесете логику select компонента в скажем select-host компонент и станете принимать <ng-content> вместо хардкода инпут поля и dropdown в темплейте, то ваши пользователи смогут пробрасывать все что угодно внутрь вашего компонента и достичь полной кастомизации без ущерба для основной реализации.

Как пример выше работает под капотом?

Когда мы вызываем check() метод у option компонента, значение которое он имеет для @Input() value будет присвоено в качестве его модели, после чего он определит свое checked состояние на основании сравнения @Input() value === model . Далее модель будет синхронизирована с остальными связанными контролами (inputString и my-select).

Тоже самое произойдет и в обратную строну, к примеру если мы обновим модель my-select при помощи ngModel, модель будет синхронизирована между всеми компонентами дерева, option ,input компоненты отобразят новое состояние.

Заключение

В примере мы реализовали достаточно простое дерево компонентов, но используя библиотеку di-controls вы можете создавать более сложные вещи, ваши контролы-хосты могут инжектить другие контролы-хосты используя функцию injectHostControl({ skipSelf: true }), таким образом вы можете синхронизировать модель между большими деревьями компонентов и создавать более сложные вещи в несколько строк кода!

Рассмотренный метод действительно очень гибкий, давать вашим пользователям подобную кастомизацию или нет, зависит только от вас, библиотека вас не как не ограничивает.

Скорость с которой вы создаете новые контролы гораздо выше, а сами компоненты гораздо стабильнее.

Тестирование будет так же намного проще, потому что работа с моделью и состояниям уже покрыты внутри библиотеки di-controls вам же нужно лишь покрывать собственную бизнес логику.

Надеюсь мой опыт создания библиотеки UI компонентов был вам полезен и пригодится в создании своих собственных! Так же советую посетить документацию в которой много различных примеров компонентов и использования.

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