Skip to content

Commit c9f59be

Browse files
committed
fix(input-otp): handle all input changes in onInput instead of partially in onKeydown
1 parent d05d110 commit c9f59be

File tree

1 file changed

+114
-59
lines changed

1 file changed

+114
-59
lines changed

core/src/components/input-otp/input-otp.tsx

Lines changed: 114 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export class InputOTP implements ComponentInterface {
4848

4949
@State() private inputValues: string[] = [];
5050
@State() hasFocus = false;
51+
@State() private previousInputValues: string[] = [];
5152

5253
/**
5354
* Indicates whether and how the text value should be automatically capitalized as it is entered/edited by the user.
@@ -336,6 +337,7 @@ export class InputOTP implements ComponentInterface {
336337
});
337338
// Update the value without emitting events
338339
this.value = this.inputValues.join('');
340+
this.previousInputValues = [...this.inputValues];
339341
}
340342

341343
/**
@@ -525,19 +527,12 @@ export class InputOTP implements ComponentInterface {
525527
}
526528

527529
/**
528-
* Handles keyboard navigation and input for the OTP component.
530+
* Handles keyboard navigation for the OTP component.
529531
*
530532
* Navigation:
531533
* - Backspace: Clears current input and moves to previous box if empty
532534
* - Arrow Left/Right: Moves focus between input boxes
533535
* - Tab: Allows normal tab navigation between components
534-
*
535-
* Input Behavior:
536-
* - Validates input against the allowed pattern
537-
* - When entering a key in a filled box:
538-
* - Shifts existing values right if there is room
539-
* - Updates the value of the input group
540-
* - Prevents default behavior to avoid automatic focus shift
541536
*/
542537
private onKeyDown = (index: number) => (event: KeyboardEvent) => {
543538
const { length } = this;
@@ -595,79 +590,139 @@ export class InputOTP implements ComponentInterface {
595590
// Let all tab events proceed normally
596591
return;
597592
}
598-
599-
// If the input box contains a value and the key being
600-
// entered is a valid key for the input box update the value
601-
// and shift the values to the right if there is room.
602-
if (this.inputValues[index] && this.validKeyPattern.test(event.key)) {
603-
if (!this.inputValues[length - 1]) {
604-
for (let i = length - 1; i > index; i--) {
605-
this.inputValues[i] = this.inputValues[i - 1];
606-
this.inputRefs[i].value = this.inputValues[i] || '';
607-
}
608-
}
609-
this.inputValues[index] = event.key;
610-
this.inputRefs[index].value = event.key;
611-
this.updateValue(event);
612-
613-
// Prevent default to avoid the browser from
614-
// automatically moving the focus to the next input
615-
event.preventDefault();
616-
}
617593
};
618594

595+
/**
596+
* Processes all input scenarios for each input box.
597+
*
598+
* This function manages:
599+
* 1. Autofill handling
600+
* 2. Input validation
601+
* 3. Full selection replacement or typing in an empty box
602+
* 4. Inserting in the middle with available space (shifting)
603+
* 5. Single character replacement
604+
*/
619605
private onInput = (index: number) => (event: InputEvent) => {
620606
const { length, validKeyPattern } = this;
621-
const value = (event.target as HTMLInputElement).value;
622-
623-
// If the value is longer than 1 character (autofill), split it into
624-
// characters and filter out invalid ones
625-
if (value.length > 1) {
607+
const input = event.target as HTMLInputElement;
608+
const value = input.value;
609+
const previousValue = this.previousInputValues[index] || '';
610+
611+
// 1. Autofill handling
612+
// If the length of the value increases by more than 1 from the previous
613+
// value, treat this as autofill. This is to prevent the case where the
614+
// user is typing a single character into an input box containing a value
615+
// as that will trigger this function with a value length of 2 characters.
616+
const isAutofill = value.length - previousValue.length > 1;
617+
if (isAutofill) {
618+
// Distribute valid characters across input boxes
626619
const validChars = value
627620
.split('')
628621
.filter((char) => validKeyPattern.test(char))
629622
.slice(0, length);
630-
631-
// If there are no valid characters coming from the
632-
// autofill, all input refs have to be cleared after the
633-
// browser has finished the autofill behavior
634-
if (validChars.length === 0) {
635-
requestAnimationFrame(() => {
636-
this.inputRefs.forEach((input) => {
637-
input.value = '';
638-
});
639-
});
623+
for (let i = 0; i < length; i++) {
624+
this.inputValues[i] = validChars[i] || '';
625+
if (this.inputRefs[i] != null) {
626+
this.inputRefs[i]!.value = validChars[i] || '';
627+
}
640628
}
641-
642-
// Update the value of the input group and emit the input change event
643-
this.value = validChars.join('');
644629
this.updateValue(event);
645-
646-
// Focus the first empty input box or the last input box if all boxes
647-
// are filled after a small delay to ensure the input boxes have been
648-
// updated before moving the focus
630+
// Focus the next empty input or the last one
649631
setTimeout(() => {
650632
const nextIndex = validChars.length < length ? validChars.length : length - 1;
651-
this.inputRefs[nextIndex]?.focus();
633+
if (this.inputRefs[nextIndex] != null) {
634+
this.inputRefs[nextIndex]!.focus();
635+
}
652636
}, 20);
637+
this.previousInputValues = [...this.inputValues];
638+
return;
639+
}
653640

641+
// 2. Input validation
642+
// If the character entered is invalid (does not match the pattern),
643+
// restore the previous value and exit
644+
if (value.length > 0 && !validKeyPattern.test(value[value.length - 1])) {
645+
input.value = this.inputValues[index] || '';
646+
this.previousInputValues[index] = this.inputValues[index] || '';
647+
this.previousInputValues = [...this.inputValues];
654648
return;
655649
}
656650

657-
// Only allow input if it matches the pattern
658-
if (value.length > 0 && !validKeyPattern.test(value)) {
659-
this.inputRefs[index].value = '';
660-
this.inputValues[index] = '';
651+
// 3. Full selection replacement or typing in an empty box
652+
// If the user selects all text in the input box and types, or if the
653+
// input box is empty, replace only this input box. If the box is empty,
654+
// move to the next box, otherwise stay focused on this box.
655+
const isAllSelected = input.selectionStart === 0 && input.selectionEnd === value.length;
656+
const isEmpty = !this.inputValues[index];
657+
if (isAllSelected || isEmpty) {
658+
this.inputValues[index] = value;
659+
input.value = value;
660+
this.previousInputValues[index] = value;
661+
this.updateValue(event);
662+
this.focusNext(index);
663+
this.previousInputValues = [...this.inputValues];
661664
return;
662665
}
663666

664-
// For single character input, fill the current box
665-
this.inputValues[index] = value;
666-
this.updateValue(event);
667+
// 4. Inserting in the middle with available space (shifting)
668+
// If typing in a filled input box and there are empty boxes at the end,
669+
// shift all values starting at the current box to the right, and insert
670+
// the new character at the current box.
671+
const hasAvailableBoxAtEnd = this.inputValues[this.inputValues.length - 1] === '';
672+
if (this.inputValues[index] && hasAvailableBoxAtEnd && value.length === 2) {
673+
// Get the inserted character (from event or by diffing value/previousValue)
674+
let newChar = (event as InputEvent).data;
675+
if (!newChar) {
676+
newChar = value.split('').find((c, i) => c !== previousValue[i]) || value[value.length - 1];
677+
}
678+
// Validate the new character before shifting
679+
if (!validKeyPattern.test(newChar)) {
680+
input.value = this.inputValues[index] || '';
681+
this.previousInputValues[index] = this.inputValues[index] || '';
682+
this.previousInputValues = [...this.inputValues];
683+
return;
684+
}
685+
// Shift values right from the end to the insertion point
686+
for (let i = this.inputValues.length - 1; i > index; i--) {
687+
this.inputValues[i] = this.inputValues[i - 1];
688+
if (this.inputRefs[i] != null) {
689+
this.inputRefs[i]!.value = this.inputValues[i] || '';
690+
}
691+
}
692+
this.inputValues[index] = newChar;
693+
if (this.inputRefs[index] != null) {
694+
this.inputRefs[index]!.value = newChar;
695+
}
696+
this.previousInputValues[index] = newChar;
697+
this.updateValue(event);
698+
this.previousInputValues = [...this.inputValues];
699+
return;
700+
}
667701

668-
if (value.length > 0) {
669-
this.focusNext(index);
702+
// 5. Single character replacement
703+
// Handles replacing a single character in a box containing a value based
704+
// on the cursor position. We need the cursor position to determine which
705+
// character was the last character typed. For example, if the user types "2"
706+
// in an input box with the cursor at the beginning of the value of "6",
707+
// the value will be "26", but we want to grab the "2" as the last character
708+
// typed.
709+
const cursorPos = input.selectionStart ?? value.length;
710+
const newCharIndex = cursorPos - 1;
711+
const newChar = value[newCharIndex] ?? value[0];
712+
713+
// Check if the new character is valid before updating the value
714+
if (!validKeyPattern.test(newChar)) {
715+
input.value = this.inputValues[index] || '';
716+
this.previousInputValues[index] = this.inputValues[index] || '';
717+
this.previousInputValues = [...this.inputValues];
718+
return;
670719
}
720+
721+
input.value = newChar;
722+
this.inputValues[index] = newChar;
723+
this.previousInputValues[index] = newChar;
724+
this.updateValue(event);
725+
this.previousInputValues = [...this.inputValues];
671726
};
672727

673728
/**

0 commit comments

Comments
 (0)