Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 47 additions & 28 deletions core/src/components/input-otp/input-otp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
};

/**
Expand Down Expand Up @@ -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}
Expand Down
108 changes: 107 additions & 1 deletion core/src/components/input-otp/test/basic/input-otp.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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']);
});
});
});

Expand Down
Loading