fix(input-otp): correctly handle autofill by splitting the values into all inputs (#30444)

Properly handle autofill by detecting when the input value exceeds one character in the `onInput` handler and distributing the value across all input fields.

---------

Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
This commit is contained in:
Brandy Smith
2025-06-03 09:59:03 -04:00
committed by GitHub
parent 4d6a067677
commit b77447bea0
2 changed files with 154 additions and 29 deletions

View File

@ -544,12 +544,14 @@ export class InputOTP implements ComponentInterface {
const rtl = isRTL(this.el);
const input = event.target as HTMLInputElement;
const isPasteShortcut = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'v';
// Meta shortcuts are used to copy, paste, and select text
// We don't want to handle these keys here
const metaShortcuts = ['a', 'c', 'v', 'x', 'r', 'z', 'y'];
const isTextSelection = input.selectionStart !== input.selectionEnd;
// Return if the key is the paste shortcut or the input value
// Return if the key is a meta shortcut or the input value
// text is selected and let the onPaste / onInput handler manage it
if (isPasteShortcut || isTextSelection) {
if (isTextSelection || ((event.metaKey || event.ctrlKey) && metaShortcuts.includes(event.key.toLowerCase()))) {
return;
}
@ -615,39 +617,57 @@ export class InputOTP implements ComponentInterface {
};
private onInput = (index: number) => (event: InputEvent) => {
const { validKeyPattern } = this;
const { length, validKeyPattern } = this;
const value = (event.target as HTMLInputElement).value;
// Only allow input if it's a single character and matches the pattern
if (value.length > 1 || (value.length > 0 && !validKeyPattern.test(value))) {
// Reset the input value if not valid
// If the value is longer than 1 character (autofill), split it into
// characters and filter out invalid ones
if (value.length > 1) {
const validChars = value
.split('')
.filter((char) => validKeyPattern.test(char))
.slice(0, length);
// If there are no valid characters coming from the
// autofill, all input refs have to be cleared after the
// browser has finished the autofill behavior
if (validChars.length === 0) {
requestAnimationFrame(() => {
this.inputRefs.forEach((input) => {
input.value = '';
});
});
}
// Update the value of the input group and emit the input change event
this.value = validChars.join('');
this.updateValue(event);
// Focus the first empty input box or the last input box if all boxes
// are filled after a small delay to ensure the input boxes have been
// updated before moving the focus
setTimeout(() => {
const nextIndex = validChars.length < length ? validChars.length : length - 1;
this.inputRefs[nextIndex]?.focus();
}, 20);
return;
}
// Only allow input if it matches the pattern
if (value.length > 0 && !validKeyPattern.test(value)) {
this.inputRefs[index].value = '';
this.inputValues[index] = '';
return;
}
// Find the first empty box before or at the current index
let targetIndex = index;
for (let i = 0; i < index; i++) {
if (!this.inputValues[i] || this.inputValues[i] === '') {
targetIndex = i;
break;
}
}
// Set the value at the target index
this.inputValues[targetIndex] = value;
// If the value was entered in a later box, clear the current box
if (targetIndex !== index) {
this.inputRefs[index].value = '';
}
// For single character input, fill the current box
this.inputValues[index] = value;
this.updateValue(event);
if (value.length > 0) {
this.focusNext(targetIndex);
this.focusNext(index);
}
this.updateValue(event);
};
/**
@ -754,13 +774,12 @@ export class InputOTP implements ComponentInterface {
type="text"
autoCapitalize={autocapitalize}
inputmode={inputmode}
maxLength={1}
pattern={pattern}
disabled={disabled}
readOnly={readonly}
tabIndex={index === tabbableIndex ? 0 : -1}
value={inputValues[index] || ''}
autocomplete={index === 0 ? 'one-time-code' : 'off'}
autocomplete="one-time-code"
ref={(el) => (inputRefs[index] = el as HTMLInputElement)}
onInput={this.onInput(index)}
onBlur={this.onBlur}

View File

@ -2,6 +2,16 @@ import { expect } from '@playwright/test';
import type { Locator } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
/**
* Simulates an autofill event in an input element with the given value
*/
async function simulateAutofill(input: any, value: string) {
await input.evaluate((input: any, value: string) => {
(input as HTMLInputElement).value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
}, value);
}
/**
* Simulates a paste event in an input element with the given value
*/
@ -334,7 +344,10 @@ configs({ modes: ['ios'] }).forEach(({ title, config }) => {
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('أبجد123');
// We need to type the numbers separately because the browser
// does not properly handle the script text when mixed with numbers
await page.keyboard.type('123');
await page.keyboard.type('أبجد');
// Because Arabic is a right-to-left script, JavaScript's handling of RTL text
// causes the array values to be reversed while input boxes maintain LTR order.
@ -431,6 +444,87 @@ configs({ modes: ['ios'] }).forEach(({ title, config }) => {
});
});
test.describe(title('input-otp: autofill functionality'), () => {
test('should handle autofill correctly', 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, '1234');
const inputOtp = page.locator('ion-input-otp');
await verifyInputValues(inputOtp, ['1', '2', '3', '4']);
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);
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await simulateAutofill(firstInput, '123456');
const inputOtp = page.locator('ion-input-otp');
await verifyInputValues(inputOtp, ['1', '2', '3', '4']);
const lastInput = page.locator('ion-input-otp input').last();
await expect(lastInput).toBeFocused();
});
test('should handle autofill correctly when it is less than the length', 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, '12');
const inputOtp = page.locator('ion-input-otp');
await verifyInputValues(inputOtp, ['1', '2', '', '']);
const thirdInput = page.locator('ion-input-otp input').nth(2);
await expect(thirdInput).toBeFocused();
});
test('should handle autofill correctly when using autofill after typing 1 character', 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 page.keyboard.type('9');
const secondInput = page.locator('ion-input-otp input').nth(1);
await secondInput.focus();
await simulateAutofill(secondInput, '1234');
const inputOtp = page.locator('ion-input-otp');
await verifyInputValues(inputOtp, ['1', '2', '3', '4']);
const lastInput = page.locator('ion-input-otp input').last();
await expect(lastInput).toBeFocused();
});
test('should handle autofill correctly when autofill value contains invalid characters', async ({ page }) => {
await page.setContent(`<ion-input-otp pattern="[a-zA-Z]">Description</ion-input-otp>`, config);
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await simulateAutofill(firstInput, '1234');
const inputOtp = page.locator('ion-input-otp');
await verifyInputValues(inputOtp, ['', '', '', '']);
await expect(firstInput).toBeFocused();
});
});
test.describe(title('input-otp: focus functionality'), () => {
test('should focus the first input box when tabbed to', async ({ page }) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
@ -614,6 +708,18 @@ configs({ modes: ['ios'] }).forEach(({ title, config }) => {
await verifyInputValues(inputOtp, ['1', '2', '3', '4']);
});
test('should paste mixed language text into all input boxes', async ({ page }) => {
await page.setContent(`<ion-input-otp type="text" length="6">Description</ion-input-otp>`, config);
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await simulatePaste(firstInput, 'أبجد123');
const inputOtp = page.locator('ion-input-otp');
await verifyInputValues(inputOtp, ['أ', 'ب', 'ج', 'د', '1', '2']);
});
});
});