Введение
Создание кастомного компонента, который работает с ngModel и FormControl, традиционно требует написания большого количества boilerplate-кода: реализация ControlValueAccessor, управление состояниями, синхронизация с формой. В Taiga UI эту проблему решает базовый класс TuiControl.
В самой библиотеке часто используется TuiControl, это обертка позволяющая удобно работать с кастомными контролами, однако разработчики в своих проектах продолжают использовать ControlValueAccessor, хотя можно воспользоваться готовым решением из библиотеки.
TuiControl — это абстрактный класс, который:
-
Избавляет от boilerplate — автоматически реализует
ControlValueAccessor -
Использует signals — современный реактивный подход Angular 19+
-
Синхронизируется с формами — автоматически отслеживает состояния (disabled, invalid, touched)
-
Поддерживает трансформацию значений — конвертация между форматами компонента и формы
-
Предоставляет fallback-значения — дефолтные значения из коробки
Расположение: @taiga-ui/cdk/classes/control
Содержание
Основные концепции
Зачем нужен TuiControl?
Типичная реализация ControlValueAccessor выглядит так:
// 50+ строк boilerplate кода
export class MyInput implements ControlValueAccessor {
private onChange = (_: any) => {};
private onTouched = () => {};
writeValue(value: any) { /* ... */ }
registerOnChange(fn: any) { this.onChange = fn; }
registerOnTouched(fn: any) { this.onTouched = fn; }
setDisabledState(isDisabled: boolean) { /* ... */ }
}
С TuiControl весь этот код уже реализован. Вы просто наследуетесь и получаете:
Ключевые возможности
1. Signals-based API
Все состояния доступны как сигналы — реактивность из коробки:
this.value() // текущее значение
this.disabled() // disabled?
this.invalid() // есть ошибки?
this.touched() // пользователь взаимодействовал?
this.interactive() // доступен для редактирования?
2. Автоматическая интеграция с формами
Работает со всеми видами Angular Forms:
<my-component [(ngModel)]="data" />
<my-component [formControl]="control" />
<my-component formControlName="field" />
3. Автоматическая синхронизация состояний
TuiControl отслеживает:
-
Изменения значения (
control.value) -
Статус валидации (
VALID,INVALID,PENDING,DISABLED) -
Обновляет Change Detection только когда нужно
Быстрый старт
Давайте создадим простой компонент-рейтинг со звёздочками:
import {Component, computed, input} from '@angular/core';
import {TuiControl} from '@taiga-ui/cdk/classes';
import {tuiAsControl, tuiFallbackValueProvider} from '@taiga-ui/cdk';
@Component({
selector: 'my-rating',
template: `
@for (star of stars(); track $index) {
<button
type="button"
[class.active]="isActive($index)"
[disabled]="disabled()"
(click)="onClick($index + 1)"
>★</button>
}
`,
providers: [
tuiAsControl(MyRating),
tuiFallbackValueProvider(0), // Значение по умолчанию
],
})
export class MyRating extends TuiControl<number> {
public readonly max = input(5);
protected readonly stars = computed(() =>
Array.from({length: this.max()})
);
protected onClick(value: number): void {
if (this.interactive()) {
this.onChange(value); // Уведомляем форму
}
}
protected isActive(index: number): boolean {
return this.value() > index;
}
}
Использование:
// Template-driven Forms
<my-rating [(ngModel)]="rating" />
// Reactive Forms
<my-rating [formControl]="ratingControl" />
// С параметрами
<my-rating
[formControl]="control"
[readOnly]="true"
[max]="10"
/>
Что произошло?
-
Наследовались от
TuiControl<number> -
Добавили
tuiAsControl()в providers -
Установили fallback-значение через
tuiFallbackValueProvider(0) -
Вызываем
this.onChange()для обновления формы -
Проверяем
this.interactive()перед изменениями -
Используем
this.value(),this.disabled()как signals
Всё! Компонент готов к работе с формами.
API и возможности
Основные сигналы
|
Сигнал |
Тип |
Описание |
|---|---|---|
|
|
|
Текущее значение (или fallback) |
|
|
|
Компонент disabled? |
|
|
|
Есть ошибки валидации? |
|
|
|
Пользователь взаимодействовал? |
|
|
|
Доступен для редактирования? |
|
|
|
Режим только чтения (input) |
Основные методы
// Уведомить форму об изменении
this.onChange(newValue);
// Пометить как "тронутый"
this.onTouched();
// Переопределить writeValue для дополнительной логики
public override writeValue(value: T | null): void {
super.writeValue(value);
// Ваша логика
}
Доступ к NgControl
export class MyComponent extends TuiControl<string> {
protected checkErrors(): void {
console.log(this.control.errors);
console.log(this.control.status); // 'VALID' | 'INVALID' | ...
}
}
Примеры из реальной библиотеки
Пример 1: Директива на нативном input
@Component({
selector: 'input[tuiInputColor]',
providers: [
tuiAsControl(TuiInputColorComponent),
tuiFallbackValueProvider(''),
],
host: {
'[disabled]': 'disabled()',
'[value]': 'value()',
'(input)': 'onChange($event.target.value)',
},
})
export class TuiInputColorComponent extends TuiControl<string> {
public readonly format = input<'hex' | 'hexa'>('hex');
protected readonly maskito = tuiMaskito(
computed(() => ({
mask: ['#', ...Array.from({length: this.format().length * 2})
.fill(/[0-9a-fA-F]/)],
}))
);
}
Ключевые моменты:
-
Директива применяется к нативному
<input> -
Прямая привязка через
hostbindings -
Интеграция с Maskito для форматирования ввода
Пример 2: Компонент с составным значением
export class TuiInputRange extends TuiControl<readonly [number, number]> {
public readonly min = input(0);
public readonly max = input(100);
public override writeValue(value: [number, number]): void {
super.writeValue(value);
// Синхронизация с внутренними textfield'ами
this.updateInternalFields(this.value());
}
protected onRangeChange(start: number, end: number): void {
// Валидация: start не может быть больше end
this.onChange([
Math.min(start, end),
Math.max(start, end)
]);
}
}
Ключевые моменты:
-
Работа с tuple-типом
[number, number] -
Переопределение
writeValueдля синхронизации -
Внутренняя валидация значений
Пример 3: Координация с дочерними компонентами
@Directive({
selector: '[tuiTable][ngModel]',
providers: [tuiFallbackValueProvider([])],
})
export class TuiTableControlDirective<T> extends TuiControl<readonly T[]> {
private readonly children = signal<ReadonlyArray<CheckboxRow<T>>>([]);
// Computed для UI-состояний
protected readonly checked = computed(() =>
this.children().every(c => this.value().includes(c.data))
);
public toggleAll(): void {
this.onChange(
this.checked()
? []
: this.children().map(c => c.data)
);
}
}
Ключевые моменты:
-
Управление массивом выбранных элементов
-
Computed-сигналы для производных состояний
-
Координация между родителем и дочерними элементами
Продвинутые сценарии
1. Трансформация значений (TuiValueTransformer)
Когда формат компонента отличается от формата формы:
export class UppercaseTransformer extends TuiValueTransformer<string, string> {
// Из компонента в форму
public toControlValue(componentValue: string): string {
return componentValue.toUpperCase();
}
// Из формы в компонент
public fromControlValue(controlValue: string): string {
return controlValue.toLowerCase();
}
}
@Component({
providers: [
tuiAsControl(MyInput),
{provide: TuiValueTransformer, useClass: UppercaseTransformer},
],
})
export class MyInput extends TuiControl<string> {
// Работаем с lowercase, в форме сохраняется uppercase
}
2. Составные компоненты
Когда один компонент управляет несколькими полями:
export class DateRangePicker extends TuiControl<{start: Date; end: Date}> {
protected onStartChange(start: Date): void {
this.onChange({...this.value(), start});
}
protected onEndChange(end: Date): void {
this.onChange({...this.value(), end});
}
}
3. Интеграция с dropdown
export class MySelect extends TuiControl<string | null> {
private readonly open = inject(TuiDropdownOpen).open;
protected onSelect(value: string): void {
this.onChange(value);
this.open.set(false);
this.onTouched();
}
}
4. Работа с объектами и составными значениями
TuiControl поддерживает любые типы, включая сложные объекты:
interface UserProfile {
name: string;
email: string;
age: number;
}
@Component({
selector: 'user-profile-editor',
providers: [
tuiAsControl(UserProfileEditor),
tuiFallbackValueProvider({name: '', email: '', age: 0}),
],
})
export class UserProfileEditor extends TuiControl<UserProfile> {
protected readonly form = new FormGroup({
name: new FormControl('', {nonNullable: true}),
email: new FormControl('', {nonNullable: true}),
age: new FormControl(0, {nonNullable: true}),
});
constructor() {
super();
// Синхронизация внутренней формы с внешней
this.form.valueChanges
.pipe(takeUntilDestroyed())
.subscribe(() => this.onChange(this.form.getRawValue()));
}
public override writeValue(value: UserProfile | null): void {
super.writeValue(value);
this.form.setValue(this.value(), {emitEvent: false});
}
}
Кастомная валидация для объектов:
export function userProfileValidator(
control: AbstractControl<UserProfile>
): ValidationErrors | null {
const value = control.value;
const errors: ValidationErrors = {};
if (value.name.length < 2) {
errors['name'] = new TuiValidationError('Минимум 2 символа');
}
if (!/^[^s@]+@[^s@]+.[^s@]+$/.test(value.email)) {
errors['email'] = new TuiValidationError('Некорректный email');
}
return Object.keys(errors).length ? errors : null;
}
// Использование
protected readonly control = new FormControl<UserProfile>(
defaultValue,
[userProfileValidator]
);
💡 Ключевые правила при работе с объектами:
-
Используйте иммутабельные обновления:
{...this.value(), field: newValue} -
Устанавливайте fallback через
tuiFallbackValueProvider() -
Синхронизируйте внутренние контролы в
writeValue() -
Используйте
TuiValidationErrorдля структурированных ошибок
Best Practices
Обязательные правила
-
Используйте
tuiAsControl()в providersproviders: [tuiAsControl(MyComponent)] -
Устанавливайте fallback-значение
providers: [ tuiAsControl(MyComponent), tuiFallbackValueProvider(defaultValue), ] -
Проверяйте
interactive()перед изменениямиprotected onClick(value: T): void { if (this.interactive()) { this.onChange(value); } } -
Вызывайте
onTouched()при blurhost: { '(blur)': 'onTouched()', } -
Используйте OnPush и host bindings
@Component({ changeDetection: ChangeDetectionStrategy.OnPush, host: { '[class._disabled]': 'disabled()', '[class._invalid]': 'invalid()', }, }) -
Используйте computed для производных значений
// ✅ Правильно protected readonly isEmpty = computed(() => !this.value()); // ❌ Неправильно protected isEmpty = false; -
Не изменяйте
this.controlнапрямую// ✅ Правильно: используйте onChange this.onChange(newValue); // ❌ Неправильно: напрямую через control this.control.control?.setValue(newValue);
Заключение
TuiControl радикально упрощает создание форм-компонентов в Angular:
-
Избавляет от 50+ строк boilerplate —
ControlValueAccessorуже реализован -
Signals из коробки — современный реактивный API Angular 19+
-
Автоматическая синхронизация — состояния формы всегда актуальны
-
Готов к production — используется во всех компонентах Taiga UI
Часто сам использовал в своих проектах, и данный класс очень удобный для написания кастомных контролов если в проекте уже есть Taiga UI.
Автор: pol96956
