I'm having an issue with my HTML input field and the typescript component that is using ngModelChange. I want to be able to edit the input value wherever I need to.
For example:
I know this is a known issue that could be fixed with re-setting the cursor using setSelectionRange, however that has not worked since even if I used the setSelectionRange(selectionStart, selectionEnd) with the correct value of the cursor, the ngModelChange, would put the cursor back to the end.
I also have a Regex that applies the colon after each two digits.
Although this is my code, I also provide a stackblitz where you can play with it: https://stackblitz.com/edit/angular-ivy-adynjf?file=src/app/app.compone
This is my input field:
<input
id="value"
type="text"
[ngModel]="changedValue"
(ngModelChange)="formatAndChange($event)"
/>
and part of my component:
export class AppComponent {
public changedValue: String = "00:00:00";
public formatAndChange(inputValue: string) {
this.changedValue = inputValue;
if (inputValue.length > 8) {
inputValue = inputValue.substr(0, 8);
}
let unformat = inputValue.replace(/\D/g, "");
if (unformat.length > 0) {
inputValue = unformat.match(new RegExp(".{1,2}", "g")).join(":");
}
this.changedValue = new String(inputValue);
}
}
Basically my question is, how is this structure supposed to be used if we want it all: the value changes and is formatted while the user is typing (we add the colon so the format is correct), and the cursor stays in place (ngModelChange does not change the cursor placement or at least I can make it return to where it was)
Appreciate it. Thanks!!
This is not quite correct:
even if I used the setSelectionRange(selectionStart, selectionEnd) with the correct value of the cursor, the ngModelChange, would put the cursor back to the end.
The cursor is placed at the end of the input field by the browser whenever the value is updated via JavaScript. Nothing to do with Angular.
Let's take a look at what happens when you type something in the input field. This is a very well-defined sequence:
ngModelChange
fires;formatAndChange
runs and updates changedValue
;formatAndChange
method has completed by this point);ngModel
;ngModel
schedules a microtask (I'll explain at the end of the answer), which updates the actual input element value.Note that when ngModel
is updated, ngModelChange
is not even fired.
If you were trying to setSelectionRange
inside formatAndChange
, it was never going to work, because this is what would happen:
changedValue
is updated;ngModel
and subsequently the input value are updated, throwing the cursor to the end of the input.To get this working you need to call setSelectionRange
after the input value is updated - so at least as late as a microtask after change detection is complete. Here's the updated code (note that this does not work exactly right due to colons between the digits, but I am sure you can figure that out by yourself):
import {
AfterViewChecked,
Component,
ElementRef,
ViewChild
} from '@angular/core';
@Component({
selector: 'my-app',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements AfterViewChecked {
public changedValue: String = '00:00:00';
private valueUpdated: boolean;
private selectionStart: number;
private selectionEnd: number;
private selectionDirection: 'forward' | 'backward' | 'none';
@ViewChild('input')
private inputRef: ElementRef<HTMLInputElement>;
public formatAndChange(inputValue: string) {
console.log(inputValue);
const oldChangedValue = this.changedValue;
this.changedValue = inputValue;
if (inputValue.length > 8) {
inputValue = inputValue.substr(0, 8);
}
let unformat = inputValue.replace(/\D/g, '');
if (unformat.length > 0) {
inputValue = unformat.match(new RegExp('.{1,2}', 'g')).join(':');
}
console.log(inputValue);
this.changedValue = new String(inputValue);
this.valueUpdated = oldChangedValue !== this.changedValue;
if (this.valueUpdated && this.inputRef.nativeElement) {
const element = this.inputRef.nativeElement;
this.selectionStart = element.selectionStart;
this.selectionEnd = element.selectionEnd;
this.selectionDirection = element.selectionDirection;
}
}
// This lifecycle hook is called after change detection is complete for this component
ngAfterViewChecked() {
// This method is called VERY often, so we need to make sure that we only execute this logic when truly necessary (i.e. the value has actually changed)
if (this.valueUpdated && this.inputRef.nativeElement) {
this.valueUpdated = false;
// This is how you schedule a microtask
Promise.resolve().then(() => {
// Make sure you update this to deal with colons
this.inputRef.nativeElement.setSelectionRange(
this.selectionStart,
this.selectionEnd,
this.selectionDirection
);
});
}
}
}
A microtask is basically some code, that is executed after the current call stack empties. Javascript's tasks and microtasks are at the very core of the JavaScript engine, and they are actually not that simple to grasp, but very useful to understand.
I do not know why Angular developers decided to update the input value inside a microtask, must've had their reasons.