mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-08 15:51:16 +08:00
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:
@ -48,6 +48,7 @@ export class InputOTP implements ComponentInterface {
|
|||||||
|
|
||||||
@State() private inputValues: string[] = [];
|
@State() private inputValues: string[] = [];
|
||||||
@State() hasFocus = false;
|
@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.
|
* 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
|
// Update the value without emitting events
|
||||||
this.value = this.inputValues.join('');
|
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:
|
* Navigation:
|
||||||
* - Backspace: Clears current input and moves to previous box if empty
|
* - Backspace: Clears current input and moves to previous box if empty
|
||||||
* - Arrow Left/Right: Moves focus between input boxes
|
* - Arrow Left/Right: Moves focus between input boxes
|
||||||
* - Tab: Allows normal tab navigation between components
|
* - 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) => {
|
private onKeyDown = (index: number) => (event: KeyboardEvent) => {
|
||||||
const { length } = this;
|
const { length } = this;
|
||||||
@ -595,34 +590,32 @@ export class InputOTP implements ComponentInterface {
|
|||||||
// Let all tab events proceed normally
|
// Let all tab events proceed normally
|
||||||
return;
|
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) => {
|
private onInput = (index: number) => (event: InputEvent) => {
|
||||||
const { length, validKeyPattern } = this;
|
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
|
// 1. Autofill handling
|
||||||
// characters and filter out invalid ones
|
// If the length of the value increases by more than 1 from the previous
|
||||||
if (value.length > 1) {
|
// 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
|
const validChars = value
|
||||||
.split('')
|
.split('')
|
||||||
.filter((char) => validKeyPattern.test(char))
|
.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
|
for (let i = 0; i < length; i++) {
|
||||||
this.value = validChars.join('');
|
this.inputValues[i] = validChars[i] || '';
|
||||||
|
this.inputRefs[i].value = validChars[i] || '';
|
||||||
|
}
|
||||||
this.updateValue(event);
|
this.updateValue(event);
|
||||||
|
|
||||||
// Focus the first empty input box or the last input box if all boxes
|
// 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();
|
this.inputRefs[nextIndex]?.focus();
|
||||||
}, 20);
|
}, 20);
|
||||||
|
|
||||||
|
this.previousInputValues = [...this.inputValues];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow input if it matches the pattern
|
// 2. Input validation
|
||||||
if (value.length > 0 && !validKeyPattern.test(value)) {
|
// If the character entered is invalid (does not match the pattern),
|
||||||
this.inputRefs[index].value = '';
|
// restore the previous value and exit
|
||||||
this.inputValues[index] = '';
|
if (value.length > 0 && !validKeyPattern.test(value[value.length - 1])) {
|
||||||
|
input.value = this.inputValues[index] || '';
|
||||||
|
this.previousInputValues = [...this.inputValues];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For single character input, fill the current box
|
// 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;
|
this.inputValues[index] = value;
|
||||||
|
input.value = value;
|
||||||
this.updateValue(event);
|
this.updateValue(event);
|
||||||
|
|
||||||
if (value.length > 0) {
|
|
||||||
this.focusNext(index);
|
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
|
// Focus the next empty input after pasting
|
||||||
// If all boxes are filled, focus the last input
|
// If all boxes are filled, focus the last input
|
||||||
const nextEmptyIndex = validChars.length;
|
const nextEmptyIndex = validChars.length < length ? validChars.length : length - 1;
|
||||||
if (nextEmptyIndex < length) {
|
|
||||||
inputRefs[nextEmptyIndex]?.focus();
|
inputRefs[nextEmptyIndex]?.focus();
|
||||||
} else {
|
|
||||||
inputRefs[length - 1]?.focus();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -442,6 +442,67 @@ configs({ modes: ['ios'] }).forEach(({ title, config }) => {
|
|||||||
|
|
||||||
await verifyInputValues(inputOtp, ['1', '9', '3', '']);
|
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'), () => {
|
test.describe(title('input-otp: autofill functionality'), () => {
|
||||||
@ -460,6 +521,53 @@ configs({ modes: ['ios'] }).forEach(({ title, config }) => {
|
|||||||
await expect(lastInput).toBeFocused();
|
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 }) => {
|
test('should handle autofill correctly when it exceeds the length', async ({ page }) => {
|
||||||
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
|
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user