Skip to content

Commit 81aa977

Browse files
fix(input-otp): prevent deletion and paste when disabled or readonly (#30983)
Closes #30913 ## Description When `ion-input-otp` has the `readonly` prop set, typing is correctly blocked but users are still able to delete characters using the Backspace or Delete keys. When `disabled` or `readonly` are set, users are still able to paste into the component. ## Changes - Added guard for `disabled` and `readonly` inside `onInput` and `onPaste` handler - Prevented default behavior for `Backspace` and `Delete` when `readonly` is `true` - Return in `onKeydown` when `disabled` is `true` - Added e2e tests verifying the behavior ## How to Test 1. Add `readonly` to `ion-input-otp` 2. Attempt to type → no input allowed 3. Press Backspace/Delete → no characters removed Behavior now matches expected readonly semantics. --------- Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
1 parent 00666a5 commit 81aa977

File tree

2 files changed

+84
-3
lines changed

2 files changed

+84
-3
lines changed

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -535,10 +535,21 @@ export class InputOTP implements ComponentInterface {
535535
* - Tab: Allows normal tab navigation between components
536536
*/
537537
private onKeyDown = (index: number) => (event: KeyboardEvent) => {
538-
const { length } = this;
538+
const { disabled, length, readonly } = this;
539539
const rtl = isRTL(this.el);
540540
const input = event.target as HTMLInputElement;
541541

542+
if (disabled) {
543+
return;
544+
}
545+
546+
if (readonly) {
547+
if (event.key === 'Backspace' || event.key === 'Delete') {
548+
event.preventDefault();
549+
return;
550+
}
551+
}
552+
542553
// Meta shortcuts are used to copy, paste, and select text
543554
// We don't want to handle these keys here
544555
const metaShortcuts = ['a', 'c', 'v', 'x', 'r', 'z', 'y'];
@@ -603,11 +614,15 @@ export class InputOTP implements ComponentInterface {
603614
* 5. Single character replacement
604615
*/
605616
private onInput = (index: number) => (event: InputEvent) => {
606-
const { length, validKeyPattern } = this;
617+
const { disabled, length, readonly, validKeyPattern } = this;
607618
const input = event.target as HTMLInputElement;
608619
const value = input.value;
609620
const previousValue = this.previousInputValues[index] || '';
610621

622+
if (disabled || readonly) {
623+
return;
624+
}
625+
611626
// 1. Autofill handling
612627
// If the length of the value increases by more than 1 from the previous
613628
// value, treat this as autofill. This is to prevent the case where the
@@ -735,10 +750,14 @@ export class InputOTP implements ComponentInterface {
735750
* the next empty input after pasting.
736751
*/
737752
private onPaste = (event: ClipboardEvent) => {
738-
const { inputRefs, length, validKeyPattern } = this;
753+
const { disabled, inputRefs, length, readonly, validKeyPattern } = this;
739754

740755
event.preventDefault();
741756

757+
if (disabled || readonly) {
758+
return;
759+
}
760+
742761
const pastedText = event.clipboardData?.getData('text');
743762

744763
// If there is no pasted text, still emit the input change event

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,31 @@ configs({ modes: ['ios'] }).forEach(({ title, config }) => {
687687
const firstInput = page.locator('ion-input-otp input').first();
688688
await expect(firstInput).toBeFocused();
689689
});
690+
691+
test('should allow arrow key navigation when readonly is true', async ({ page }) => {
692+
await page.setContent(`<ion-input-otp value="1234" readonly>Description</ion-input-otp>`, config);
693+
694+
const inputOtp = page.locator('ion-input-otp');
695+
const inputBoxes = page.locator('ion-input-otp input');
696+
await inputBoxes.nth(1).focus();
697+
698+
const isRTL = await page.evaluate(() => document.dir === 'rtl');
699+
if (isRTL) {
700+
await page.keyboard.press('ArrowLeft');
701+
await expect(inputBoxes.nth(2)).toBeFocused();
702+
703+
await page.keyboard.press('ArrowRight');
704+
await expect(inputBoxes.nth(1)).toBeFocused();
705+
} else {
706+
await page.keyboard.press('ArrowRight');
707+
await expect(inputBoxes.nth(2)).toBeFocused();
708+
709+
await page.keyboard.press('ArrowLeft');
710+
await expect(inputBoxes.nth(1)).toBeFocused();
711+
}
712+
713+
await verifyInputValues(inputOtp, ['1', '2', '3', '4']);
714+
});
690715
});
691716

692717
test.describe(title('input-otp: backspace functionality'), () => {
@@ -737,6 +762,21 @@ configs({ modes: ['ios'] }).forEach(({ title, config }) => {
737762
const inputOtp = page.locator('ion-input-otp');
738763
await verifyInputValues(inputOtp, ['1', '3', '', '']);
739764
});
765+
766+
test('should not modify values with backspace or delete when readonly is true', async ({ page }) => {
767+
await page.setContent(`<ion-input-otp value="1234" readonly>Description</ion-input-otp>`, config);
768+
769+
const inputOtp = page.locator('ion-input-otp');
770+
const ionInput = await page.spyOnEvent('ionInput');
771+
772+
await page.keyboard.press('Tab');
773+
await page.keyboard.press('Backspace');
774+
await page.keyboard.press('Delete');
775+
await page.keyboard.type('5');
776+
777+
await verifyInputValues(inputOtp, ['1', '2', '3', '4']);
778+
await expect(ionInput).not.toHaveReceivedEvent();
779+
});
740780
});
741781

742782
test.describe(title('input-otp: paste functionality'), () => {
@@ -828,6 +868,28 @@ configs({ modes: ['ios'] }).forEach(({ title, config }) => {
828868

829869
await verifyInputValues(inputOtp, ['أ', 'ب', 'ج', 'د', '1', '2']);
830870
});
871+
872+
test('should not paste text when disabled is true', async ({ page }) => {
873+
await page.setContent(`<ion-input-otp value="1234" disabled>Description</ion-input-otp>`, config);
874+
875+
const firstInput = page.locator('ion-input-otp input').first();
876+
await firstInput.focus();
877+
await simulatePaste(firstInput, '5678');
878+
879+
const inputOtp = page.locator('ion-input-otp');
880+
await verifyInputValues(inputOtp, ['1', '2', '3', '4']);
881+
});
882+
883+
test('should not paste text when readonly is true', async ({ page }) => {
884+
await page.setContent(`<ion-input-otp value="1234" readonly>Description</ion-input-otp>`, config);
885+
886+
const firstInput = page.locator('ion-input-otp input').first();
887+
await firstInput.focus();
888+
await simulatePaste(firstInput, '5678');
889+
890+
const inputOtp = page.locator('ion-input-otp');
891+
await verifyInputValues(inputOtp, ['1', '2', '3', '4']);
892+
});
831893
});
832894
});
833895

0 commit comments

Comments
 (0)