Search code examples
htmlangulartypescriptone-time-password

Angular OTP Input: Value Replicating in Multiple Fields Issue


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:

  1. Value Duplication:
  • When I type a number in the first input (index 0), the same number appears in the second input (index 1).
  1. Backspace Behavior:
  • When I click on the second input and press backspace, the value in the first input is deleted instead of the second one.

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:

  1. Each digit should only be entered in the selected input field, without affecting others.
  2. Pressing backspace should clear the current field first and then move focus to the previous input.

What I Have Tried:

  1. Replaced [value] with [(ngModel)
  2. hecked for unexpected change detection updates.
  3. I added console logs and verified the updates were occurring in the expected order.
  4. Manually handling state updates in onInput function

Questions

  1. Why does the value get duplicated in the next input field when typing?
  2. How can I prevent backspace from deleting the previous field's value before the current one?

Solution

  • 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.

    ngFor:

    <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>
    

    TS:

    trackByIndex = (index: number, obj: object): string => {   return index; };
    

    @for:

      <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>
    

    Full Code:

    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);
    

    Stackblitz Demo