Skip to content

Instantly share code, notes, and snippets.

@LeonardoZivieri
Last active May 5, 2025 11:51
Show Gist options
  • Save LeonardoZivieri/b79789a8449868ee3ef80fad88a47cdb to your computer and use it in GitHub Desktop.
Save LeonardoZivieri/b79789a8449868ee3ef80fad88a47cdb to your computer and use it in GitHub Desktop.
Angular ControlValueAccessor wrapper
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
effect,
EventEmitter,
inject,
input,
Input,
OnDestroy,
OnInit,
Output,
Type,
} from '@angular/core';
import {
ControlValueAccessor,
Validator,
AbstractControl,
ValidationErrors,
NG_VALUE_ACCESSOR,
NG_VALIDATORS,
} from '@angular/forms';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
const baseControl = Symbol('BaseControlComponentConfigs');
function collectAllErrors(control: AbstractControl): ValidationErrors | null {
let errors: ValidationErrors = {};
if (control.errors) {
errors = {
...control.errors,
};
}
if (control instanceof FormGroup) {
for (const controlInGroup in control.controls) {
const controlErrors = collectAllErrors(
control.controls[controlInGroup]
);
if (controlErrors) {
for (const errorIndex in controlErrors) {
errors[`${controlInGroup}.${errorIndex}`] =
controlErrors[errorIndex];
}
}
}
}
if (control instanceof FormArray) {
for (const controlInGroup of control.controls) {
const controlErrors = collectAllErrors(controlInGroup);
if (controlErrors) {
for (const errorIndex in controlErrors) {
errors[
`${control.controls.indexOf(controlInGroup)}.${errorIndex}`
] = controlErrors[errorIndex];
}
}
}
}
if (Object.keys(errors).length == 0) {
return null;
}
return errors;
}
@Component({
template: '',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export abstract class BaseControlComponent<
ExternalValueType = any,
InternalValueType = ExternalValueType,
> implements ControlValueAccessor, Validator, OnInit, OnDestroy
{
static getProviders(klass: Type<BaseControlComponent>) {
return [
{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: klass,
},
{
provide: NG_VALIDATORS,
useExisting: klass,
multi: true,
},
] as const;
}
private [baseControl] = {
destroy$: new Subject<void>(),
detectorRef: inject(ChangeDetectorRef),
};
abstract control: AbstractControl;
// Arrays to store onChange and onTouched callbacks
private onChangeCallbacks: ((value: ExternalValueType) => void)[] = [];
private onTouchedCallbacks: (() => void)[] = [];
emitFirstChangeEvent = false;
get internalValue(): InternalValueType {
return this.control.value;
}
get value(): ExternalValueType {
return this.toExternalValue(this.control.value);
}
@Input()
set value(newValue: ExternalValueType) {
this.writeValue(newValue);
}
disabled = input<boolean>(false);
#eff_disabled = effect(() => {
const disabled = this.disabled();
if (disabled !== undefined) {
this.setDisabledState!(disabled);
}
});
@Output() valueChange = new EventEmitter<ExternalValueType>();
// Methods to convert between internal and external values
toExternalValue(internalValue: InternalValueType): ExternalValueType {
return internalValue as unknown as ExternalValueType;
}
toInternalValue(externalValue: ExternalValueType): InternalValueType {
return externalValue as unknown as InternalValueType;
}
ngOnInit(): void {
let lastUpdatedValue = this.value;
this.registerOnChange((value) => {
if (lastUpdatedValue !== value) {
this.valueChange.emit(value);
lastUpdatedValue = value;
}
});
this.control.valueChanges
.pipe(takeUntil(this[baseControl].destroy$))
.subscribe((internalValue) => {
const externalValue = this.toExternalValue(internalValue);
this.#lastWritedValue = externalValue;
this.onChangeCallbacks.forEach((callback) => {
callback(externalValue);
});
});
this.control.statusChanges
.pipe(takeUntil(this[baseControl].destroy$))
.subscribe(() => {
this.onTouch();
});
}
ngOnDestroy(): void {
this[baseControl].destroy$.next();
this[baseControl].destroy$.complete();
}
// ControlValueAccessor Methods
#lastWritedValueFirst = {};
#lastWritedValue: {} | ExternalValueType = this.#lastWritedValueFirst;
writeValue(value: ExternalValueType): void {
if (value !== undefined && value !== this.#lastWritedValue) {
const prevWritedValue = this.#lastWritedValue;
this.#lastWritedValue = value;
const internalValue = this.toInternalValue(value);
this.control.setValue(internalValue, {
emitEvent: prevWritedValue !== this.#lastWritedValueFirst,
});
this[baseControl].detectorRef.detectChanges();
}
}
registerOnChange(fn: (value: ExternalValueType) => void): void {
this.onChangeCallbacks.push(fn);
}
registerOnTouched(fn: () => void): void {
this.onTouchedCallbacks.push(fn);
}
onTouch(): void {
this.onTouchedCallbacks.forEach((callback) => {
callback();
});
}
setDisabledState?(isDisabled: boolean): void {
if (isDisabled !== this.control.disabled) {
if (isDisabled) {
this.control.disable();
} else {
this.control.enable();
}
}
this[baseControl].detectorRef.detectChanges();
}
// Validator Methods
validate(control: AbstractControl): ValidationErrors | null {
return collectAllErrors(this.control);
}
registerOnValidatorChange?(fn: () => void): void {
this.control.statusChanges
.pipe(takeUntil(this[baseControl].destroy$))
.subscribe(fn);
}
}
// Example
@Component({
selector: 'mylib-file-input',
templateUrl: './file-input.component.html',
styleUrl: './file-input.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [...BaseControlComponent.getProviders(FileInputComponent)],
})
export class FileInputComponent extends BaseControlComponent<null | FileInputValue> {
override control = new FormControl<(typeof this)['value']>(null);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment