mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-07 23:16:52 +08:00
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:
@ -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}
|
||||
|
||||
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user