Введение
Создание кастомного компонента, который работает с 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.
