From 725e10607bf397d5c2d7b5c4ea7a713f4ce321c6 Mon Sep 17 00:00:00 2001
From: Hemanth Darapureddy <41389351+hemanth004@users.noreply.github.com>
Date: Fri, 25 Sep 2026 15:25:57 +0530
Subject: [PATCH 1/4] fix(input-otp): sync value when length changes
---
core/src/components/input-otp/input-otp.tsx | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/core/src/components/input-otp/input-otp.tsx b/core/src/components/input-otp/input-otp.tsx
index 7c46ca59d42..2d741855f5a 100644
--- a/core/src/components/input-otp/input-otp.tsx
+++ b/core/src/components/input-otp/input-otp.tsx
@@ -195,6 +195,11 @@ export class InputOTP implements ComponentInterface {
valueChanged() {
this.initializeValues();
this.updateTabIndexes();
+ @Watch('length')
+ lengthChanged() {
+ this.initializeValues();
+ this.updateTabIndexes();
+ }
}
/**
From 35c625c2b3322af19e0e33091011344e67057272 Mon Sep 17 00:00:00 2001
From: Hemanth Darapureddy <41389351+hemanth004@users.noreply.github.com>
Date: Fri, 25 Sep 2026 15:28:57 +0530
Subject: [PATCH 2/4] test(input-otp): cover runtime length changes
---
.../src/components/input-otp/test/basic/input-otp.e2e.ts | 9 +++++++++
1 file changed, 9 insertions(+)
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 306e72f873d..89aa5745ce0 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
@@ -1,3 +1,12 @@
+ test('should synchronize the value when length changes', async ({ page }) => {
+ await page.setContent(`Description`, config);
+
+ const inputOtp = page.locator('ion-input-otp');
+ await inputOtp.evaluate((el) => el.setAttribute('length', '2'));
+
+ await verifyInputValues(inputOtp, ['1', '2']);
+ });
+
import { expect } from '@playwright/test';
import type { Locator } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
From 1b8b18b9e80615e1b964c3169a4e9d5719301198 Mon Sep 17 00:00:00 2001
From: Hemanth Darapureddy <41389351+hemanth004@users.noreply.github.com>
Date: Fri, 25 Sep 2026 15:31:06 +0530
Subject: [PATCH 3/4] test(input-otp): place runtime length regression coverage
---
.../input-otp/test/basic/input-otp.e2e.ts | 18 +++++++++---------
1 file changed, 9 insertions(+), 9 deletions(-)
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 89aa5745ce0..4a9da89b2e6 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
@@ -1,12 +1,3 @@
- test('should synchronize the value when length changes', async ({ page }) => {
- await page.setContent(`Description`, config);
-
- const inputOtp = page.locator('ion-input-otp');
- await inputOtp.evaluate((el) => el.setAttribute('length', '2'));
-
- await verifyInputValues(inputOtp, ['1', '2']);
- });
-
import { expect } from '@playwright/test';
import type { Locator } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
@@ -80,6 +71,15 @@ configs({ modes: ['ios'] }).forEach(({ title, config }) => {
await verifyInputValues(inputOtp, ['1', '2', '3', '4', '5', '6', '7', '8']);
});
+ test('should synchronize the value when length changes', async ({ page }) => {
+ await page.setContent(`Description`, config);
+
+ const inputOtp = page.locator('ion-input-otp');
+ await inputOtp.evaluate((el) => el.setAttribute('length', '2'));
+
+ await verifyInputValues(inputOtp, ['1', '2']);
+ });
+
test('should accept numbers only by default', async ({ page }) => {
await page.setContent(`Description`, config);
From e24c0de9d6235d1bdeb64421cc3dacdf33807e22 Mon Sep 17 00:00:00 2001
From: ShaneK
Date: Thu, 1 Oct 2026 06:29:47 -0700
Subject: [PATCH 4/4] fix(input-otp): update tab indexes and refs after length
changes
---
core/src/components/input-otp/input-otp.tsx | 29 ++++++++--
.../input-otp/test/a11y/input-otp.e2e.ts | 21 +++++++
.../input-otp/test/basic/input-otp.e2e.ts | 56 ++++++++++++++++++-
3 files changed, 101 insertions(+), 5 deletions(-)
diff --git a/core/src/components/input-otp/input-otp.tsx b/core/src/components/input-otp/input-otp.tsx
index 2d741855f5a..1c84b3c9a42 100644
--- a/core/src/components/input-otp/input-otp.tsx
+++ b/core/src/components/input-otp/input-otp.tsx
@@ -43,6 +43,12 @@ export class InputOTP implements ComponentInterface {
*/
private isKeyboardNavigation = false;
+ /**
+ * The `length` watcher runs before new inputs render, so
+ * `componentDidRender` updates their tab indexes instead.
+ */
+ private updateTabIndexesAfterRender = false;
+
@Element() el!: HTMLIonInputOtpElement;
@State() private inputValues: string[] = [];
@@ -195,11 +201,12 @@ export class InputOTP implements ComponentInterface {
valueChanged() {
this.initializeValues();
this.updateTabIndexes();
- @Watch('length')
+ }
+
+ @Watch('length')
lengthChanged() {
this.initializeValues();
- this.updateTabIndexes();
- }
+ this.updateTabIndexesAfterRender = true;
}
/**
@@ -277,6 +284,13 @@ export class InputOTP implements ComponentInterface {
this.updateTabIndexes();
}
+ componentDidRender() {
+ if (this.updateTabIndexesAfterRender) {
+ this.updateTabIndexesAfterRender = false;
+ this.updateTabIndexes();
+ }
+ }
+
/**
* Get the regex pattern for allowed characters.
* If a pattern is provided, use it to create a regex pattern
@@ -856,7 +870,14 @@ export class InputOTP implements ComponentInterface {
tabIndex={index === tabbableIndex ? 0 : -1}
value={inputValues[index] || ''}
autocomplete="one-time-code"
- ref={(el) => (inputRefs[index] = el as HTMLInputElement)}
+ ref={(el) => {
+ if (el) {
+ inputRefs[index] = el as HTMLInputElement;
+ } else {
+ // The input was removed, so drop its ref and any after it.
+ inputRefs.splice(index);
+ }
+ }}
onInput={this.onInput(index)}
onBlur={this.onBlur}
onFocus={this.onFocus(index)}
diff --git a/core/src/components/input-otp/test/a11y/input-otp.e2e.ts b/core/src/components/input-otp/test/a11y/input-otp.e2e.ts
index 88b0be04800..7cc5374d8ef 100644
--- a/core/src/components/input-otp/test/a11y/input-otp.e2e.ts
+++ b/core/src/components/input-otp/test/a11y/input-otp.e2e.ts
@@ -46,6 +46,27 @@ configs().forEach(({ title, config }) => {
await expect(inputBoxes.nth(3)).toHaveAttribute('aria-hidden', 'true');
});
+ test('should update aria-hidden when length increases', async ({ page }, testInfo) => {
+ testInfo.annotations.push({
+ type: 'issue',
+ description: 'https://github.com/ionic-team/ionic-framework/issues/31484',
+ });
+
+ await page.setContent(``, config);
+
+ const inputOtp = page.locator('ion-input-otp');
+ await inputOtp.evaluate((el: HTMLIonInputOtpElement) => {
+ el.length = 4;
+ });
+
+ const inputBoxes = page.locator('ion-input-otp input');
+
+ await expect(inputBoxes.nth(0)).toHaveAttribute('aria-hidden', 'false');
+ await expect(inputBoxes.nth(1)).toHaveAttribute('aria-hidden', 'true');
+ await expect(inputBoxes.nth(2)).toHaveAttribute('aria-hidden', 'true');
+ await expect(inputBoxes.nth(3)).toHaveAttribute('aria-hidden', 'true');
+ });
+
test('should update aria-hidden when typing a value', async ({ page }) => {
await page.setContent(``, config);
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 4a9da89b2e6..a135f502f0a 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
@@ -71,7 +71,12 @@ configs({ modes: ['ios'] }).forEach(({ title, config }) => {
await verifyInputValues(inputOtp, ['1', '2', '3', '4', '5', '6', '7', '8']);
});
- test('should synchronize the value when length changes', async ({ page }) => {
+ test('should synchronize the value when length changes', async ({ page }, testInfo) => {
+ testInfo.annotations.push({
+ type: 'issue',
+ description: 'https://github.com/ionic-team/ionic-framework/issues/31484',
+ });
+
await page.setContent(`Description`, config);
const inputOtp = page.locator('ion-input-otp');
@@ -80,6 +85,25 @@ configs({ modes: ['ios'] }).forEach(({ title, config }) => {
await verifyInputValues(inputOtp, ['1', '2']);
});
+ test('should add empty input boxes when length increases', async ({ page }, testInfo) => {
+ testInfo.annotations.push({
+ type: 'issue',
+ description: 'https://github.com/ionic-team/ionic-framework/issues/31484',
+ });
+
+ await page.setContent(`Description`, config);
+
+ const inputOtp = page.locator('ion-input-otp');
+ await inputOtp.evaluate((el: HTMLIonInputOtpElement) => {
+ el.length = 4;
+ });
+
+ const inputBoxes = page.locator('ion-input-otp input');
+ await expect(inputBoxes).toHaveCount(4);
+
+ await verifyInputValues(inputOtp, ['1', '2', '', '']);
+ });
+
test('should accept numbers only by default', async ({ page }) => {
await page.setContent(`Description`, config);
@@ -1042,6 +1066,36 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
await expect(ionChange).toHaveReceivedEventTimes(1);
});
+ test('should emit ionChange event when blurring with a new value after length decreases', async ({
+ page,
+ }, testInfo) => {
+ testInfo.annotations.push({
+ type: 'issue',
+ description: 'https://github.com/ionic-team/ionic-framework/issues/31484',
+ });
+
+ await page.setContent(`Description`, config);
+
+ const ionChange = await page.spyOnEvent('ionChange');
+
+ const inputOtp = page.locator('ion-input-otp');
+ await inputOtp.evaluate((el: HTMLIonInputOtpElement) => {
+ el.length = 2;
+ });
+ await page.waitForChanges();
+
+ const firstInput = page.locator('ion-input-otp input').first();
+ await firstInput.focus();
+
+ await page.keyboard.type('12');
+
+ // Click outside the input to trigger the blur event
+ await page.mouse.click(0, 0);
+
+ await ionChange.next();
+ await expect(ionChange).toHaveReceivedEventDetail({ value: '12', event: { isTrusted: true } });
+ });
+
test('should not emit ionChange event when blurring with the same value', async ({ page }) => {
await page.setContent(`Description`, config);