Angular ngModelChange is late when updating NgModel
TLDR
StackBlitz.
my-directive.directive.ts
/* ... */
ngOnInit () {
const initialOnChange = (this.ngControl.valueAccessor as any).onChange;
(this.ngControl.valueAccessor as any).onChange = (value) => initialOnChange(this.processInput(value));
}
/* ... */
@HostListener('ngModelChange', ['$event'])
ngModelChange(value: any) {
this.ngControl.valueAccessor.writeValue(this.processInput(value));
}
Detailed answer
Let's see why it didn't work initially.
Angular has default value accessors for certain elements, such as for input type='text'
, input type='checkbox'
etc...
A ControlValueAccessor
is the middleman between the VIEW layer and the MODEL layer. When a user types into an input, the VIEW notifies the ControlValueAccessor
, which has the job to inform the MODEL.
For instance, when the input
event occurs, the onChange
method of the ControlValueAccessor
will be called. Here's how onChange
looks like for every ControlValueAccessor
:
function setUpViewChangePipeline(control: FormControl, dir: NgControl): void {
dir.valueAccessor!.registerOnChange((newValue: any) => {
control._pendingValue = newValue;
control._pendingChange = true;
control._pendingDirty = true;
if (control.updateOn === 'change') updateControl(control, dir);
});
}
The magic happens in updateControl
:
function updateControl(control: FormControl, dir: NgControl): void {
if (control._pendingDirty) control.markAsDirty();
control.setValue(control._pendingValue, {emitModelToViewChange: false});
// !
dir.viewToModelUpdate(control._pendingValue);
control._pendingChange = false;
}
dir.viewToModelUpdate(control._pendingValue);
is what invokes the ngModelChange
event in the custom directive. What this means is that the model value is the value from the input(in lowercase). And because ControlValueAccessor.writeValue
only writes the value to the VIEW, there will be a delay between the VIEW's value and the MODEL's value.
It's worth mentioning that FormControl.setValue(val)
will write val
to both layers, VIEW and MODEL, but if we were to used this, there would be an infinite loop, since setValue()
internally calls viewToModelUpdate
(because the MODEL has to be updated), and viewToModelUpdate
calls setValue()
.
Let's have a look at a possible solution:
ngOnInit () {
const initialOnChange = (this.ngControl.valueAccessor as any).onChange;
(this.ngControl.valueAccessor as any).onChange = (value) => initialOnChange(this.processInput(value));
}
With this approach, you're modifying your data at the VIEW layer, before it is sent to the ControlValueAccessor
.
And we can be sure that onChange
exists on every built-in ControlValueAccessor
.
If you are going to create a custom one, just make sure it has an onChange
property. TypeScript can help you with that.
If you'd like to read more about internals of @angular/forms
, I'd recommend having a look at A thorough exploration of Angular Forms.
you can get it using
@HostListener('input', ['$event'])
ngModelChange(event: any) {
const item = event.target
const value = item.value;
const pos = item.selectionStart;
this.control.control.setValue(this.processInput(value), { emit: false });
item.selectionStart = item.selectionEnd = pos
}
See that we use @HostListener input, to get the item, not only the value. This allow us position the cursor in his position after change the value
NOTE: To make a simple uppercase it's better use css text-transform:uppercase and, when we want to get the value use toUpperCase()
NOTE2: about mask see this SO