Введение

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

Что произошло?

  1. Наследовались от TuiControl<number>

  2. Добавили tuiAsControl() в providers

  3. Установили fallback-значение через tuiFallbackValueProvider(0)

  4. Вызываем this.onChange() для обновления формы

  5. Проверяем this.interactive() перед изменениями

  6. Используем this.value(), this.disabled() как signals

Всё! Компонент готов к работе с формами.


API и возможности

Основные сигналы

Сигнал

Тип

Описание

value()

Signal<T>

Текущее значение (или fallback)

disabled()

Signal<boolean>

Компонент disabled?

invalid()

Signal<boolean>

Есть ошибки валидации?

touched()

Signal<boolean>

Пользователь взаимодействовал?

interactive()

Signal<boolean>

Доступен для редактирования?

readOnly()

InputSignal<boolean>

Режим только чтения (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>

  • Прямая привязка через host bindings

  • Интеграция с 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

Обязательные правила

  1. Используйте tuiAsControl() в providers

    providers: [tuiAsControl(MyComponent)]
    
  2. Устанавливайте fallback-значение

    providers: [
        tuiAsControl(MyComponent),
        tuiFallbackValueProvider(defaultValue),
    ]
    
  3. Проверяйте interactive() перед изменениями

    protected onClick(value: T): void {
        if (this.interactive()) {
            this.onChange(value);
        }
    }
    
  4. Вызывайте onTouched() при blur

    host: {
        '(blur)': 'onTouched()',
    }
    
  5. Используйте OnPush и host bindings

    @Component({
        changeDetection: ChangeDetectionStrategy.OnPush,
        host: {
            '[class._disabled]': 'disabled()',
            '[class._invalid]': 'invalid()',
        },
    })
    
  6. Используйте computed для производных значений

    // ✅ Правильно
    protected readonly isEmpty = computed(() => !this.value());
    
    // ❌ Неправильно
    protected isEmpty = false;
    
  7. Не изменяйте this.control напрямую

    // ✅ Правильно: используйте onChange
    this.onChange(newValue);
    
    // ❌ Неправильно: напрямую через control
    this.control.control?.setValue(newValue);
    

Заключение

TuiControl радикально упрощает создание форм-компонентов в Angular:

  • Избавляет от 50+ строк boilerplateControlValueAccessor уже реализован

  • Signals из коробки — современный реактивный API Angular 19+

  • Автоматическая синхронизация — состояния формы всегда актуальны

  • Готов к production — используется во всех компонентах Taiga UI

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