diff --git a/core/src/components/input-otp/input-otp.tsx b/core/src/components/input-otp/input-otp.tsx index 7c46ca59d42..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[] = []; @@ -197,6 +203,12 @@ export class InputOTP implements ComponentInterface { this.updateTabIndexes(); } + @Watch('length') + lengthChanged() { + this.initializeValues(); + this.updateTabIndexesAfterRender = true; + } + /** * Processes the separators prop into an array of numbers. * @@ -272,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 @@ -851,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 306e72f873d..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,6 +71,39 @@ 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 }, 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) => el.setAttribute('length', '2')); + + 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); @@ -1033,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);