mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-08 07:41:51 +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 rtl = isRTL(this.el);
|
||||||
const input = event.target as HTMLInputElement;
|
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;
|
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
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -615,39 +617,57 @@ export class InputOTP implements ComponentInterface {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private onInput = (index: number) => (event: InputEvent) => {
|
private onInput = (index: number) => (event: InputEvent) => {
|
||||||
const { validKeyPattern } = this;
|
const { length, validKeyPattern } = this;
|
||||||
|
|
||||||
const value = (event.target as HTMLInputElement).value;
|
const value = (event.target as HTMLInputElement).value;
|
||||||
|
|
||||||
// Only allow input if it's a single character and matches the pattern
|
// If the value is longer than 1 character (autofill), split it into
|
||||||
if (value.length > 1 || (value.length > 0 && !validKeyPattern.test(value))) {
|
// characters and filter out invalid ones
|
||||||
// Reset the input value if not valid
|
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.inputRefs[index].value = '';
|
||||||
this.inputValues[index] = '';
|
this.inputValues[index] = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the first empty box before or at the current index
|
// For single character input, fill the current box
|
||||||
let targetIndex = index;
|
this.inputValues[index] = value;
|
||||||
for (let i = 0; i < index; i++) {
|
this.updateValue(event);
|
||||||
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 = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.length > 0) {
|
if (value.length > 0) {
|
||||||
this.focusNext(targetIndex);
|
this.focusNext(index);
|
||||||
}
|
}
|
||||||
this.updateValue(event);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -754,13 +774,12 @@ export class InputOTP implements ComponentInterface {
|
|||||||
type="text"
|
type="text"
|
||||||
autoCapitalize={autocapitalize}
|
autoCapitalize={autocapitalize}
|
||||||
inputmode={inputmode}
|
inputmode={inputmode}
|
||||||
maxLength={1}
|
|
||||||
pattern={pattern}
|
pattern={pattern}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
readOnly={readonly}
|
readOnly={readonly}
|
||||||
tabIndex={index === tabbableIndex ? 0 : -1}
|
tabIndex={index === tabbableIndex ? 0 : -1}
|
||||||
value={inputValues[index] || ''}
|
value={inputValues[index] || ''}
|
||||||
autocomplete={index === 0 ? 'one-time-code' : 'off'}
|
autocomplete="one-time-code"
|
||||||
ref={(el) => (inputRefs[index] = el as HTMLInputElement)}
|
ref={(el) => (inputRefs[index] = el as HTMLInputElement)}
|
||||||
onInput={this.onInput(index)}
|
onInput={this.onInput(index)}
|
||||||
onBlur={this.onBlur}
|
onBlur={this.onBlur}
|
||||||
|
|||||||
@ -2,6 +2,16 @@ import { expect } from '@playwright/test';
|
|||||||
import type { Locator } from '@playwright/test';
|
import type { Locator } from '@playwright/test';
|
||||||
import { configs, test } from '@utils/test/playwright';
|
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
|
* 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();
|
const firstInput = page.locator('ion-input-otp input').first();
|
||||||
await firstInput.focus();
|
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
|
// 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.
|
// 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.describe(title('input-otp: focus functionality'), () => {
|
||||||
test('should focus the first input box when tabbed to', async ({ page }) => {
|
test('should focus the first input box when tabbed to', async ({ page }) => {
|
||||||
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
|
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']);
|
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