Last active
May 5, 2025 11:51
-
-
Save LeonardoZivieri/b79789a8449868ee3ef80fad88a47cdb to your computer and use it in GitHub Desktop.
Angular ControlValueAccessor wrapper
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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