Skip to content

Commit 344c72a

Browse files
committed
feat(input-otp): expose reset and setFocus methods
1 parent 618575d commit 344c72a

File tree

7 files changed

+172
-2
lines changed

7 files changed

+172
-2
lines changed

core/api.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,8 @@ ion-input-otp,prop,shape,"rectangular" | "round" | "soft",'round',false,false
793793
ion-input-otp,prop,size,"large" | "medium" | "small",'medium',false,false
794794
ion-input-otp,prop,type,"number" | "text",'number',false,false
795795
ion-input-otp,prop,value,null | number | string | undefined,'',false,false
796+
ion-input-otp,method,reset,reset() => Promise<void>
797+
ion-input-otp,method,setFocus,setFocus(index?: number) => Promise<void>
796798
ion-input-otp,event,ionBlur,FocusEvent,true
797799
ion-input-otp,event,ionChange,InputOtpChangeEventDetail,true
798800
ion-input-otp,event,ionComplete,InputOtpCompleteEventDetail,true

core/src/components.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1350,10 +1350,19 @@ export namespace Components {
13501350
* If `true`, the user cannot modify the value.
13511351
*/
13521352
"readonly": boolean;
1353+
/**
1354+
* Resets the input values and focus state.
1355+
*/
1356+
"reset": () => Promise<void>;
13531357
/**
13541358
* Where separators should be shown between input boxes. Can be a comma-separated string or an array of numbers. For example: `"3"` will show a separator after the 3rd input box. `[1,4]` will show a separator after the 1st and 4th input boxes. `"all"` will show a separator between every input box.
13551359
*/
13561360
"separators"?: 'all' | string | number[];
1361+
/**
1362+
* Sets focus to an input box.
1363+
* @param index The index of the input box to focus. If not provided, focuses the first empty input box or the last input if all are filled. The input boxes start at index 0.
1364+
*/
1365+
"setFocus": (index?: number) => Promise<void>;
13571366
/**
13581367
* The shape of the input boxes. If "round" they will have an increased border radius. If "rectangular" they will have no border radius. If "soft" they will have a soft border radius.
13591368
*/

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Component, Element, Event, Host, Prop, State, h, Watch } from '@stencil
33
import { printIonWarning } from '@utils/logging';
44
import { isRTL } from '@utils/rtl';
55
import { createColorClasses } from '@utils/theme';
6+
import { Method } from 'ionicons/dist/types/stencil-public-runtime';
67

78
import { getIonMode } from '../../global/ionic-global';
89
import type { Color } from '../../interface';
@@ -240,6 +241,41 @@ export class InputOTP implements ComponentInterface {
240241
}
241242
}
242243

244+
/**
245+
* Resets the input values and focus state.
246+
*/
247+
@Method()
248+
async reset() {
249+
this.inputValues = Array(this.length).fill('');
250+
this.value = '';
251+
252+
this.focusedValue = null;
253+
this.hasFocus = false;
254+
255+
this.inputRefs.forEach((input) => {
256+
input.blur();
257+
});
258+
259+
this.updateTabIndexes();
260+
}
261+
262+
/**
263+
* Sets focus to an input box.
264+
* @param index The index of the input box to focus. If not provided,
265+
* focuses the first empty input box or the last input if all are filled.
266+
* The input boxes start at index 0.
267+
*/
268+
@Method()
269+
async setFocus(index?: number) {
270+
if (typeof index === 'number') {
271+
const validIndex = Math.max(0, Math.min(index, this.length - 1));
272+
this.inputRefs[validIndex]?.focus();
273+
} else {
274+
const tabbableIndex = this.getTabbableIndex();
275+
this.inputRefs[tabbableIndex]?.focus();
276+
}
277+
}
278+
243279
/**
244280
* Get the regex pattern for allowed characters.
245281
* If a pattern is provided, use it to create a regex pattern

core/src/components/input-otp/test/basic/index.html

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
grid-row-gap: 20px;
2525
grid-column-gap: 20px;
2626
}
27+
28+
button {
29+
margin: 8px 2px !important;
30+
}
2731
</style>
2832
</head>
2933

@@ -75,6 +79,15 @@ <h2>Readonly</h2>
7579
<h2>Invalid</h2>
7680
<ion-input-otp class="ion-invalid">Description</ion-input-otp>
7781
<ion-input-otp fill="solid" class="ion-invalid">Description</ion-input-otp>
82+
83+
<h2>Methods</h2>
84+
<ion-input-otp id="inputOtpMethods" value="1234">Description</ion-input-otp>
85+
<div style="display: flex; justify-content: center">
86+
<button id="reset">Reset</button>
87+
<button id="focus">Focus</button>
88+
<button id="focus-third">Focus Third</button>
89+
<button id="focus-and-reset">Focus and Reset</button>
90+
</div>
7891
</div>
7992
</div>
8093
</ion-content>
@@ -112,6 +125,23 @@ <h2>Invalid</h2>
112125
document.addEventListener('ionBlur', (ev) => {
113126
console.log('ionBlur', ev);
114127
});
128+
129+
document.getElementById('reset').addEventListener('click', () => {
130+
inputOtpMethods.reset();
131+
});
132+
133+
document.getElementById('focus').addEventListener('click', () => {
134+
inputOtpMethods.setFocus();
135+
});
136+
137+
document.getElementById('focus-third').addEventListener('click', () => {
138+
inputOtpMethods.setFocus(2);
139+
});
140+
141+
document.getElementById('focus-and-reset').addEventListener('click', () => {
142+
inputOtpMethods.setFocus();
143+
inputOtpMethods.reset();
144+
});
115145
</script>
116146
</ion-app>
117147
</body>

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

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,3 +744,94 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
744744
});
745745
});
746746
});
747+
748+
/**
749+
* Methods are the same across modes & directions
750+
*/
751+
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
752+
test.describe(title('input-otp: setFocus method'), () => {
753+
test('should focus the specified input box when index is provided', async ({ page }) => {
754+
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
755+
756+
const inputOtp = page.locator('ion-input-otp');
757+
await inputOtp.evaluate((el: HTMLIonInputOtpElement) => {
758+
el.setFocus(2);
759+
});
760+
761+
const thirdInput = page.locator('ion-input-otp input').nth(2);
762+
await expect(thirdInput).toBeFocused();
763+
});
764+
765+
test('should focus first empty input when no index is provided and not all inputs are filled', async ({ page }) => {
766+
await page.setContent(`<ion-input-otp value="12">Description</ion-input-otp>`, config);
767+
768+
const inputOtp = page.locator('ion-input-otp');
769+
await inputOtp.evaluate((el: HTMLIonInputOtpElement) => {
770+
el.setFocus();
771+
});
772+
773+
const thirdInput = page.locator('ion-input-otp input').nth(2);
774+
await expect(thirdInput).toBeFocused();
775+
});
776+
777+
test('should focus last input when no index is provided and all inputs are filled', async ({ page }) => {
778+
await page.setContent(`<ion-input-otp value="1234">Description</ion-input-otp>`, config);
779+
780+
const inputOtp = page.locator('ion-input-otp');
781+
await inputOtp.evaluate((el: HTMLIonInputOtpElement) => {
782+
el.setFocus();
783+
});
784+
785+
const lastInput = page.locator('ion-input-otp input').last();
786+
await expect(lastInput).toBeFocused();
787+
});
788+
789+
test('should clamp invalid indices to valid range', async ({ page }) => {
790+
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
791+
792+
const inputOtp = page.locator('ion-input-otp');
793+
794+
// Test negative index
795+
await inputOtp.evaluate((el: HTMLIonInputOtpElement) => {
796+
el.setFocus(-1);
797+
});
798+
const firstInput = page.locator('ion-input-otp input').first();
799+
await expect(firstInput).toBeFocused();
800+
801+
// Test index beyond length
802+
await inputOtp.evaluate((el: HTMLIonInputOtpElement) => {
803+
el.setFocus(10);
804+
});
805+
const lastInput = page.locator('ion-input-otp input').last();
806+
await expect(lastInput).toBeFocused();
807+
});
808+
});
809+
810+
test.describe(title('input-otp: reset method'), () => {
811+
test('should clear all input values and reset focus state', async ({ page }) => {
812+
await page.setContent(`<ion-input-otp value="1234">Description</ion-input-otp>`, config);
813+
814+
const inputOtp = page.locator('ion-input-otp');
815+
const inputBoxes = page.locator('ion-input-otp input');
816+
817+
// Focus an input first
818+
await inputBoxes.nth(2).focus();
819+
await expect(inputBoxes.nth(2)).toBeFocused();
820+
821+
// Call reset
822+
await inputOtp.evaluate((el: HTMLIonInputOtpElement) => {
823+
el.reset();
824+
});
825+
826+
// Check that values are cleared
827+
await expect(inputOtp).toHaveJSProperty('value', '');
828+
await expect(inputBoxes.nth(0)).toHaveValue('');
829+
await expect(inputBoxes.nth(1)).toHaveValue('');
830+
await expect(inputBoxes.nth(2)).toHaveValue('');
831+
await expect(inputBoxes.nth(3)).toHaveValue('');
832+
833+
// Check that focus is removed
834+
await expect(inputBoxes.nth(2)).not.toBeFocused();
835+
});
836+
});
837+
});

packages/angular/src/directives/proxies.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1018,7 +1018,8 @@ This event will not emit when programmatically setting the `value` property.
10181018

10191019

10201020
@ProxyCmp({
1021-
inputs: ['color', 'disabled', 'fill', 'inputmode', 'length', 'pattern', 'readonly', 'separators', 'shape', 'size', 'type', 'value']
1021+
inputs: ['color', 'disabled', 'fill', 'inputmode', 'length', 'pattern', 'readonly', 'separators', 'shape', 'size', 'type', 'value'],
1022+
methods: ['reset', 'setFocus']
10221023
})
10231024
@Component({
10241025
selector: 'ion-input-otp',

packages/angular/standalone/src/directives/proxies.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -984,7 +984,8 @@ export declare interface IonInfiniteScrollContent extends Components.IonInfinite
984984

985985
@ProxyCmp({
986986
defineCustomElementFn: defineIonInputOtp,
987-
inputs: ['color', 'disabled', 'fill', 'inputmode', 'length', 'pattern', 'readonly', 'separators', 'shape', 'size', 'type', 'value']
987+
inputs: ['color', 'disabled', 'fill', 'inputmode', 'length', 'pattern', 'readonly', 'separators', 'shape', 'size', 'type', 'value'],
988+
methods: ['reset', 'setFocus']
988989
})
989990
@Component({
990991
selector: 'ion-input-otp',

0 commit comments

Comments
 (0)