- PVSM.RU - https://www.pvsm.ru -
Создание кастомного компонента, который работает с ngModel и FormControl, традиционно требует написания большого количества boilerplate-кода: реализация ControlValueAccessor, управление состояниями, синхронизация с формой. В Taiga UI эту проблему решает базовый класс TuiControl.
В самой библиотеке часто используется TuiControl, это обертка позволяющая удобно работать с кастомными контролами, однако разработчики в своих проектах продолжают использовать ControlValueAccessor, хотя можно воспользоваться готовым решением из библиотеки.
TuiControl — это абстрактный класс, который:
Избавляет от boilerplate — автоматически реализует ControlValueAccessor
Использует signals — современный реактивный подход Angular 19+
Синхронизируется с формами — автоматически отслеживает состояния (disabled, invalid, touched)
Поддерживает трансформацию значений — конвертация между форматами компонента и формы
Предоставляет fallback-значения — дефолтные значения из коробки
Расположение: @taiga-ui/cdk/classes/control
Быстрый старт [2]
Best Practices [6]
Типичная реализация 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 весь этот код уже реализован. Вы просто наследуетесь и получаете:
Все состояния доступны как сигналы — реактивность из коробки:
this.value() // текущее значение
this.disabled() // disabled?
this.invalid() // есть ошибки?
this.touched() // пользователь взаимодействовал?
this.interactive() // доступен для редактирования?
Работает со всеми видами Angular Forms:
<my-component [(ngModel)]="data" />
<my-component [formControl]="control" />
<my-component formControlName="field" />
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
Всё! Компонент готов к работе с формами.
|
Сигнал |
Тип |
Описание |
|---|---|---|
|
|
|
Текущее значение (или fallback) |
|
|
|
Компонент disabled? |
|
|
|
Есть ошибки валидации? |
|
|
|
Пользователь взаимодействовал? |
|
|
|
Доступен для редактирования? |
|
|
|
Режим только чтения (input) |
// Уведомить форму об изменении
this.onChange(newValue);
// Пометить как "тронутый"
this.onTouched();
// Переопределить writeValue для дополнительной логики
public override writeValue(value: T | null): void {
super.writeValue(value);
// Ваша логика
}
export class MyComponent extends TuiControl<string> {
protected checkErrors(): void {
console.log(this.control.errors);
console.log(this.control.status); // 'VALID' | 'INVALID' | ...
}
}
@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 для форматирования ввода
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 для синхронизации
Внутренняя валидация значений
@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-сигналы для производных состояний
Координация между родителем и дочерними элементами
Когда формат компонента отличается от формата формы:
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
}
Когда один компонент управляет несколькими полями:
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});
}
}
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();
}
}
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 для структурированных ошибок
Используйте tuiAsControl() в providers
providers: [tuiAsControl(MyComponent)]
Устанавливайте fallback-значение
providers: [
tuiAsControl(MyComponent),
tuiFallbackValueProvider(defaultValue),
]
Проверяйте interactive() перед изменениями
protected onClick(value: T): void {
if (this.interactive()) {
this.onChange(value);
}
}
Вызывайте onTouched() при blur
host: {
'(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
Источник [7]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/html/443647
Ссылки в тексте:
[1] Основные концепции: #%D0%BE%D1%81%D0%BD%D0%BE%D0%B2%D0%BD%D1%8B%D0%B5-%D0%BA%D0%BE%D0%BD%D1%86%D0%B5%D0%BF%D1%86%D0%B8%D0%B8
[2] Быстрый старт: #%D0%B1%D1%8B%D1%81%D1%82%D1%80%D1%8B%D0%B9-%D1%81%D1%82%D0%B0%D1%80%D1%82
[3] API и возможности: #api-%D0%B8-%D0%B2%D0%BE%D0%B7%D0%BC%D0%BE%D0%B6%D0%BD%D0%BE%D1%81%D1%82%D0%B8
[4] Примеры из реальной библиотеки: #%D0%BF%D1%80%D0%B8%D0%BC%D0%B5%D1%80%D1%8B-%D0%B8%D0%B7-%D1%80%D0%B5%D0%B0%D0%BB%D1%8C%D0%BD%D0%BE%D0%B9-%D0%B1%D0%B8%D0%B1%D0%BB%D0%B8%D0%BE%D1%82%D0%B5%D0%BA%D0%B8
[5] Продвинутые сценарии: #%D0%BF%D1%80%D0%BE%D0%B4%D0%B2%D0%B8%D0%BD%D1%83%D1%82%D1%8B%D0%B5-%D1%81%D1%86%D0%B5%D0%BD%D0%B0%D1%80%D0%B8%D0%B8
[6] Best Practices: #best-practices
[7] Источник: https://habr.com/ru/articles/991650/?utm_source=habrahabr&utm_medium=rss&utm_campaign=991650
Нажмите здесь для печати.