В основе любой зрелой дизайн-системы лежит набор универсальных и предсказуемых компонентов. Когда речь заходит о формах, ключевым элементом, отделяющим профессиональную библиотеку компонентов от набора «костылей», является реализация ControlValueAccessor.
Этот интерфейс — не просто «ещё одно API». Это фундаментальный контракт, который позволяет нашим UI-компонентам бесшовно интегрироваться в мощную экосистему Angular Forms, включая валидацию, управление состоянием и потоки данных.
В этой статье мы рассмотрим эталонную реализацию кастомного инпута. Мы не просто реализуем CVA, но и грамотно интегрируем NgControl для доступа к состоянию контрола, избегая при этом классических ловушек вроде циклических зависимостей. Цель — получить компонент, готовый к использованию в самых сложных и масштабируемых enterprise-приложениях.
Зачем ControlValueAccessor (CVA)
ControlValueAccessor — это контракт между реактивной формой и вашим UI-контролом. Реализовав его, вы превращаете любой кастомный компонент в нативный контрол для реактивных форм: двусторонняя синхронизация значения, touched/dirty/disabled, валидаторы, единый DX.
NG_VALUE_ACCESSOR: как «подключить» ваш компонент к формам
Angular ищет подходящий аксессор через DI-токен NG_VALUE_ACCESSOR. Для кастомного компонента регистрируем себя:
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => CustomInputComponent), multi: true, }]
-
multi: true— у токена может быть несколько провайдеров. -
useExisting— использовать уже созданный инстанс компонента в роли аксессора.
Внимание: В большинстве случаев forwardRef не требуется. Однако в данном паттерне, сочетающем CVA и NgControl, он является обязательным для разрешения циклической зависимости, которая может возникнуть на этапе компиляции.
Интерфейс CVA — кратко и по делу
interface ControlValueAccessor { writeValue(obj: any): void; // модель -> вид registerOnChange(fn: any): void; // вид -> модель (изменение) registerOnTouched(fn: any): void; // вид -> модель (blur) setDisabledState?(isDisabled: boolean): void; // disabled }
Почему стоит получить NgControl внутри компонента
Состояния контрола (invalid, touched, dirty, errors, statusChanges) живут в NgControl. Если аккуратно получить NgControl внутри компонента, можно:
-
отрисовывать ошибки и состояния прямо в шаблоне
-
навешивать классы/ARIA-атрибуты
-
не пробрасывать статусы извне.
Важно: прямой инжект NgControlвызовет циклическую зависимость (NG200) и если компонент используют вне форм, прямой инжект сломается (не найдёт провайдера). Безопасный способ — через Injector.get и self: true: попросим NgControlтолько у себя, и если его нет — вернётся null.
Современная реализация (Signals, OnPush, NgControl/Injector)
@Component({ selector: 'app-custom-input', standalone: true, imports: [CommonModule], templateUrl: './custom-input.component.html', styleUrls: ['./custom-input.component.css'], changeDetection: ChangeDetectionStrategy.OnPush, providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => CustomInputComponent), multi: true, }], }) export class CustomInputComponent implements ControlValueAccessor, OnInit { // Публичный API public id = input<string>('custom-input'); public label = input.required<string>(); public type = input<string>('text'); public placeholder = input<string>(''); public value = signal<string>(''); public disabled = signal<boolean>(false); // Коллбеки от Angular Forms public onChange: (value: string) => void = () => {}; public onTouched: () => void = () => {}; // Доступ к состоянию FormControl (если компонент используется в форме) public ngControl: NgControl | null = null; private injector = inject(Injector); private inputEl = viewChild.required<ElementRef<HTMLInputElement>>('inputEl'); constructor() { // Создаём эффект, который будет синхронизировать сигнал с DOM effect(() => if (this.inputEl()) { this.inputEl().nativeElement.value = this.value(); }); } public ngOnInit(): void { // Берём NgControl только из собственного инжектора; // если компонента нет в форме - получим null this.ngControl = this.injector.get(NgControl, null, { self: true }); } public onInput(event: Event): void { const newValue = (event.target as HTMLInputElement).value; this.value.set(value); this.onChange(newValue); } // ---- ControlValueAccessor ---- public writeValue(value: string): void { this.value.set(value); } public registerOnChange(fn: (value: string) => void): void { this.onChange = fn; } public registerOnTouched(fn: () => void): void { this.onTouched = fn; } public setDisabledState(isDisabled: boolean): void { this.disabled.set(isDisabled); } }
шаблон (с ARIA и ошибками)
<div class="input-container"> <label [attr.for]="id()">{{ label() }}</label> <input #inputEl [id]="id()" [type]="type()" [placeholder]="placeholder()" [disabled]="disabled()" (input)="onInput($event)" (blur)="onTouched()" [attr.aria-invalid]="ngControl?.invalid && ngControl?.touched" /> </div>
Используем в реактивной форме
@Component({ selector: 'app-auth-form', standalone: true, imports: [ReactiveFormsModule, CustomInputComponent], templateUrl: './auth-form.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class AuthFormComponent implements OnInit { private fb = inject(FormBuilder); public submittedData = signal<any>(null); public customForm = this.fb.group({ username: this.fb.control('', { validators: [Validators.required, Validators.minLength(3)] }), email: this.fb.control('', { validators: [Validators.required, Validators.email] }), }); public onSubmit(): void { if (this.customForm.valid) { this.submittedData.set(this.customForm.value); console.log(this.customForm.value); } else { this.customForm.markAllAsTouched(); } } }
<form [formGroup]="customForm" (ngSubmit)="onSubmit()"> <app-custom-input formControlName="username" label="Username" placeholder="Введите имя пользователя" /> <app-custom-input formControlName="email" label="Email" type="email" placeholder="Введите адрес электронной почты" /> <button type="submit" [disabled]="customForm.invalid">Submit</button> </form> @if (submittedData()) { <div class="submitted"> <h3>Submitted Data:</h3> <pre>{{ submittedData() | json }}</pre> </div> }
Как это связано «под капотом»
-
formControlNameчерез DI находит value accessor’ы поNG_VALUE_ACCESSOR. Наш компонент — один из них. -
Angular вызывает
registerOnChange/registerOnTouchedи сохраняет переданные функции. -
На инициализации/патчах формы Angular вызывает
writeValue, а приdisable/enable—setDisabledState. -
Пользователь вводит текст → в
onInputмы дергаемonChange(newValue)→ обновляетсяFormControl→ валидаторы/статусы/valueChanges. -
Параллельно мы безопасно достали
NgControlчерезInjector.get(..., { self: true })и используем его дляaria-invalid, показа ошибок.
Почему такой подход идеален для переиспользования
-
Универсальность: один контрол для десятков форм, единое поведение и доступность (a11y).
-
Прозрачность состояний через
NgControl: правильные ARIA-атрибуты и UX-сигналы. -
Простота с Signals: минимум кода, предсказуемые обновления, OnPush из коробки.
-
Расширяемость: легко добавить маски/форматирование,
NG_VALIDATORS, асинхронные проверки, подсказки и т.п.
Заключение
ControlValueAccessor даёт чистый контракт «форма ↔ контрол», а NgControl + Injector — удобный доступ к статусам без хрупких костылей. В результате у вас получается переиспользуемый инпут: доступный, предсказуемый, тестопригодный и готовый к масштабированию.
ссылка на оригинал статьи https://habr.com/ru/articles/946890/
Добавить комментарий