diff --git a/core/src/components/input-otp/input-otp.tsx b/core/src/components/input-otp/input-otp.tsx index 1c84b3c9a42..4c51e004f2c 100644 --- a/core/src/components/input-otp/input-otp.tsx +++ b/core/src/components/input-otp/input-otp.tsx @@ -548,7 +548,8 @@ export class InputOTP implements ComponentInterface { * Handles keyboard navigation for the OTP component. * * Navigation: - * - Backspace: Clears current input and moves to previous box if empty + * - Backspace: Clears current input, or clears the previous box and moves + * focus to it if the current input is empty * - Arrow Left/Right: Moves focus between input boxes * - Tab: Allows normal tab navigation between components */ @@ -597,8 +598,18 @@ export class InputOTP implements ComponentInterface { this.updateValue(event); event.preventDefault(); } else if (!this.inputValues[index] && index > 0) { - // If current input is empty, move to previous input + // If current input is empty, clear the previous input and move + // focus to it. The default action is prevented because browsers do + // not agree on which box it applies to: desktop browsers delete from + // the newly focused box, but the iOS keyboard deletes from the box + // that had focus when the key was pressed, which is empty. + const previousIndex = index - 1; + this.inputValues[previousIndex] = ''; + this.inputRefs[previousIndex].value = ''; + this.previousInputValues = [...this.inputValues]; + this.updateValue(event); this.focusPrevious(index); + event.preventDefault(); } } else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { this.isKeyboardNavigation = true; diff --git a/core/src/components/input-otp/test/input-otp.spec.ts b/core/src/components/input-otp/test/input-otp.spec.ts new file mode 100644 index 00000000000..d13e3715c24 --- /dev/null +++ b/core/src/components/input-otp/test/input-otp.spec.ts @@ -0,0 +1,48 @@ +import { newSpecPage } from '@stencil/core/testing'; + +import { InputOTP } from '../input-otp'; + +describe('input-otp: backspace', () => { + it('should clear the previous box when Backspace is pressed in an empty box', async () => { + const page = await newSpecPage({ + components: [InputOTP], + html: `Description`, + }); + + const inputOtp = page.body.querySelector('ion-input-otp')!; + const inputs = inputOtp.querySelectorAll('input'); + // The mock document fires focus events even for an element that already + // has focus, which makes onFocus recurse, so stub focus() instead. + inputs.forEach((input) => (input.focus = jest.fn())); + const ionInput = jest.fn(); + inputOtp.addEventListener('ionInput', (ev) => ionInput((ev as CustomEvent).detail.value)); + + // Box 4 is empty. The test environment does not apply the browser's default + // Backspace action, like the iOS keyboard, so the component has to clear box 3 itself. + const event = new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true, cancelable: true }); + inputs[3].dispatchEvent(event); + await page.waitForChanges(); + + expect(inputs[2].value).toBe(''); + expect(inputOtp.value).toBe('78'); + expect(ionInput).toHaveBeenCalledWith('78'); + expect(event.defaultPrevented).toBe(true); + expect(inputs[2].focus).toHaveBeenCalled(); + }); + + it('should not change the value when Backspace is pressed in the first, empty box', async () => { + const page = await newSpecPage({ + components: [InputOTP], + html: `Description`, + }); + + const inputOtp = page.body.querySelector('ion-input-otp')!; + const inputs = inputOtp.querySelectorAll('input'); + inputs.forEach((input) => (input.focus = jest.fn())); + + inputs[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true, cancelable: true })); + await page.waitForChanges(); + + expect(inputOtp.value).toBe(''); + }); +});