feat(input-otp): add new input-otp component (#30386)

Adds a new component `ion-input-otp` which provides the OTP input functionality

- Displays as an input group with multiple boxes accepting a single character
- Accepts `type` which determines whether the boxes accept numbers or text/numbers and determines the keyboard to display
- Supports changing the displayed keyboard using the `inputmode` property
- Accepts a `length` property to control the number of input boxes
- Accepts the following properties to change the design: `fill`, `shape`, `size`, `color`
- Accepts a `separators` property to show a separator between 1 or more input boxes
- Supports the `disabled`, `readonly` and invalid states
- Supports limiting the accepted input via the `pattern` property
- Emits the following events: `ionInput`, `ionChange`, `ionComplete`, `ionBlur`, `ionFocus`
- Exposes the following method: `setFocus`

---------

Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
Co-authored-by: Shane <shane@shanessite.net>
This commit is contained in:
Brandy Smith
2025-05-29 15:10:37 -04:00
committed by GitHub
parent 2dea6071db
commit 4d6a067677
275 changed files with 4452 additions and 79 deletions

View File

@@ -7,6 +7,8 @@ describe('Form', () => {
it('should update Ionic form classes when calling form methods programmatically', async () => {
cy.get('#input-touched').click();
cy.get('#touched-input-test').should('have.class', 'ion-touched');
cy.get('#input-otp-touched').click();
cy.get('#touched-input-otp-number-test').should('have.class', 'ion-touched');
});
describe('markAllAsTouched', () => {
@@ -32,6 +34,9 @@ describe('Form', () => {
input2: 'Default Value',
inputMin: 1,
inputMax: 1,
inputOtp: null,
inputOtpText: '',
inputOtp2: 1234,
checkbox: false,
radio: null
});
@@ -41,6 +46,15 @@ describe('Form', () => {
cy.get('ion-input.required').type('Some value');
cy.get('ion-input.required input').blur();
// Test number OTP input
cy.get('#touched-input-otp-number-test input').first().type('5678');
cy.get('#touched-input-otp-number-test input').last().focus().blur();
// Test text OTP input
cy.get('#touched-input-otp-text-test input').first().type('ABCD');
cy.get('#touched-input-otp-text-test input').last().focus().blur();
testStatus('INVALID');
cy.get('ion-select').click();
@@ -60,6 +74,9 @@ describe('Form', () => {
input2: 'Default Value',
inputMin: 1,
inputMax: 1,
inputOtp: 5678,
inputOtpText: 'ABCD',
inputOtp2: 1234,
checkbox: false,
radio: null
});
@@ -75,6 +92,9 @@ describe('Form', () => {
input2: 'Default Value',
inputMin: 1,
inputMax: 1,
inputOtp: null,
inputOtpText: '',
inputOtp2: 1234,
checkbox: false,
radio: null
});
@@ -90,6 +110,9 @@ describe('Form', () => {
input2: 'Default Value',
inputMin: 1,
inputMax: 1,
inputOtp: null,
inputOtpText: '',
inputOtp2: 1234,
checkbox: true,
radio: null
});
@@ -105,8 +128,36 @@ describe('Form', () => {
input2: 'Default Value',
inputMin: 1,
inputMax: 1,
inputOtp: null,
inputOtpText: '',
inputOtp2: 1234,
checkbox: false,
radio: 'nes'
radio: 'nes',
});
});
it('ion-input-otp should change', () => {
// Test number OTP input
cy.get('#touched-input-otp-number-test input').first().type('5678');
cy.get('#touched-input-otp-number-test input').last().focus().blur();
// Test text OTP input
cy.get('#touched-input-otp-text-test input').first().type('ABCD');
cy.get('#touched-input-otp-text-test input').last().focus().blur();
testData({
datetime: '2010-08-20',
select: null,
toggle: false,
input: '',
input2: 'Default Value',
inputMin: 1,
inputMax: 1,
inputOtp: 5678,
inputOtpText: 'ABCD',
inputOtp2: 1234,
checkbox: false,
radio: null
});
});
@@ -115,6 +166,38 @@ describe('Form', () => {
cy.get('#submit-button').click();
cy.get('#submit').should('have.text', 'true');
});
it('ion-input-otp should validate both number and text types', () => {
// Test number OTP validation
cy.get('#touched-input-otp-number-test').should('have.class', 'ng-invalid');
cy.get('#touched-input-otp-number-test input').first().type('5678');
cy.get('#touched-input-otp-number-test input').last().focus().blur();
cy.get('#touched-input-otp-number-test').should('have.class', 'ng-valid');
// Test text OTP validation
cy.get('#touched-input-otp-text-test').should('have.class', 'ng-invalid');
cy.get('#touched-input-otp-text-test input').first().type('ABCD');
cy.get('#touched-input-otp-text-test input').last().focus().blur();
cy.get('#touched-input-otp-text-test').should('have.class', 'ng-valid');
});
// Add test for partial OTP input validation
it('ion-input-otp should remain invalid when partially filled', () => {
// Test number OTP with only first digit
cy.get('#touched-input-otp-number-test').should('have.class', 'ng-invalid');
cy.get('#touched-input-otp-number-test input').first().type('5');
cy.get('#touched-input-otp-number-test input').eq(1).focus().blur();
cy.get('#touched-input-otp-number-test').should('have.class', 'ng-invalid');
// Test text OTP with only first character
cy.get('#touched-input-otp-text-test').should('have.class', 'ng-invalid');
cy.get('#touched-input-otp-text-test input').first().type('A');
cy.get('#touched-input-otp-text-test input').eq(1).focus().blur();
cy.get('#touched-input-otp-text-test').should('have.class', 'ng-invalid');
// Verify form status is still invalid
testStatus('INVALID');
});
});
describe('blur', () => {
@@ -128,6 +211,9 @@ describe('Form', () => {
input2: 'Default Value',
inputMin: 1,
inputMax: 1,
inputOtp: null,
inputOtpText: '',
inputOtp2: 1234,
checkbox: false,
radio: null
});
@@ -140,6 +226,9 @@ describe('Form', () => {
input2: 'Default Value',
inputMin: 1,
inputMax: 1,
inputOtp: null,
inputOtpText: '',
inputOtp2: 1234,
checkbox: true,
radio: null
});

View File

@@ -3,17 +3,18 @@ describe('Inputs', () => {
cy.visit('/lazy/inputs');
})
it('should have default value', () => {
it('should have default values', () => {
cy.get('ion-checkbox').should('have.prop', 'checked').and('equal', true);
cy.get('ion-radio-group').should('have.prop', 'value').and('equal', 'nes');
cy.get('ion-toggle').should('have.prop', 'checked').and('equal', true);
cy.get('ion-input').should('have.prop', 'value').and('equal', 'some text');
cy.get('ion-input-otp').should('have.prop', 'value').and('equal', '1234');
cy.get('ion-datetime').should('have.prop', 'value').and('equal', '1994-03-15');
cy.get('ion-select').should('have.prop', 'value').and('equal', 'nes');
cy.get('ion-range').should('have.prop', 'value').and('equal', 50);
});
it('should have reset value', () => {
it('should reset values', () => {
cy.get('#reset-button').click();
cy.get('ion-checkbox').should('have.prop', 'checked').and('equal', false);
@@ -26,12 +27,13 @@ describe('Inputs', () => {
* value property is undefined.
*/
cy.get('ion-input').should('not.have.prop', 'value');
cy.get('ion-input-otp').should('not.have.prop', 'value');
cy.get('ion-datetime').should('not.have.prop', 'value');
cy.get('ion-select').should('not.have.prop', 'value');
cy.get('ion-range').should('not.have.prop', 'value');
});
it('should get some value', () => {
it('should set values', () => {
cy.get('#reset-button').click();
cy.get('#set-button').click();
@@ -39,12 +41,13 @@ describe('Inputs', () => {
cy.get('ion-radio-group').should('have.prop', 'value').and('equal', 'nes');
cy.get('ion-toggle').should('have.prop', 'checked').and('equal', true);
cy.get('ion-input').should('have.prop', 'value').and('equal', 'some text');
cy.get('ion-input-otp').should('have.prop', 'value').and('equal', '1234');
cy.get('ion-datetime').should('have.prop', 'value').and('equal', '1994-03-15');
cy.get('ion-select').should('have.prop', 'value').and('equal', 'nes');
cy.get('ion-range').should('have.prop', 'value').and('equal', 50);
});
it('change values should update angular', () => {
it('should update angular when values change', () => {
cy.get('#reset-button').click();
cy.get('ion-checkbox#first-checkbox').click();
@@ -52,7 +55,10 @@ describe('Inputs', () => {
cy.get('ion-toggle').first().click();
cy.get('ion-input').eq(0).type('hola');
cy.get('ion-input input').eq(0).blur();
cy.focused().blur();
cy.get('ion-input-otp input').eq(0).type('1234');
cy.focused().blur();
// Set date to 1994-03-14
cy.get('ion-datetime').first().shadow().find('.calendar-day:not([disabled])').first().click();
@@ -68,7 +74,18 @@ describe('Inputs', () => {
cy.get('#radio-note').should('have.text', 'nes');
cy.get('#toggle-note').should('have.text', 'true');
cy.get('#input-note').should('have.text', 'hola');
cy.get('#input-otp-note').should('have.text', '1234');
cy.get('#datetime-note').should('have.text', '1994-03-14');
cy.get('#select-note').should('have.text', 'ps');
});
it('should update values when erasing input', () => {
cy.get('ion-input').eq(0).type('{backspace}');
cy.get('ion-input').should('have.prop', 'value').and('equal', 'some tex');
cy.get('#input-note').should('have.text', 'some tex');
cy.get('ion-input-otp input:last').eq(0).type('{backspace}');
cy.get('ion-input-otp').should('have.prop', 'value').and('equal', '123');
cy.get('#input-otp-note').should('have.text', '123');
});
});

View File

@@ -1,4 +1,3 @@
describe('Value Accessors', () => {
describe('Checkbox', () => {
@@ -147,4 +146,79 @@ describe('Value Accessors', () => {
});
});
describe('Input OTP', () => {
beforeEach(() => cy.visit('/standalone/value-accessors/input-otp'));
it('should update the form value', () => {
cy.get('#formValue').should('have.text', JSON.stringify({
inputOtpString: '',
inputOtpNumber: ''
}, null, 2));
cy.get('ion-input-otp[formControlName="inputOtpString"]').should('have.class', 'ion-pristine');
cy.get('ion-input-otp[formControlName="inputOtpNumber"]').should('have.class', 'ion-pristine');
cy.get('ion-input-otp[formControlName="inputOtpString"]').should('have.class', 'ion-invalid');
cy.get('ion-input-otp[formControlName="inputOtpNumber"]').should('have.class', 'ion-invalid');
// Type into the string OTP input
cy.get('ion-input-otp[formControlName="inputOtpString"] input').eq(0).type('a');
cy.get('ion-input-otp[formControlName="inputOtpString"] input').eq(1).type('b');
cy.get('ion-input-otp[formControlName="inputOtpString"] input').eq(2).type('c');
cy.get('ion-input-otp[formControlName="inputOtpString"] input').eq(3).type('d');
cy.get('ion-input-otp[formControlName="inputOtpString"] input').eq(3).blur();
// Type into the number OTP input
cy.get('ion-input-otp[formControlName="inputOtpNumber"] input').eq(0).type('1');
cy.get('ion-input-otp[formControlName="inputOtpNumber"] input').eq(1).type('2');
cy.get('ion-input-otp[formControlName="inputOtpNumber"] input').eq(2).type('3');
cy.get('ion-input-otp[formControlName="inputOtpNumber"] input').eq(3).type('4');
cy.get('ion-input-otp[formControlName="inputOtpNumber"] input').eq(3).blur();
cy.get('#formValue').should('have.text', JSON.stringify({
inputOtpString: 'abcd',
inputOtpNumber: 1234
}, null, 2));
cy.get('ion-input-otp[formControlName="inputOtpString"]').should('have.class', 'ion-dirty');
cy.get('ion-input-otp[formControlName="inputOtpNumber"]').should('have.class', 'ion-dirty');
cy.get('ion-input-otp[formControlName="inputOtpString"]').should('have.class', 'ion-valid');
cy.get('ion-input-otp[formControlName="inputOtpNumber"]').should('have.class', 'ion-valid');
});
it('should remain invalid when partially filled', () => {
cy.get('#formValue').should('have.text', JSON.stringify({
inputOtpString: '',
inputOtpNumber: ''
}, null, 2));
cy.get('ion-input-otp[formControlName="inputOtpString"]').should('have.class', 'ion-pristine');
cy.get('ion-input-otp[formControlName="inputOtpNumber"]').should('have.class', 'ion-pristine');
cy.get('ion-input-otp[formControlName="inputOtpString"]').should('have.class', 'ion-invalid');
cy.get('ion-input-otp[formControlName="inputOtpNumber"]').should('have.class', 'ion-invalid');
// Type only 2 characters into the string OTP input
cy.get('ion-input-otp[formControlName="inputOtpString"] input').eq(0).type('a');
cy.get('ion-input-otp[formControlName="inputOtpString"] input').eq(1).type('b');
cy.get('ion-input-otp[formControlName="inputOtpString"] input').eq(2).blur();
// Type only 2 characters into the number OTP input
cy.get('ion-input-otp[formControlName="inputOtpNumber"] input').eq(0).type('1');
cy.get('ion-input-otp[formControlName="inputOtpNumber"] input').eq(1).type('2');
cy.get('ion-input-otp[formControlName="inputOtpNumber"] input').eq(2).blur();
cy.get('#formValue').should('have.text', JSON.stringify({
inputOtpString: 'ab',
inputOtpNumber: 12
}, null, 2));
cy.get('ion-input-otp[formControlName="inputOtpString"]').should('have.class', 'ion-dirty');
cy.get('ion-input-otp[formControlName="inputOtpNumber"]').should('have.class', 'ion-dirty');
// Verify both inputs remain invalid when partially filled
cy.get('ion-input-otp[formControlName="inputOtpString"]').should('have.class', 'ion-invalid');
cy.get('ion-input-otp[formControlName="inputOtpNumber"]').should('have.class', 'ion-invalid');
});
});
});

View File

@@ -44,6 +44,20 @@
<ion-input label="Input" formControlName="input2"></ion-input>
</ion-item>
<ion-item>
<ion-input-otp id="touched-input-otp-number-test" formControlName="inputOtp" class="required">Input OTP (required)</ion-input-otp>
</ion-item>
<ion-button id="input-otp-touched" (click)="setOtpTouched()">Set Input OTP Touched</ion-button>
<ion-item>
<ion-input-otp id="touched-input-otp-text-test" type="text" formControlName="inputOtpText" class="required">Input OTP Text (required)</ion-input-otp>
</ion-item>
<ion-item>
<ion-input-otp formControlName="inputOtp2">Input OTP</ion-input-otp>
</ion-item>
<ion-item>
<ion-checkbox formControlName="checkbox"> Checkbox </ion-checkbox>
</ion-item>

View File

@@ -1,6 +1,15 @@
import { Component } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, UntypedFormControl } from '@angular/forms';
import { UntypedFormGroup, UntypedFormBuilder, Validators, UntypedFormControl, AbstractControl, ValidationErrors } from '@angular/forms';
function otpRequiredLength(length: number) {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value;
if (!value || value.toString().length !== length) {
return { otpLength: true };
}
return null;
};
}
@Component({
selector: 'app-form',
templateUrl: './form.component.html',
@@ -19,6 +28,9 @@ export class FormComponent {
toggle: [false],
input: ['', Validators.required],
input2: ['Default Value'],
inputOtp: [null, [Validators.required, otpRequiredLength(4)]],
inputOtpText: ['', [Validators.required, otpRequiredLength(4)]],
inputOtp2: [1234],
inputMin: [1, Validators.min(1)],
inputMax: [1, Validators.max(1)],
checkbox: [false],
@@ -35,6 +47,13 @@ export class FormComponent {
}
}
setOtpTouched() {
const formControl = this.profileForm.get('inputOtp');
if (formControl) {
formControl.markAsTouched();
}
}
onSubmit() {
this.submitted = 'true';
}
@@ -46,6 +65,9 @@ export class FormComponent {
toggle: true,
input: 'Some value',
input2: 'Another values',
inputOtp: 5678,
inputOtpText: 'ABCD',
inputOtp2: 1234,
checkbox: true,
radio: 'nes'
});

View File

@@ -71,6 +71,16 @@
<ion-note slot="end">{{input}}</ion-note>
</ion-item>
<ion-item>
<ion-input-otp [(ngModel)]="inputOtp">Input OTP</ion-input-otp>
<ion-note slot="end" id="input-otp-note">{{inputOtp}}</ion-note>
</ion-item>
<ion-item color="dark">
<ion-input-otp [(ngModel)]="inputOtp">Input OTP Mirror</ion-input-otp>
<ion-note slot="end">{{inputOtp}}</ion-note>
</ion-item>
<ion-item>
<ion-checkbox [(ngModel)]="checkbox" slot="start" id="first-checkbox">
Checkbox

View File

@@ -9,6 +9,7 @@ export class InputsComponent {
datetime? = '1994-03-15';
input? = 'some text';
inputOtp? = '1234';
checkbox = true;
radio? = 'nes';
toggle = true;
@@ -20,6 +21,7 @@ export class InputsComponent {
console.log('set values');
this.datetime = '1994-03-15';
this.input = 'some text';
this.inputOtp = '1234';
this.checkbox = true;
this.radio = 'nes';
this.toggle = true;
@@ -31,6 +33,7 @@ export class InputsComponent {
console.log('reset values');
this.datetime = undefined;
this.input = undefined;
this.inputOtp = undefined;
this.checkbox = false;
this.radio = undefined;
this.toggle = false;

View File

@@ -44,6 +44,7 @@ export const routes: Routes = [
{ path: 'checkbox', loadComponent: () => import('../value-accessors/checkbox/checkbox.component').then(c => c.CheckboxComponent) },
{ path: 'datetime', loadComponent: () => import('../value-accessors/datetime/datetime.component').then(c => c.DatetimeComponent) },
{ path: 'input', loadComponent: () => import('../value-accessors/input/input.component').then(c => c.InputComponent) },
{ path: 'input-otp', loadComponent: () => import('../value-accessors/input-otp/input-otp.component').then(c => c.InputOtpComponent) },
{ path: 'radio-group', loadComponent: () => import('../value-accessors/radio-group/radio-group.component').then(c => c.RadioGroupComponent) },
{ path: 'range', loadComponent: () => import('../value-accessors/range/range.component').then(c => c.RangeComponent) },
{ path: 'searchbar', loadComponent: () => import('../value-accessors/searchbar/searchbar.component').then(c => c.SearchbarComponent) },

View File

@@ -116,6 +116,11 @@
Input Test
</ion-label>
</ion-item>
<ion-item routerLink="/standalone/value-accessors/input-otp">
<ion-label>
Input OTP Test
</ion-label>
</ion-item>
<ion-item routerLink="/standalone/value-accessors/radio-group">
<ion-label>
Radio Group Test

View File

@@ -0,0 +1,11 @@
<div>
<h1>IonInputOtp Value Accessors</h1>
<p>
This test checks the form integrations with ion-input-otp to make sure values are correctly assigned to the form group.
</p>
<app-value-accessor-test [formGroup]="form">
<ion-input-otp type="text" formControlName="inputOtpString">String</ion-input-otp>
<ion-input-otp type="number" formControlName="inputOtpNumber">Number</ion-input-otp>
</app-value-accessor-test>
</div>

View File

@@ -0,0 +1,34 @@
import { Component } from "@angular/core";
import { FormBuilder, FormsModule, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from "@angular/forms";
import { IonInputOtp } from "@ionic/angular/standalone";
import { ValueAccessorTestComponent } from "../value-accessor-test/value-accessor-test.component";
function otpRequiredLength(length: number) {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value;
if (!value || value.toString().length !== length) {
return { otpLength: true };
}
return null;
};
}
@Component({
selector: 'app-input-otp',
templateUrl: 'input-otp.component.html',
standalone: true,
imports: [
IonInputOtp,
ReactiveFormsModule,
FormsModule,
ValueAccessorTestComponent
]
})
export class InputOtpComponent {
form = this.fb.group({
inputOtpString: ['', [Validators.required, otpRequiredLength(4)]],
inputOtpNumber: ['', [Validators.required, otpRequiredLength(4)]],
});
constructor(private fb: FormBuilder) { }
}

View File

@@ -1,41 +1,38 @@
#!/bin/bash
# The directory where the source
# for each specific application is.
# Directory containing test application versions
# (e.g. ng18, ng19, etc.)
APPS_DIR="apps"
# The directory where the
# base application logic is
# Base application template that all test apps are built from
BASE_DIR="base"
# Output directory for built applications
BUILD_DIR="build"
# The specific application
# we are building
# Application version to build (passed as first argument)
APP_DIR="${1}"
# The full path to the specific application.
# Full paths for source and destination
FULL_APP_DIR="${APPS_DIR}/${APP_DIR}/."
# The full path to the base application.
FULL_BASE_DIR="${BASE_DIR}/."
# The full path to the built application.
BUILD_APP_DIR="${BUILD_DIR}/${APP_DIR}/"
# Make sure the full app directory exists.
# Verify application version exists
if [ ! -d $FULL_APP_DIR ]; then
echo "Could not find test app: ${FULL_APP_DIR}"
exit 1
fi
# Make the build directory if it does not already exist.
# Create build directory if needed
mkdir -p $BUILD_DIR
# First we need to copy the base application
# Copy base template first
echo "Copying base application..."
cp -R $FULL_BASE_DIR $BUILD_APP_DIR
# Then we can copy the specific app which
# will override any files in the base application.
# Copy version-specific files (overrides base template)
echo "Copying application version..."
cp -R $FULL_APP_DIR $BUILD_APP_DIR
echo "Copied test app files for ${APP_DIR}"