I know there is an existing npm package for OTP input fields, as mentioned in this Stack Overflow post, but I need to build this component from scratch for better customization and learning purposes.
I am working on an Angular 8 OTP input component, where users enter a 6-digit code. The OTP input fields are dynamically generated using *ngFor, and I am using [value] binding and event listeners to update the component state. However, I am encountering two issues:
My Component Code HTML (otp-input.component.html):
<div class="otp-container">
<input
*ngFor="let digit of otpArray; let i = index"
type="text"
class="otp-input"
maxlength="1"
[value]="otpArray[i]"
(input)="onInput($event, i)"
(keydown)="onKeyDown($event, i)"
#otpInput
/>
</div>
TypeScript (otp-input.component.ts):
import { Component, EventEmitter, Output, ViewChildren, ElementRef, QueryList } from
'@angular/core';
@Component({
selector: 'app-otp-input',
templateUrl: './otp-input.component.html',
styleUrls: ['./otp-input.component.css']
})
export class OtpInputComponent {
otpLength = 6;
otpArray: string[] = new Array(this.otpLength).fill('');
@Output() otpCompleted = new EventEmitter<string>();
@ViewChildren('otpInput') otpInputs!: QueryList<ElementRef>;
onInput(event: Event, index: number): void {
const inputElement = event.target as HTMLInputElement;
const value = inputElement.value;
// Ensure the correct value is assigned to the correct index
this.otpArray[index] = value;
console.log(`User entered value "${this.otpArray[index]}" at index ${index}`);
const inputEvent = event as InputEvent;
if (inputEvent.inputType === 'deleteContentBackward') {
console.log('User pressed delete on input ' + index);
return;
}
if (value && index < this.otpLength - 1) {
this.otpInputs.toArray()[index + 1].nativeElement.focus();
}
this.checkOtpCompletion();
}
onKeyDown(event: KeyboardEvent, index: number): void {
if (event.key === 'Backspace') {
if (this.otpArray[index]) {
this.otpArray[index] = ''; // Clear current input
} else if (index > 0) {
console.log('Backspace pressed, moving to previous index:', index);
this.otpInputs.toArray()[index - 1].nativeElement.focus();
}
}
}
checkOtpCompletion(): void {
const otpValue: string = this.otpArray.join('');
if (otpValue.length === this.otpLength) {
this.otpCompleted.emit(otpValue);
}
} } Expected Behavior:
What I Have Tried:
Questions
The problem might be that *ngFor
destroys the elements and recreates then for change detection cycles, when trackBy
is not specified, this might be the reason for this strange behavior, alternative theory is the name and id help determine which element to update, if not specified, you might face weird bugs like incorrect input being updated.
<div class="otp-container">
<input
*ngFor="let digit of otpArray; let i = index; trackBy:trackByIndex"
type="text"
class="otp-input"
maxlength="1"
[id]="'otp-' + i"
[name]="'otp-' + i"
[value]="otpArray[i]"
(input)="onInput($event, i)"
(keydown)="onKeyDown($event, i)"
#otpInput
/>
</div>
trackByIndex = (index: number, obj: object): string => { return index; };
<div class="otp-container">
@for(digit of otpArray; let i = $index;track i) {
<input
type="text"
class="otp-input"
maxlength="1"
[id]="'otp-' + i"
[name]="'otp-' + i"
[value]="otpArray[i]"
(input)="onInput($event, i)"
(keydown)="onKeyDown($event, i)"
#otpInput
/>
}
</div>
import { Component } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import {
EventEmitter,
Output,
ViewChildren,
ElementRef,
QueryList,
} from '@angular/core';
@Component({
selector: 'app-otp-input',
template: `
<div class="otp-container">
@for(digit of otpArray; let i = $index;track i) {
<input
type="text"
class="otp-input"
maxlength="1"
[id]="'otp-' + i"
[name]="'otp-' + i"
[value]="otpArray[i]"
(input)="onInput($event, i)"
(keydown)="onKeyDown($event, i)"
#otpInput
/>
}
</div>
`,
})
export class OtpInputComponent {
otpLength = 6;
otpArray: string[] = new Array(this.otpLength).fill('');
@Output() otpCompleted = new EventEmitter<string>();
@ViewChildren('otpInput') otpInputs!: QueryList<ElementRef>;
onInput(event: Event, index: number): void {
const inputElement = event.target as HTMLInputElement;
const value = inputElement.value;
// Ensure the correct value is assigned to the correct index
this.otpArray[index] = value;
console.log(
`User entered value "${this.otpArray[index]}" at index ${index}`
);
const inputEvent = event as InputEvent;
if (inputEvent.inputType === 'deleteContentBackward') {
console.log('User pressed delete on input ' + index);
return;
}
if (value && index < this.otpLength - 1) {
this.otpInputs.toArray()[index + 1].nativeElement.focus();
}
this.checkOtpCompletion();
}
checkOtpCompletion(): void {
const otpValue: string = this.otpArray.join('');
if (otpValue.length === this.otpLength) {
this.otpCompleted.emit(otpValue);
}
}
onKeyDown(event: KeyboardEvent, index: number): void {
if (event.key === 'Backspace') {
if (this.otpArray[index]) {
this.otpArray[index] = ''; // Clear current input
} else if (index > 0) {
console.log('Backspace pressed, moving to previous index:', index);
this.otpInputs.toArray()[index - 1].nativeElement.focus();
}
}
}
}
@Component({
selector: 'app-root',
template: `
<app-otp-input/>
`,
imports: [OtpInputComponent],
})
export class App {
name = 'Angular';
}
bootstrapApplication(App);