Создание кастомного элемента формы в Angular

Пока TrackLab версии 0.4.0 всё ещё разрабатывается, я хочу поделиться тем, чем я занимался последние несколько дней. Наиболее времязатратной частью было создание кастомных элементов формы.

Я использую Angular (v20) + Ionic (v8.7) в своём проекте, но пример ниже должен работать и на чистом Angular если вы замените элементы, специфичные для Ionic на дефолтные HTML элементы (например, <ion-input> на <input>).

Когда я создавал новые кастомные элементы, было несколько нюансов.

Первый. Один из компонентов состоит из нескольких полей. Это элемент для ввода периодов с датой начала и датой окончания.

Второй. Я уже задал валидации для этих полей с использованием своей директивы validationError, и я хотел сохранить эту логику внутри нового элемента.

Вот почему я решил использовать форму внутри компонента. Я знаю, что я мог использовать вложенную форму и задать FormGroup в родительской форме, но мне не нравится такой подход, потому что для него требуются дополнительные действия при каждом использовании компонента.

Начало

Я начал с простой формы, которую я хотел вынести в отдельный компонент. Для краткости я опустил некоторые части кода: переводы, маски и прочий не имеющий отношения к теме статьи код.

HTML
<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>

В компоненте:

TypeScript
constructor(private formBuilder: FormBuilder) {
  this.filterForm = this.formBuilder.group(
    {
      startDate: ['', [Validators.required]],
      endDate: ['', [Validators.required]],
    },
  );
}

Моей целью было перенести всё в переиспользуемый компонент с минимальными изменениями.

Создание нового компонента

Следующий шаг — создать новый компонент

Bash
ng g component components/date-period-input

Чтобы он вёл себя как форма ввода, он должен реализовывать интерфейс ControlValueAccessor и предоставить токен NG_VALUE_ACCESSOR.

TypeScript
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, вы должны добавить следующие методы:

  • writeValue
  • registerOnChange
  • registerOnTouched

Основной из них — это writeValue(). Он выполняется каждый раз, когда кто-то (или что-то) пытается изменить значение элемента формы.

Тестирование компонента

Чтобы протестировать кастомный элемент формы, создайте родительскую форму и добавьте элемент в неё.

HTML
<form [formGroup]="filterForm">
    <app-date-period-input 
      formControlName="datePeriod"
    ></app-date-period-input>
</form>

<ion-button (click)="test()">Test</ion-button>
TypeScript
@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);
  }
}

На данный момент вы всегда будете видеть один и тот же результат:

TypeScript
{
  datePeriod: null
}

Как сделать, чтобы всё заработало?

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

Для ручного ввода обновляйте занчение при каждом срабатывании события ionInput (или onInput).

HTML
<ion-input
  validationError
  formControlName="startDate"
  (ionInput)="updateValue()"
></ion-input>

<!-- ... -->

<ion-input
  validationError
  formControlName="endDate"
  (ionInput)="updateValue()"
></ion-input>
TypeScript
updateValue() {
  this.onChange(this.form.value);
  this.onTouched();
}

После этих изменений вы начнёте получать правильные данные в родительской форме.

Для нажатий на кнопки используйте form.patchValue(), а затем вызывайте updateValue().

TypeScript
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. Это позволяет передать ошибки из внутренней формы в родительскую.

TypeScript
@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

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *