9

I'm making a directive using angular 8 to do some processing and convert the text to uppercase. Simplified code below:

html:

<input class="form-control" id="label" name="label" required myDirective>

directive:

import { Directive, HostListener } from '@angular/core';
import { NgControl } from '@angular/forms';

@Directive({
  selector: '[myDirective]'
})
export class Mydirective {
  constructor(private control: NgControl) { }

  processInput(value: any) {
     // do some formatting
     return value.toUpperCase();
  }

  @HostListener('ngModelChange', ['$event'])
  ngModelChange(value: any) {
     this.control.valueAccessor.writeValue(this.processInput(value));
  }
}

Right now, the view is updated correctly, however the model is late by one step. for example: if input text shows 'AAAA' then ng-reflect-model will show 'AAAa'.

I have reproduced the error in stackblitz: Error Reproduced in Stackblitz

Any idea where I am wrong?

Thanks before!

Owen Kelvin
  • 14,054
  • 10
  • 41
  • 74
Daniel W
  • 1,092
  • 1
  • 12
  • 25

3 Answers3

12

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.

search results

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.

Andrei Gătej
  • 11,116
  • 1
  • 14
  • 31
2

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

Eliseo
  • 50,109
  • 4
  • 29
  • 67
0

From the documentation

writeValue: This method is called by the forms API to write to the view when programmatic changes from model to view are requested.

Calling this method on the control will hence only update the view. To Update the model we can call viewToModelUpdate(value: string) on the NgModel class. This will update the model

constructor(private model: NgModel) { }
processInput(value: any) {
   // do some formatting
   return value.toUpperCase();
}

@HostListener('input', ['$event'])
ngModelChange(input: any) {
  const value = input.target.value
  this.model.valueAccessor.writeValue(this.processInput(value));
  this.model.viewToModelUpdate(this.processInput(value));
}

Below is a working solution on stackblitz Working Solution

Owen Kelvin
  • 14,054
  • 10
  • 41
  • 74