Skip to content

Commit a7d14f2

Browse files
committed
fix(input-otp): don't allow focus in boxes without values except last
1 parent f0fba1d commit a7d14f2

File tree

3 files changed

+73
-8
lines changed

3 files changed

+73
-8
lines changed

core/src/components.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1360,7 +1360,7 @@ export namespace Components {
13601360
"separators"?: 'all' | string | number[];
13611361
/**
13621362
* Sets focus to an input box.
1363-
* @param index The index of the input box to focus. If not provided, focuses the first empty input box or the last input if all are filled. The input boxes start at index 0.
1363+
* @param index - The index of the input box to focus (0-based). If provided and the input box has a value, the input box at that index will be focused. Otherwise, the first empty input box or the last input if all are filled will be focused.
13641364
*/
13651365
"setFocus": (index?: number) => Promise<void>;
13661366
/**

core/src/components/input-otp/input-otp.tsx

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ export class InputOTP implements ComponentInterface {
3232
*/
3333
private focusedValue?: string | number | null;
3434

35+
/**
36+
* Tracks whether the user is navigating through input boxes using keyboard navigation
37+
* (arrow keys, tab) versus mouse clicks. This is used to determine the appropriate
38+
* focus behavior when an input box is focused.
39+
*/
40+
private isKeyboardNavigation = false;
41+
3542
@Element() el!: HTMLIonInputOtpElement;
3643

3744
@State() private inputValues: string[] = [];
@@ -261,9 +268,9 @@ export class InputOTP implements ComponentInterface {
261268

262269
/**
263270
* Sets focus to an input box.
264-
* @param index The index of the input box to focus. If not provided,
265-
* focuses the first empty input box or the last input if all are filled.
266-
* The input boxes start at index 0.
271+
* @param index - The index of the input box to focus (0-based).
272+
* If provided and the input box has a value, the input box at that index will be focused.
273+
* Otherwise, the first empty input box or the last input if all are filled will be focused.
267274
*/
268275
@Method()
269276
async setFocus(index?: number) {
@@ -406,6 +413,13 @@ export class InputOTP implements ComponentInterface {
406413
const { length } = this;
407414
const rtl = isRTL(this.el);
408415

416+
// Do not process the paste shortcut to avoid changing
417+
// the value to the letter "v" on paste
418+
const isPasteShortcut = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'v';
419+
if (isPasteShortcut) {
420+
return;
421+
}
422+
409423
if (event.key === 'Backspace') {
410424
if (this.inputValues[index]) {
411425
// Remove the value at the current index
@@ -431,6 +445,7 @@ export class InputOTP implements ComponentInterface {
431445
this.focusPrevious(index);
432446
}
433447
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
448+
this.isKeyboardNavigation = true;
434449
event.preventDefault();
435450
const isLeft = event.key === 'ArrowLeft';
436451
const shouldMoveNext = (isLeft && rtl) || (!isLeft && !rtl);
@@ -444,6 +459,7 @@ export class InputOTP implements ComponentInterface {
444459
this.focusPrevious(index);
445460
}
446461
} else if (event.key === 'Tab') {
462+
this.isKeyboardNavigation = true;
447463
// Let all tab events proceed normally
448464
return;
449465
}
@@ -551,6 +567,13 @@ export class InputOTP implements ComponentInterface {
551567

552568
/**
553569
* Handles the focus behavior for the input OTP component.
570+
*
571+
* Focus behavior:
572+
* 1. Keyboard navigation: Allow normal focus movement
573+
* 2. Mouse click:
574+
* - If clicked box has value: Focus that box
575+
* - If clicked box is empty: Focus first empty box
576+
*
554577
* Emits the `ionFocus` event when the input group gains focus.
555578
*/
556579
private onFocus = (index: number) => (event: FocusEvent) => {
@@ -563,10 +586,25 @@ export class InputOTP implements ComponentInterface {
563586
}
564587
this.hasFocus = true;
565588

566-
// When an input receives focus, make it the only tabbable element
589+
let finalIndex = index;
590+
591+
if (!this.isKeyboardNavigation) {
592+
// If the clicked box has a value, focus it
593+
// Otherwise focus the first empty box
594+
const targetIndex = this.inputValues[index] ? index : this.getFirstEmptyIndex();
595+
finalIndex = targetIndex === -1 ? this.length - 1 : targetIndex;
596+
597+
// Focus the target box
598+
this.inputRefs[finalIndex]?.focus();
599+
}
600+
601+
// Update tabIndexes to match the focused box
567602
inputRefs.forEach((input, i) => {
568-
input.tabIndex = i === index ? 0 : -1;
603+
input.tabIndex = i === finalIndex ? 0 : -1;
569604
});
605+
606+
// Reset the keyboard navigation flag
607+
this.isKeyboardNavigation = false;
570608
};
571609

572610
/**

core/src/components/input-otp/test/basic/input-otp.e2e.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,21 @@ configs({ modes: ['ios'] }).forEach(({ title, config }) => {
324324
const secondInputOtpFirstInput = page.locator('#second input').first();
325325
await expect(secondInputOtpFirstInput).toBeFocused();
326326
});
327+
328+
test('should focus the first input box when clicking on the 2nd input box without a value', async ({ page }) => {
329+
await page.setContent(
330+
`
331+
<ion-input-otp>Description</ion-input-otp>
332+
`,
333+
config
334+
);
335+
336+
const secondInput = page.locator('ion-input-otp input').nth(1);
337+
await secondInput.click();
338+
339+
const firstInput = page.locator('ion-input-otp input').first();
340+
await expect(firstInput).toBeFocused();
341+
});
327342
});
328343

329344
test.describe(title('input-otp: backspace functionality'), () => {
@@ -750,14 +765,26 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
750765
*/
751766
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
752767
test.describe(title('input-otp: setFocus method'), () => {
753-
test('should focus the specified input box when index is provided', async ({ page }) => {
768+
test('should not focus the specified input box when index is provided and value is not set', async ({ page }) => {
754769
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
755770

756771
const inputOtp = page.locator('ion-input-otp');
757772
await inputOtp.evaluate((el: HTMLIonInputOtpElement) => {
758773
el.setFocus(2);
759774
});
760775

776+
const thirdInput = page.locator('ion-input-otp input').nth(2);
777+
await expect(thirdInput).not.toBeFocused();
778+
});
779+
780+
test('should focus the specified input box when index is provided and value is set', async ({ page }) => {
781+
await page.setContent(`<ion-input-otp value="1234">Description</ion-input-otp>`, config);
782+
783+
const inputOtp = page.locator('ion-input-otp');
784+
await inputOtp.evaluate((el: HTMLIonInputOtpElement) => {
785+
el.setFocus(2);
786+
});
787+
761788
const thirdInput = page.locator('ion-input-otp input').nth(2);
762789
await expect(thirdInput).toBeFocused();
763790
});
@@ -787,7 +814,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
787814
});
788815

789816
test('should clamp invalid indices to valid range', async ({ page }) => {
790-
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
817+
await page.setContent(`<ion-input-otp value="1234">Description</ion-input-otp>`, config);
791818

792819
const inputOtp = page.locator('ion-input-otp');
793820

0 commit comments

Comments
 (0)