fix(input-otp): improve autofill detection and invalid character handling (#30541)

Issue number: resolves #30459

---------

## What is the current behavior?
1. Typing `"12345"` in a 4-box input-otp with `type="text"` is
incorrectly triggering autofill detection on Android, causing `"45"` to
be distributed across the first two boxes instead of replacing the `"4"`
with the `"5"`.

**Current Behavior**: Type `"12345"` → `["4", "5", "", ""]` (incorrectly
distributed)
**Expected Behavior**: Type `"12345"` → `["1", "2", "3", "5"]`
(correctly replaces last character)

2. Typing an invalid character (like `"w"` when `type="number"`) in an
input box with a value is inserted, ignoring the input validation.

**Current Behavior**: Type `"2"` in the first box, focus it again, type
`"w"` → `"2w"` appears
**Expected Behavior**: Type `"2"` in the first box, focus it again, type
`"w"` → `"2"` remains (invalid character rejected)

## What is the new behavior?
- Fixes autofill detection on Android devices
- Fixes invalid character insertion in filled input boxes
- Improves cursor position handling when typing in a filled box
- Adds e2e tests for more coverage

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

## Other information

Dev build: `8.6.5-dev.11752245814.1253279a`

---------

Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
This commit is contained in:
Brandy Smith
2025-07-11 15:19:50 -04:00
committed by GitHub
parent 9b0099f462
commit 8b4023d520
2 changed files with 209 additions and 48 deletions

View File

@ -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();
};
/**

View File

@ -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(`<ion-input-otp>Description</ion-input-otp>`, 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(`<ion-input-otp type="text">Description</ion-input-otp>`, 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(`<ion-input-otp value="12">Description</ion-input-otp>`, 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(`<ion-input-otp value="12">Description</ion-input-otp>`, 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(`<ion-input-otp>Description</ion-input-otp>`, 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(`<ion-input-otp length="2">Description</ion-input-otp>`, 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(`<ion-input-otp length="2">Description</ion-input-otp>`, 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(`<ion-input-otp>Description</ion-input-otp>`, config);