diff --git a/core/src/components/input-otp/input-otp.tsx b/core/src/components/input-otp/input-otp.tsx index 3e6cc3855b2..a93eabd926d 100644 --- a/core/src/components/input-otp/input-otp.tsx +++ b/core/src/components/input-otp/input-otp.tsx @@ -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; - - // If the value is longer than 1 character (autofill), split it into - // characters and filter out invalid ones - if (value.length > 1) { + const input = event.target as HTMLInputElement; + const value = input.value; + const previousValue = this.previousInputValues[index] || ''; + + // 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(); }; /** diff --git a/core/src/components/input-otp/test/basic/input-otp.e2e.ts b/core/src/components/input-otp/test/basic/input-otp.e2e.ts index 2a50c1abd5c..2067a000209 100644 --- a/core/src/components/input-otp/test/basic/input-otp.e2e.ts +++ b/core/src/components/input-otp/test/basic/input-otp.e2e.ts @@ -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(`Description`, 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(`Description`, 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(`Description`, 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(`Description`, 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(`Description`, 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(`Description`, 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(`Description`, 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(`Description`, config);