Кастомный Angular multiselect контрол с поиском на сервере
Статья на английском языке (Article in English)
1. Предисловие
Всем привет 👋, это мой первый пост во всем интернете, и сегодня я хотел бы поделиться с вами решением проблемы, с которой я недавно столкнулся на работе. Нужно было реализовать компонент множественного выбора, который предлагал элементы по части слова, выполняя поиск на сервере. А проблема в том, что из предлагаемых мне библиотек и UI китов я не нашел ни одного годного решения и мне пришлось писать свою реализацию. Ну что же начнем!
2. Необходимые навыки и знания
- Angular 2+.
- Средний уровень знаний (уровня middle и выше) Angular 2+.
- Создание custom controls в Angular.
- UI библиотека — Angular Material, так как наш multiselect с поиском будет основан на некоторых компонентах Angular Material.
3. Как создать custom controls в Angular?
Если с данной темой вы еще не знакомы, то рекомендую к прочтению этот раздел, а если уже знакомы с данным функционалом Angular, то смело пропускайте и переходите к следующему разделу .
Angular предлагает нам простой, но мощный инструмент для создание кастомных контролов, если вдруг по какой-то причине нам не понравились, либо не хватает нативных (input, checkbox, select и т.д.).
Еще важное замечание кастомные контролы можно использовать и с реактивными формами (reactive form), и с шаблонными формами (template-driven form). Для примера будем использовать компонент счетчик.
Сначала напишем следующий код:
Для того чтобы интегрировать кастомный контрол в Angular forms, нужно чтобы этот контрол реализовывал интерфейс — ControlValueAccessor
, это яркий пример полиморфизма.
Angular и сам использует под капотом ControlValueAccessor
, для приведения к единому виду поведение нативных контролов. Но что Angular делает, используя ControlValueAccessor
? Все просто, записывает значение из модели в DOM (view), а также поднимает событие изменения контрола до FormGroup
и других директив.
Официальная дока по ControlValueAccessor
здесь — https://angular.io/api/forms/ControlValueAccessor.
Или вариант с не официальной документацией, на мой взгляд она понятнее— https://tyapk.ru/blog/post/angular-custom-form-field-control.
Как видно из официальной документации интерфейс обязывает нас реализовать три обязательных writeValue
, registerOnChange
, registerOnTouched
и один необязательный метод setDisabledState
.
writeValue(value: any) — записывает новое значение в контрол. Вызывается при задании контрола
new FormControl('Default value')
или при установки нового значения, например, черезcontrol.setValue('New value')
.registerOnChange(fn: any)— регистрирует callback функцию, которая вызывается при изменении значения контрола в интерфейсе, для метода (change) в представлении.
registerOnTouched(fn: any)— · определяет callback, который вызывается на событие снятия фокуса с контрола (on blur).
setDisabledState(isDisabled: boolean) — опциональная функция, которая вызываться при изменении значения
[disabled]="true"
на контроле.
Теперь, когда у нас есть базовые знания по ControlValueAccessor
, применим его к нашему CounterComponent`у.
Для того чтобы форма знала об изменениях контрола, нам нужно вызывать метод onChange
на каждое изменение значения value
в интерфейсе. Чтобы не писать вызов onChange
в каждом методе (up и down), в сеттере value мы вызываем метод onChange
, который уведомит форму об обновлении значения в контроле.
Чтобы Angular понимал, что это кастомный компонент мы описали это в декораторе @Component()
:
…providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => CounterControlComponent), multi: true}]…
🎯Теперь наш кастомный контрол готов к использованию в реактивной форме и в template-driven форме.
Вот и вся магия 🔮, теперь мы можем использовать наш кастомный контрол в связке c [(ngModel)]
в template-driven форме:
<counter-control [(ngModel)]="controlValue"></counter-control>
И в связке с реактивной формой (reactive-driven):
<form [formGroup]="form"><counter-control formControlName="counter"></counter-control></form>
4. Создаем multiselect-search компонент
Для создания нашего поля мы будем использовать Angular Material Chips component и Autocomplete component.
Вот как будет выглядеть код нашего компонента:
Если вы уже работали с Angular Material, то тут для вас мало чего нового, но давайте разберемся более дельно, что происходит в коде выше.
Во-первых, мы связали два отдельных компонента (Chips и Autocomplete) в один, используя композицию компонентов. Это позволило нам, вписывать произвольные элементы в список Chips, а также выбирать из предложенных из компонента Autocomplete.
Теперь поговорим про каждый отдельно:
<mat-chip-list>
В нем есть три важных момента, это событие добавления chip в список, событие удаления и событие ввода значение в поле Input.
1️⃣ На событие добавления chip у нас есть метод — onAddItem()
, который добавляет элемент в <mat-chip-list>
если оно еще не было добавлено, избавляя нас от дублирования.
2️⃣ На событие удаления chip у нас есть метод — onRemoveItem()
, который удалит элемент из списка выбранных элементов.
3️⃣ Так же метод emitSearchOnTyping()
слушает событие ввода значения в поле Input, и emit`ет событие search, если пользователь остановился печатать на 300мс debounceTime(300)
и введённое значение отличается от прошлого distinctUntilChanged()
.
<mat-autocomplete>
В этом компоненте есть два ключевых момента на которые следует обратить внимание:
1️⃣ Это обработка события выбора конкретного <mat-option> (optionSelected)=”onSelectOptionFromSearchResult($event)”
. onSelectOptionFromSearchResult()
метод почти схож с методом onAddItem()
и выполняет ту же самую функцию — добавляет элемент в список выбранных.
2️⃣ В наш multiselect-search компонент мы передаем шаблон от рисовки option у <mat-autocomplete>
:
multiselect-search.component.ts
@Input()
public optionTemplate: TemplateRef<any>;
multiselect-search.component.html
<mat-option
*ngFor="let searchedItem of searchResults
[value]="searchedItem.value"
>
<ng-container
[ngTemplateOutlet]="optionTemplate"
[ngTemplateOutletContext]="{ $implicit: searchedItem.source }"
></ng-container>
</mat-option>
✅ Вот в принципе и все!
5. Улучшаем UX нашего multiselect-search компонента
Добавим в наш компонент отображения, когда поиск ничего не нашел и индикатор загрузки при выполнении запроса поиска на сервер.
Добавьте <mat-option>
для этих случаев использования:
Для индикатора загрузки:
<mat-option *ngIf="loading; else searchResultsOptions" disabled>
<div style="height: 50px;">
<nb-spinner message="Loading..." size="large"></nb-spinner>
</div>
</mat-option>
Для пустого результата поиска:
<mat-option *ngIf="!loading && searchResults.length === 0 && inputElement?.nativeElement.value.length !== 0" disabled>
<div class="user-message">
<app-user-message message="Nothing found"></app-user-message>
<p class="caption user-message__subtext">
Try to enter a different value for the search, or press Enter if you can confirm the current value.
</p>
</div>
</mat-option>
Для результатов поиска:
<ng-template #searchResultsOptions>
<mat-option
*ngFor="let searchedItem of searchResults"
[value]="searchedItem.value"
>
<ng-container
[ngTemplateOutlet]="optionTemplate"
[ngTemplateOutletContext]="{ $implicit: searchedItem.source }"
></ng-container>
</mat-option>
</ng-template>
Полный код MultiselectSearchComponent с индикатором загрузки и для пустого результата поиска:
Теперь наш компонент может информировать пользователя, когда поиск на сервере ничего не выдал и когда идет выполнение запроса-поиска.
6. Пример использования multiselect-search компонента
Спасибо за чтение данной статьи, оставляйте свои комментарии и пожелания по улучшению. Если статья была вам полезна, поставьте clap 👏.