diff --git a/core/src/components/input-otp/input-otp.tsx b/core/src/components/input-otp/input-otp.tsx index 55c52d6ee91..3e6cc3855b2 100644 --- a/core/src/components/input-otp/input-otp.tsx +++ b/core/src/components/input-otp/input-otp.tsx @@ -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 - 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; + // 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 = ''; + }); + }); } - } - // Set the value at the target index - this.inputValues[targetIndex] = 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); - // If the value was entered in a later box, clear the current box - if (targetIndex !== index) { + return; + } + + // Only allow input if it matches the pattern + if (value.length > 0 && !validKeyPattern.test(value)) { this.inputRefs[index].value = ''; + this.inputValues[index] = ''; + return; } + // 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} 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 3dfb627589a..2a50c1abd5c 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 @@ -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(`Description`, 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(`Description`, 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(`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 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(`Description`, 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(`Description`, 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(`Description`, 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(`Description`, 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']); + }); }); });