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);