2

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?

1 Answer 1

2

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

Sign up to request clarification or add additional context in comments.

5 Comments

Thank you for your quick response! However, I believe $index and @for are features introduced in Angular 17. As I mentioned earlier, I'm looking for a solution that works with Angular 8.
@YoniShkolsky if you could share a stackblitz with the issue happening, steps to reproduce the issue on the stackblitz, I can check
No need! I added the trackBy: trackByIndex in the ngFor and it work perfectly! ust to clarify, the issue was caused by Angular recreating the elements, which led to unintended behavior.?
@YoniShkolsky yes, either that or missing id and name
New in angular and was working on a poc for me. TrackBy and trackbyindex is what being missed. Caught issue exactly on the point. Even AI couldn’t meet the developer experience. Thanks :)

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.