Перейти к содержимому

53. Пользовательские Form Controls

56. Пользовательские Form Controls (ControlValueAccessor) ⭐

Заголовок раздела «56. Пользовательские Form Controls (ControlValueAccessor) ⭐»

Привет! Яша здесь. Когда стандартных <input> и <select> недостаточно — ты создаёшь кастомный контрол. ControlValueAccessor — мост между Angular Forms и твоим компонентом. После этого урока ты сможешь создать любой контрол: рейтинг, цветопикер, rich text editor 🎨


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.component.ts
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); }
}

@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;
}
}

star-rating.component.spec.ts
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();
});
});

Кастомный star-rating контрол с полной интеграцией форм: