
Пока TrackLab версии 0.4.0 всё ещё разрабатывается, я хочу поделиться тем, чем я занимался последние несколько дней. Наиболее времязатратной частью было создание кастомных элементов формы.
Я использую Angular (v20) + Ionic (v8.7) в своём проекте, но пример ниже должен работать и на чистом Angular если вы замените элементы, специфичные для Ionic на дефолтные HTML элементы (например, <ion-input> на <input>).
Когда я создавал новые кастомные элементы, было несколько нюансов.
Первый. Один из компонентов состоит из нескольких полей. Это элемент для ввода периодов с датой начала и датой окончания.
Второй. Я уже задал валидации для этих полей с использованием своей директивы validationError, и я хотел сохранить эту логику внутри нового элемента.
Вот почему я решил использовать форму внутри компонента. Я знаю, что я мог использовать вложенную форму и задать FormGroup в родительской форме, но мне не нравится такой подход, потому что для него требуются дополнительные действия при каждом использовании компонента.
Начало
Я начал с простой формы, которую я хотел вынести в отдельный компонент. Для краткости я опустил некоторые части кода: переводы, маски и прочий не имеющий отношения к теме статьи код.
<form [formGroup]="filterForm">
<ion-item>
<ion-label position="stacked">Date from</ion-label>
<ion-input
validationError
formControlName="startDate"
></ion-input>
</ion-item>
<ion-item>
<ion-label position="stacked">Date to</ion-label>
<ion-input
validationError
formControlName="endDate"
></ion-input>
</ion-item>
</form>
<div class="controls">
<ion-button (click)="shiftDates(-1)">Prev</ion-button>
<ion-chip (click)="selectPeriod('week')">Week</ion-chip>
<ion-chip (click)="selectPeriod('month')">Month</ion-chip>
<ion-button (click)="shiftDates(1)">Next</ion-button>
</div>В компоненте:
constructor(private formBuilder: FormBuilder) {
this.filterForm = this.formBuilder.group(
{
startDate: ['', [Validators.required]],
endDate: ['', [Validators.required]],
},
);
}Моей целью было перенести всё в переиспользуемый компонент с минимальными изменениями.
Создание нового компонента
Следующий шаг — создать новый компонент
ng g component components/date-period-inputЧтобы он вёл себя как форма ввода, он должен реализовывать интерфейс ControlValueAccessor и предоставить токен NG_VALUE_ACCESSOR.
import { Component, forwardRef } from '@angular/core';
import { ControlValueAccessor, FormBuilder, FormGroup, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule, Validators } from '@angular/forms';
import { IonInput, IonLabel, IonItem, IonButton, IonChip } from '@ionic/angular/standalone';
export type DatePeriod = {
startDate: string;
endDate: string;
};
@Component({
selector: 'app-date-period-input',
templateUrl: './date-period-input.component.html',
imports: [IonChip, IonButton, IonItem, IonLabel, IonInput, FormsModule, ReactiveFormsModule],
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DatePeriodInputComponent),
multi: true,
}],
})
export class DatePeriodInputComponent implements ControlValueAccessor {
public form: FormGroup;
private onChange = (_: any) => { };
private onTouched = () => { };
constructor(private formBuilder: FormBuilder) {
this.form = this.formBuilder.group({
startDate: ['', [Validators.required]],
endDate: ['', [Validators.required]],
});
}
writeValue(value: DatePeriod): void {
if (value) {
this.form.patchValue(value);
} else {
this.form.patchValue({ startDate: '', endDate: '' });
}
}
registerOnChange(fn: any): void {
this.onChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
}
Чтобы реализовать интерфейс ControlValueAccessor, вы должны добавить следующие методы:
writeValueregisterOnChangeregisterOnTouched
Основной из них — это writeValue(). Он выполняется каждый раз, когда кто-то (или что-то) пытается изменить значение элемента формы.
Тестирование компонента
Чтобы протестировать кастомный элемент формы, создайте родительскую форму и добавьте элемент в неё.
<form [formGroup]="filterForm">
<app-date-period-input
formControlName="datePeriod"
></app-date-period-input>
</form>
<ion-button (click)="test()">Test</ion-button>@Component({
selector: 'app-some-page',
templateUrl: './some-page.page.html',
imports: [DatePeriodInputComponent], // <-- don't forget to import
})
export class LibraryItemStatsPage implements OnInit {
public filterForm: FormGroup;
constructor(
private formBuilder: FormBuilder,
) {
this.filterForm = this.formBuilder.group({
datePeriod: [],
});
}
test() {
console.log(this.filterForm.value);
}
}На данный момент вы всегда будете видеть один и тот же результат:
{
datePeriod: null
}Как сделать, чтобы всё заработало?
Чтобы элемент заработал, вам нужно обновлять его значение при каждом действии пользователя. Это может быть ввод с клавиатуры или нажатие на кнопки.
Для ручного ввода обновляйте занчение при каждом срабатывании события ionInput (или onInput).
<ion-input
validationError
formControlName="startDate"
(ionInput)="updateValue()"
></ion-input>
<!-- ... -->
<ion-input
validationError
formControlName="endDate"
(ionInput)="updateValue()"
></ion-input>updateValue() {
this.onChange(this.form.value);
this.onTouched();
}После этих изменений вы начнёте получать правильные данные в родительской форме.

Для нажатий на кнопки используйте form.patchValue(), а затем вызывайте updateValue().
patchAndUpdate(value: DatePeriod) {
this.form.patchValue(value);
this.updateValue();
}
selectPeriod(periodName: string) {
// some logic
this.patchAndUpdate(result);
}
shiftDates(shiftDirection: number) {
// some logic
this.patchAndUpdate(result);
}Валидации
Как я упоминал ранее, одна из причин для сохранения формы внутри компонента — это необходимость обрабатывать валидации.
Чтобы добиться этого, я реализовал интерфейс Validator. Это позволяет передать ошибки из внутренней формы в родительскую.
@Component({
// ...
providers: [
// ...
{
provide: NG_VALIDATORS,
multi: true,
useExisting: DatePeriodInputComponent
},
],
})
export class DatePeriodInputComponent implements ControlValueAccessor, Validator {
// ...
validate(control: AbstractControl): ValidationErrors | null {
if (!this.form.valid) {
return Object.values(this.form.controls)
.reduce((result, curr) => ({
...result,
...curr.errors,
}), {});
}
return null;
}
}Заключение
Итоговый результат выглядит так:

Теперь кастомный элемент формы может быть использован везде как обычное поле ввода — нет необходимости каждый раз вручную задавать FormGroup или валидации.
Источники
https://blog.angular-university.io/angular-custom-form-controls
