diff --git a/core/src/components/input-otp/input-otp.tsx b/core/src/components/input-otp/input-otp.tsx
index 3e6cc3855b..a93eabd926 100644
--- a/core/src/components/input-otp/input-otp.tsx
+++ b/core/src/components/input-otp/input-otp.tsx
@@ -48,6 +48,7 @@ export class InputOTP implements ComponentInterface {
@State() private inputValues: string[] = [];
@State() hasFocus = false;
+ @State() private previousInputValues: string[] = [];
/**
* 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 {
});
// Update the value without emitting events
this.value = this.inputValues.join('');
+ this.previousInputValues = [...this.inputValues];
}
/**
@@ -525,19 +527,12 @@ export class InputOTP implements ComponentInterface {
}
/**
- * Handles keyboard navigation and input for the OTP component.
+ * Handles keyboard navigation for the OTP component.
*
* Navigation:
* - Backspace: Clears current input and moves to previous box if empty
* - Arrow Left/Right: Moves focus between input boxes
* - Tab: Allows normal tab navigation between components
- *
- * Input Behavior:
- * - Validates input against the allowed pattern
- * - When entering a key in a filled box:
- * - Shifts existing values right if there is room
- * - Updates the value of the input group
- * - Prevents default behavior to avoid automatic focus shift
*/
private onKeyDown = (index: number) => (event: KeyboardEvent) => {
const { length } = this;
@@ -595,34 +590,32 @@ export class InputOTP implements ComponentInterface {
// Let all tab events proceed normally
return;
}
-
- // If the input box contains a value and the key being
- // entered is a valid key for the input box update the value
- // and shift the values to the right if there is room.
- if (this.inputValues[index] && this.validKeyPattern.test(event.key)) {
- if (!this.inputValues[length - 1]) {
- for (let i = length - 1; i > index; i--) {
- this.inputValues[i] = this.inputValues[i - 1];
- this.inputRefs[i].value = this.inputValues[i] || '';
- }
- }
- this.inputValues[index] = event.key;
- this.inputRefs[index].value = event.key;
- this.updateValue(event);
-
- // Prevent default to avoid the browser from
- // automatically moving the focus to the next input
- event.preventDefault();
- }
};
+ /**
+ * Processes all input scenarios for each input box.
+ *
+ * This function manages:
+ * 1. Autofill handling
+ * 2. Input validation
+ * 3. Full selection replacement or typing in an empty box
+ * 4. Inserting in the middle with available space (shifting)
+ * 5. Single character replacement
+ */
private onInput = (index: number) => (event: InputEvent) => {
const { length, validKeyPattern } = this;
- const value = (event.target as HTMLInputElement).value;
+ const input = event.target as HTMLInputElement;
+ const value = input.value;
+ const previousValue = this.previousInputValues[index] || '';
- // If the value is longer than 1 character (autofill), split it into
- // characters and filter out invalid ones
- if (value.length > 1) {
+ // 1. Autofill handling
+ // If the length of the value increases by more than 1 from the previous
+ // value, treat this as autofill. This is to prevent the case where the
+ // user is typing a single character into an input box containing a value
+ // as that will trigger this function with a value length of 2 characters.
+ const isAutofill = value.length - previousValue.length > 1;
+ if (isAutofill) {
+ // Distribute valid characters across input boxes
const validChars = value
.split('')
.filter((char) => validKeyPattern.test(char))
@@ -639,8 +632,10 @@ export class InputOTP implements ComponentInterface {
});
}
- // Update the value of the input group and emit the input change event
- this.value = validChars.join('');
+ for (let i = 0; i < length; i++) {
+ this.inputValues[i] = validChars[i] || '';
+ this.inputRefs[i].value = validChars[i] || '';
+ }
this.updateValue(event);
// Focus the first empty input box or the last input box if all boxes
@@ -651,23 +646,85 @@ export class InputOTP implements ComponentInterface {
this.inputRefs[nextIndex]?.focus();
}, 20);
+ this.previousInputValues = [...this.inputValues];
return;
}
- // Only allow input if it matches the pattern
- if (value.length > 0 && !validKeyPattern.test(value)) {
- this.inputRefs[index].value = '';
- this.inputValues[index] = '';
+ // 2. Input validation
+ // If the character entered is invalid (does not match the pattern),
+ // restore the previous value and exit
+ if (value.length > 0 && !validKeyPattern.test(value[value.length - 1])) {
+ input.value = this.inputValues[index] || '';
+ this.previousInputValues = [...this.inputValues];
return;
}
- // For single character input, fill the current box
- this.inputValues[index] = value;
- this.updateValue(event);
-
- if (value.length > 0) {
+ // 3. Full selection replacement or typing in an empty box
+ // If the user selects all text in the input box and types, or if the
+ // input box is empty, replace only this input box. If the box is empty,
+ // move to the next box, otherwise stay focused on this box.
+ const isAllSelected = input.selectionStart === 0 && input.selectionEnd === value.length;
+ const isEmpty = !this.inputValues[index];
+ if (isAllSelected || isEmpty) {
+ this.inputValues[index] = value;
+ input.value = value;
+ this.updateValue(event);
this.focusNext(index);
+ this.previousInputValues = [...this.inputValues];
+ return;
}
+
+ // 4. Inserting in the middle with available space (shifting)
+ // If typing in a filled input box and there are empty boxes at the end,
+ // shift all values starting at the current box to the right, and insert
+ // the new character at the current box.
+ const hasAvailableBoxAtEnd = this.inputValues[this.inputValues.length - 1] === '';
+ if (this.inputValues[index] && hasAvailableBoxAtEnd && value.length === 2) {
+ // Get the inserted character (from event or by diffing value/previousValue)
+ let newChar = (event as InputEvent).data;
+ if (!newChar) {
+ newChar = value.split('').find((c, i) => c !== previousValue[i]) || value[value.length - 1];
+ }
+ // Validate the new character before shifting
+ if (!validKeyPattern.test(newChar)) {
+ input.value = this.inputValues[index] || '';
+ this.previousInputValues = [...this.inputValues];
+ return;
+ }
+ // Shift values right from the end to the insertion point
+ for (let i = this.inputValues.length - 1; i > index; i--) {
+ this.inputValues[i] = this.inputValues[i - 1];
+ this.inputRefs[i].value = this.inputValues[i] || '';
+ }
+ this.inputValues[index] = newChar;
+ this.inputRefs[index].value = newChar;
+ this.updateValue(event);
+ this.previousInputValues = [...this.inputValues];
+ return;
+ }
+
+ // 5. Single character replacement
+ // Handles replacing a single character in a box containing a value based
+ // on the cursor position. We need the cursor position to determine which
+ // character was the last character typed. For example, if the user types "2"
+ // in an input box with the cursor at the beginning of the value of "6",
+ // the value will be "26", but we want to grab the "2" as the last character
+ // typed.
+ const cursorPos = input.selectionStart ?? value.length;
+ const newCharIndex = cursorPos - 1;
+ const newChar = value[newCharIndex] ?? value[0];
+
+ // Check if the new character is valid before updating the value
+ if (!validKeyPattern.test(newChar)) {
+ input.value = this.inputValues[index] || '';
+ this.previousInputValues = [...this.inputValues];
+ return;
+ }
+
+ this.inputValues[index] = newChar;
+ input.value = newChar;
+ this.updateValue(event);
+ this.previousInputValues = [...this.inputValues];
};
/**
@@ -711,12 +768,8 @@ export class InputOTP implements ComponentInterface {
// Focus the next empty input after pasting
// If all boxes are filled, focus the last input
- const nextEmptyIndex = validChars.length;
- if (nextEmptyIndex < length) {
- inputRefs[nextEmptyIndex]?.focus();
- } else {
- inputRefs[length - 1]?.focus();
- }
+ const nextEmptyIndex = validChars.length < length ? validChars.length : length - 1;
+ inputRefs[nextEmptyIndex]?.focus();
};
/**
diff --git a/core/src/components/input-otp/test/basic/input-otp.e2e.ts b/core/src/components/input-otp/test/basic/input-otp.e2e.ts
index 2a50c1abd5..2067a00020 100644
--- a/core/src/components/input-otp/test/basic/input-otp.e2e.ts
+++ b/core/src/components/input-otp/test/basic/input-otp.e2e.ts
@@ -442,6 +442,67 @@ configs({ modes: ['ios'] }).forEach(({ title, config }) => {
await verifyInputValues(inputOtp, ['1', '9', '3', '']);
});
+
+ test('should replace the last value when typing one more than the length', async ({ page }) => {
+ await page.setContent(`Description`, config);
+
+ const inputOtp = page.locator('ion-input-otp');
+ const firstInput = inputOtp.locator('input').first();
+ await firstInput.focus();
+
+ await page.keyboard.type('12345');
+
+ await verifyInputValues(inputOtp, ['1', '2', '3', '5']);
+ });
+
+ test('should replace the last value when typing one more than the length and the type is text', async ({
+ page,
+ }, testInfo) => {
+ testInfo.annotations.push({
+ type: 'issue',
+ description: 'https://github.com/ionic-team/ionic-framework/issues/30459',
+ });
+
+ await page.setContent(`Description`, config);
+
+ const inputOtp = page.locator('ion-input-otp');
+ const firstInput = inputOtp.locator('input').first();
+ await firstInput.focus();
+
+ await page.keyboard.type('abcde');
+
+ await verifyInputValues(inputOtp, ['a', 'b', 'c', 'e']);
+ });
+
+ test('should not insert or shift when typing an invalid character before a number', async ({ page }) => {
+ await page.setContent(`Description`, config);
+
+ const inputOtp = page.locator('ion-input-otp');
+ const firstInput = inputOtp.locator('input').first();
+ await firstInput.focus();
+
+ // Move cursor to the start of the first input
+ await firstInput.evaluate((el: HTMLInputElement) => el.setSelectionRange(0, 0));
+
+ await page.keyboard.type('w');
+
+ await verifyInputValues(inputOtp, ['1', '2', '', '']);
+ });
+
+ test('should not insert or shift when typing an invalid character after a number', async ({ page }) => {
+ await page.setContent(`Description`, config);
+
+ const inputOtp = page.locator('ion-input-otp');
+ const firstInput = inputOtp.locator('input').first();
+ await firstInput.focus();
+
+ // Move cursor to the end of the first input
+ await firstInput.evaluate((el: HTMLInputElement) => el.setSelectionRange(1, 1));
+
+ await page.keyboard.type('w');
+
+ await verifyInputValues(inputOtp, ['1', '2', '', '']);
+ });
});
test.describe(title('input-otp: autofill functionality'), () => {
@@ -460,6 +521,53 @@ configs({ modes: ['ios'] }).forEach(({ title, config }) => {
await expect(lastInput).toBeFocused();
});
+ test('should handle autofill correctly when all characters are the same', async ({ page }) => {
+ await page.setContent(`Description`, config);
+
+ const firstInput = page.locator('ion-input-otp input').first();
+ await firstInput.focus();
+
+ await simulateAutofill(firstInput, '1111');
+
+ const inputOtp = page.locator('ion-input-otp');
+ await verifyInputValues(inputOtp, ['1', '1', '1', '1']);
+
+ const lastInput = page.locator('ion-input-otp input').last();
+ await expect(lastInput).toBeFocused();
+ });
+
+ test('should handle autofill correctly when length is 2', async ({ page }) => {
+ await page.setContent(`Description`, config);
+
+ const firstInput = page.locator('ion-input-otp input').first();
+ await firstInput.focus();
+
+ await simulateAutofill(firstInput, '12');
+
+ const inputOtp = page.locator('ion-input-otp');
+ await verifyInputValues(inputOtp, ['1', '2']);
+
+ const lastInput = page.locator('ion-input-otp input').last();
+ await expect(lastInput).toBeFocused();
+ });
+
+ test('should handle autofill correctly when length is 2 after typing 1 character', async ({ page }) => {
+ await page.setContent(`Description`, config);
+
+ await page.keyboard.type('1');
+
+ const secondInput = page.locator('ion-input-otp input').nth(1);
+ await secondInput.focus();
+
+ await simulateAutofill(secondInput, '22');
+
+ const inputOtp = page.locator('ion-input-otp');
+ await verifyInputValues(inputOtp, ['2', '2']);
+
+ const lastInput = page.locator('ion-input-otp input').last();
+ await expect(lastInput).toBeFocused();
+ });
+
test('should handle autofill correctly when it exceeds the length', async ({ page }) => {
await page.setContent(`Description`, config);