53. Пользовательские Form Controls
56. Пользовательские Form Controls (ControlValueAccessor) ⭐
Заголовок раздела «56. Пользовательские Form Controls (ControlValueAccessor) ⭐»Привет! Яша здесь. Когда стандартных <input> и <select> недостаточно — ты создаёшь кастомный контрол. ControlValueAccessor — мост между Angular Forms и твоим компонентом. После этого урока ты сможешь создать любой контрол: рейтинг, цветопикер, rich text editor 🎨
Что такое ControlValueAccessor
Заголовок раздела «Что такое ControlValueAccessor»ControlValueAccessor — интерфейс Angular, который нужно реализовать чтобы компонент работал с:
[(ngModel)]formControlName[formControl]
interface ControlValueAccessor { // Вызывается Angular когда форма хочет обновить значение в компоненте writeValue(value: any): void;
// Регистрирует callback — вызывай его когда значение изменилось в UI registerOnChange(fn: (value: any) => void): void;
// Регистрирует callback — вызывай при blur (touch) registerOnTouched(fn: () => void): void;
// Опционально — вызывается когда контрол стал disabled/enabled setDisabledState?(isDisabled: boolean): void;}Минимальная реализация
Заголовок раздела «Минимальная реализация»import { Component, forwardRef, Input, ChangeDetectionStrategy} from '@angular/core';import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule} from '@angular/forms';
@Component({ selector: 'app-text-input', standalone: true, imports: [FormsModule], template: ` <input [value]="value" [disabled]="isDisabled" (input)="onInput($event)" (blur)="onTouched()"> `, providers: [ { // NG_VALUE_ACCESSOR — токен, по которому Angular находит CVA provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TextInputComponent), // forwardRef нужен из-за hoisting multi: true, // multi: true — важно! много CVA на один токен } ], changeDetection: ChangeDetectionStrategy.OnPush,})export class TextInputComponent implements ControlValueAccessor { value = ''; isDisabled = false;
// Колбэки, зарегистрированные Angular private onChange: (value: string) => void = () => {}; private onTouched: () => void = () => {};
// Angular → компонент writeValue(value: string): void { this.value = value ?? ''; }
// Регистрируем функцию "сообщить Angular об изменении" registerOnChange(fn: (value: string) => void): void { this.onChange = fn; }
// Регистрируем функцию "сообщить Angular о touch" registerOnTouched(fn: () => void): void { this.onTouched = fn; }
// Опционально — реакция на disabled setDisabledState(isDisabled: boolean): void { this.isDisabled = isDisabled; }
// Компонент → Angular (вызываем onChange при изменении) onInput(event: Event): void { const value = (event.target as HTMLInputElement).value; this.value = value; this.onChange(value); // ← обязательно! }}Кастомный рейтинг (star rating)
Заголовок раздела «Кастомный рейтинг (star rating)»import { Component, Input, forwardRef, ChangeDetectionStrategy, ChangeDetectorRef, signal} from '@angular/core';import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';import { NgFor } from '@angular/common';
@Component({ selector: 'app-star-rating', standalone: true, imports: [NgFor], template: ` <div class="stars" [attr.aria-label]="'Рейтинг: ' + value() + ' из ' + maxStars"> @for (star of starsArray; track star) { <button type="button" [class.filled]="star <= (hovered() || value())" [class.hovered]="star <= hovered()" [disabled]="isDisabled()" (mouseenter)="hovered.set(star)" (mouseleave)="hovered.set(0)" (click)="setValue(star)" [attr.aria-label]="star + ' звёзд'"> ★ </button> } </div> `, styles: [` .stars { display: flex; gap: 4px; } button { background: none; border: none; cursor: pointer; font-size: 28px; color: #475569; transition: color 0.15s; padding: 0; line-height: 1; } button.filled { color: #fbbf24; } button.hovered { color: #f59e0b; } button:disabled { cursor: default; opacity: 0.5; } `], providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StarRatingComponent), multi: true, }], changeDetection: ChangeDetectionStrategy.OnPush,})export class StarRatingComponent implements ControlValueAccessor { @Input() maxStars = 5;
value = signal(0); hovered = signal(0); isDisabled = signal(false);
get starsArray(): number[] { return Array.from({ length: this.maxStars }, (_, i) => i + 1); }
private onChange: (value: number) => void = () => {}; private onTouched: () => void = () => {};
setValue(star: number): void { if (this.isDisabled()) return; // Повторный клик — сброс const newValue = this.value() === star ? 0 : star; this.value.set(newValue); this.onChange(newValue); this.onTouched(); }
writeValue(value: number): void { this.value.set(value || 0); }
registerOnChange(fn: (value: number) => void): void { this.onChange = fn; }
registerOnTouched(fn: () => void): void { this.onTouched = fn; }
setDisabledState(isDisabled: boolean): void { this.isDisabled.set(isDisabled); }}
// Использование в форме@Component({ template: ` <form [formGroup]="form"> <app-star-rating formControlName="rating" [maxStars]="5"></app-star-rating> <div *ngIf="form.get('rating')?.invalid && form.get('rating')?.touched"> Пожалуйста, поставьте оценку </div> </form> `})export class ReviewFormComponent { form = this.fb.group({ rating: [0, [Validators.required, Validators.min(1)]], comment: ['', Validators.required], });
constructor(private fb: FormBuilder) {}}Кастомный цветопикер
Заголовок раздела «Кастомный цветопикер»@Component({ selector: 'app-color-picker', standalone: true, template: ` <div class="picker"> <div class="presets"> @for (color of presetColors; track color) { <button type="button" [style.background]="color" [class.selected]="value() === color" (click)="selectColor(color)" [attr.title]="color"> </button> } </div> <div class="custom"> <input type="color" [value]="value()" (input)="selectColor($any($event.target).value)" (blur)="onTouched()"> <span>{{ value() }}</span> </div> </div> `, providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ColorPickerComponent), multi: true, }], changeDetection: ChangeDetectionStrategy.OnPush,})export class ColorPickerComponent implements ControlValueAccessor { value = signal('#dd0031'); isDisabled = signal(false);
presetColors = [ '#dd0031', '#1976d2', '#388e3c', '#f57c00', '#7b1fa2', '#0288d1', '#00838f', '#455a64' ];
private onChange: (v: string) => void = () => {}; private onTouched: () => void = () => {};
selectColor(color: string): void { this.value.set(color); this.onChange(color); this.onTouched(); }
writeValue(value: string): void { this.value.set(value || '#000000'); }
registerOnChange(fn: (v: string) => void): void { this.onChange = fn; } registerOnTouched(fn: () => void): void { this.onTouched = fn; } setDisabledState(disabled: boolean): void { this.isDisabled.set(disabled); }}Rich Text Editor (интеграция с contenteditable)
Заголовок раздела «Rich Text Editor (интеграция с contenteditable)»@Component({ selector: 'app-rich-editor', standalone: true, template: ` <div class="toolbar"> <button (click)="format('bold')"><b>B</b></button> <button (click)="format('italic')"><i>I</i></button> <button (click)="format('underline')"><u>U</u></button> </div> <div #editor contenteditable="true" [attr.disabled]="isDisabled" (input)="onEditorInput()" (blur)="onTouched()" class="editor"> </div> `, providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RichEditorComponent), multi: true, }], changeDetection: ChangeDetectionStrategy.OnPush,})export class RichEditorComponent implements ControlValueAccessor { @ViewChild('editor') editorRef!: ElementRef<HTMLDivElement>;
isDisabled = false; private onChange: (v: string) => void = () => {}; private onTouched: () => void = () => {};
format(command: string): void { document.execCommand(command, false); this.editorRef.nativeElement.focus(); this.onEditorInput(); }
onEditorInput(): void { this.onChange(this.editorRef.nativeElement.innerHTML); }
writeValue(value: string): void { if (this.editorRef) { this.editorRef.nativeElement.innerHTML = value || ''; } }
registerOnChange(fn: (v: string) => void): void { this.onChange = fn; } registerOnTouched(fn: () => void): void { this.onTouched = fn; } setDisabledState(disabled: boolean): void { this.isDisabled = disabled; }}Валидация кастомного контрола
Заголовок раздела «Валидация кастомного контрола»// Добавляем Validator к CVA-компонентуimport { Validator, NG_VALIDATORS, ValidationErrors, AbstractControl } from '@angular/forms';
@Component({ providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StarRatingComponent), multi: true }, { provide: NG_VALIDATORS, useExisting: forwardRef(() => StarRatingComponent), multi: true }, ]})export class StarRatingComponent implements ControlValueAccessor, Validator { @Input() minRating = 1;
validate(control: AbstractControl): ValidationErrors | null { if (!control.value || control.value < this.minRating) { return { minRating: { required: this.minRating, actual: control.value } }; } return null; }}Тестирование CVA
Заголовок раздела «Тестирование CVA»describe('StarRatingComponent', () => { let fixture: ComponentFixture<StarRatingComponent>; let component: StarRatingComponent;
beforeEach(async () => { await TestBed.configureTestingModule({ imports: [StarRatingComponent], }).compileComponents();
fixture = TestBed.createComponent(StarRatingComponent); component = fixture.componentInstance; fixture.detectChanges(); });
it('должен обновлять значение при клике', () => { const changeSpy = jasmine.createSpy('onChange'); component.registerOnChange(changeSpy);
component.setValue(3); expect(component.value()).toBe(3); expect(changeSpy).toHaveBeenCalledWith(3); });
it('должен сбрасывать значение при повторном клике', () => { component.writeValue(3); component.setValue(3); // повторный клик expect(component.value()).toBe(0); });
it('должен быть отключён при setDisabledState(true)', () => { component.setDisabledState(true); expect(component.isDisabled()).toBeTrue(); });});Playground 🎮
Заголовок раздела «Playground 🎮»Кастомный star-rating контрол с полной интеграцией форм: