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

@ -0,0 +1,23 @@
/**
* Values are converted to strings when emitted which is
* why we do not have a `number` type here even though the
* `value` prop accepts a `number` type.
*/
export interface InputOtpInputEventDetail {
value?: string | null;
event?: Event;
}
export interface InputOtpChangeEventDetail {
value?: string | null;
event?: Event;
}
export interface InputOtpCompleteEventDetail {
value?: string | null;
event?: Event;
}
export interface InputOtpCustomEvent<T = InputOtpChangeEventDetail> extends CustomEvent {
detail: T;
target: HTMLIonInputOtpElement;
}

View File

@ -0,0 +1,20 @@
@import "./input-otp";
@import "../../themes/ionic.globals.ios";
// iOS Input OTP
// --------------------------------------------------
:host {
--border-width: #{$hairlines-width};
}
:host(.has-focus) .native-input:focus {
--border-width: 1px;
}
// Fills
// --------------------------------------------------
:host(.input-otp-fill-outline) {
--border-color: #{$item-ios-border-color};
}

View File

@ -0,0 +1,20 @@
@import "./input-otp";
@import "../../themes/ionic.globals.md";
// Material Design Input OTP
// --------------------------------------------------
:host {
--border-width: 1px;
}
:host(.has-focus) .native-input:focus {
--border-width: 2px;
}
// Fills
// --------------------------------------------------
:host(.input-otp-fill-outline) {
--border-color: #{$background-color-step-300};
}

View File

@ -0,0 +1,307 @@
@import "../../themes/ionic.globals";
// Input OTP
// --------------------------------------------------
:host {
/**
* @prop --background: Background color of the input boxes
*
* @prop --border-radius: Border radius of the input boxes
*
* @prop --border-width: Border width of the input boxes
* @prop --border-color: Border color of the input boxes
*
* @prop --color: Text color of the input
*
* @prop --margin-top: Top margin of the input group
* @prop --margin-end: Right margin if direction is left-to-right, and left margin if direction is right-to-left of the input group
* @prop --margin-bottom: Bottom margin of the input group
* @prop --margin-start: Left margin if direction is left-to-right, and right margin if direction is right-to-left of the input group
*
* @prop --padding-top: Top padding of the input group
* @prop --padding-end: Right padding if direction is left-to-right, and left padding if direction is right-to-left of the input group
* @prop --padding-bottom: Bottom padding of the input group
* @prop --padding-start: Left padding if direction is left-to-right, and right padding if direction is right-to-left of the input group
*
* @prop --height: Height of input boxes
* @prop --width: Width of input boxes
* @prop --min-width: Minimum width of input boxes
*
* @prop --separator-color: Color of the separator between boxes
* @prop --separator-width: Width of the separator between boxes
* @prop --separator-height: Height of the separator between boxes
* @prop --separator-border-radius: Border radius of the separator between boxes
*
* @prop --highlight-color-focused: The color of the highlight on the input when focused
* @prop --highlight-color-valid: The color of the highlight on the input when valid
* @prop --highlight-color-invalid: The color of the highlight on the input when invalid
*/
--margin-top: 0;
--margin-end: 0;
--margin-bottom: 0;
--margin-start: 0;
--padding-top: 16px;
--padding-end: 0;
--padding-bottom: 16px;
--padding-start: 0;
--color: initial;
--min-width: 40px;
--separator-width: 8px;
--separator-height: var(--separator-width);
--separator-border-radius: 999px;
--separator-color: #{$background-color-step-150};
--highlight-color-focused: #{ion-color(primary, base)};
--highlight-color-valid: #{ion-color(success, base)};
--highlight-color-invalid: #{ion-color(danger, base)};
/**
* This is a private API that is used to switch
* out the highlight color based on the state
* of the component without having to write
* different selectors for different fill variants.
*/
--highlight-color: var(--highlight-color-focused);
display: block;
position: relative;
font-size: dynamic-font(14px);
}
.input-otp-group {
@include margin(var(--margin-top), var(--margin-end), var(--margin-bottom), var(--margin-start));
@include padding(var(--padding-top), var(--padding-end), var(--padding-bottom), var(--padding-start));
display: flex;
align-items: center;
justify-content: center;
}
.native-wrapper {
display: flex;
align-items: center;
justify-content: center;
min-width: var(--min-width);
}
// Native Input
// ----------------------------------------------------------------
.native-input {
@include border-radius(var(--border-radius));
width: var(--width);
// Required to shrink the input box width
min-width: inherit;
height: var(--height);
border-width: var(--border-width);
border-style: solid;
border-color: var(--border-color);
background: var(--background);
color: var(--color);
font-size: inherit;
text-align: center;
appearance: none;
}
:host(.has-focus) .native-input {
caret-color: var(--highlight-color);
}
// Input Description
// ----------------------------------------------------------------
.input-otp-description {
color: $text-color-step-300;
font-size: dynamic-font(12px);
line-height: dynamic-font(20px);
text-align: center;
}
.input-otp-description-hidden {
display: none;
}
// Input Separator
// ----------------------------------------------------------------
.input-otp-separator {
@include border-radius(var(--separator-border-radius));
flex-shrink: 0;
width: var(--separator-width);
height: var(--separator-height);
background: var(--separator-color);
}
// Sizes
// --------------------------------------------------
:host(.input-otp-size-small) {
--width: 40px;
--height: 40px;
}
:host(.input-otp-size-small) .input-otp-group {
gap: 8px;
}
:host(.input-otp-size-medium) {
--width: 48px;
--height: 48px;
}
:host(.input-otp-size-large) {
--width: 56px;
--height: 56px;
}
:host(.input-otp-size-medium) .input-otp-group,
:host(.input-otp-size-large) .input-otp-group {
gap: 12px;
}
// Shapes
// --------------------------------------------------
:host(.input-otp-shape-round) {
--border-radius: 16px;
}
:host(.input-otp-shape-soft) {
--border-radius: 8px;
}
:host(.input-otp-shape-rectangular) {
--border-radius: 0;
}
// Fills
// --------------------------------------------------
:host(.input-otp-fill-outline) {
--background: none;
}
:host(.input-otp-fill-solid) {
--border-color: #{$background-color-step-50};
--background: #{$background-color-step-50};
}
// States
// --------------------------------------------------
:host(.input-otp-disabled) {
--color: #{$text-color-step-650};
}
:host(.input-otp-fill-outline.input-otp-disabled) {
--background: #{$background-color-step-50};
--border-color: #{$background-color-step-100};
}
:host(.input-otp-disabled),
:host(.input-otp-disabled) .native-input:disabled {
cursor: not-allowed;
}
:host(.has-focus) .native-input:focus {
--border-color: var(--highlight-color);
outline: none;
}
:host(.input-otp-fill-outline.input-otp-readonly) {
--background: #{$background-color-step-50};
}
:host(.input-otp-fill-solid.input-otp-disabled),
:host(.input-otp-fill-solid.input-otp-readonly) {
--border-color: #{$background-color-step-100};
--background: #{$background-color-step-100};
}
// Input Highlight
// ----------------------------------------------------------------
:host(.ion-touched.ion-invalid) {
--highlight-color: var(--highlight-color-invalid);
}
/**
* The component highlight is only shown
* on focus, so we can safely set the valid
* color state when valid. If we
* set it when .has-focus is present then
* the highlight color would change
* from the valid color to the component's
* color during the transition after the
* component loses focus.
*/
:host(.ion-valid) {
--highlight-color: var(--highlight-color-valid);
}
/**
* If the input has a validity state, the
* border should reflect that as a color.
* The invalid state should show if the input is
* invalid and has already been touched.
* The valid state should show if the input
* is valid, has already been touched, and
* is currently focused. Do not show the valid
* highlight when the input is blurred.
*/
:host(.has-focus.ion-valid),
:host(.ion-touched.ion-invalid) {
--border-color: var(--highlight-color);
}
// Colors
// ----------------------------------------------------------------
:host(.ion-color) {
--highlight-color-focused: #{current-color(base)};
}
// Outline border should match the current color
// and the solid border should match when focused
:host(.input-otp-fill-outline.ion-color) .native-input,
:host(.input-otp-fill-solid.ion-color) .native-input:focus {
border-color: current-color(base, 0.6);
}
// Invalid
:host(.input-otp-fill-outline.ion-color.ion-invalid) .native-input,
:host(.input-otp-fill-solid.ion-color.ion-invalid) .native-input,
:host(.input-otp-fill-outline.ion-color.has-focus.ion-invalid) .native-input,
:host(.input-otp-fill-solid.ion-color.has-focus.ion-invalid) .native-input {
border-color: ion-color(danger, base);
}
// Valid
:host(.input-otp-fill-outline.ion-color.ion-valid) .native-input,
:host(.input-otp-fill-solid.ion-color.ion-valid) .native-input,
:host(.input-otp-fill-outline.ion-color.has-focus.ion-valid) .native-input,
:host(.input-otp-fill-solid.ion-color.has-focus.ion-valid) .native-input {
border-color: ion-color(success, base);
}
// Outline & Disabled
:host(.input-otp-fill-outline.input-otp-disabled.ion-color) .native-input {
border-color: current-color(base, 0.3);
}

View File

@ -0,0 +1,789 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Fragment, Host, Prop, State, h, Watch } from '@stencil/core';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes } from '@utils/helpers';
import { printIonWarning } from '@utils/logging';
import { isRTL } from '@utils/rtl';
import { createColorClasses } from '@utils/theme';
import { Method } from 'ionicons/dist/types/stencil-public-runtime';
import { getIonMode } from '../../global/ionic-global';
import type { Color } from '../../interface';
import type {
InputOtpChangeEventDetail,
InputOtpCompleteEventDetail,
InputOtpInputEventDetail,
} from './input-otp-interface';
@Component({
tag: 'ion-input-otp',
styleUrls: {
ios: 'input-otp.ios.scss',
md: 'input-otp.md.scss',
},
scoped: true,
})
export class InputOTP implements ComponentInterface {
private inheritedAttributes: Attributes = {};
private inputRefs: HTMLInputElement[] = [];
private inputId = `ion-input-otp-${inputIds++}`;
private parsedSeparators: number[] = [];
/**
* Stores the initial value of the input when it receives focus.
* Used to determine if the value changed during the focus session
* to avoid emitting unnecessary change events on blur.
*/
private focusedValue?: string | number | null;
/**
* Tracks whether the user is navigating through input boxes using keyboard navigation
* (arrow keys, tab) versus mouse clicks. This is used to determine the appropriate
* focus behavior when an input box is focused.
*/
private isKeyboardNavigation = false;
@Element() el!: HTMLIonInputOtpElement;
@State() private inputValues: string[] = [];
@State() hasFocus = false;
/**
* Indicates whether and how the text value should be automatically capitalized as it is entered/edited by the user.
* Available options: `"off"`, `"none"`, `"on"`, `"sentences"`, `"words"`, `"characters"`.
*/
@Prop() autocapitalize = 'off';
/**
* The color to use from your application's color palette.
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
* For more information on colors, see [theming](/docs/theming/basics).
*/
@Prop({ reflect: true }) color?: Color;
/**
* If `true`, the user cannot interact with the input.
*/
@Prop({ reflect: true }) disabled = false;
/**
* The fill for the input boxes. If `"solid"` the input boxes will have a background. If
* `"outline"` the input boxes will be transparent with a border.
*/
@Prop() fill?: 'outline' | 'solid' = 'outline';
/**
* A hint to the browser for which keyboard to display.
* Possible values: `"none"`, `"text"`, `"tel"`, `"url"`,
* `"email"`, `"numeric"`, `"decimal"`, and `"search"`.
*
* For numbers (type="number"): "numeric"
* For text (type="text"): "text"
*/
@Prop() inputmode?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search';
/**
* The number of input boxes to display.
*/
@Prop() length = 4;
/**
* A regex pattern string for allowed characters. Defaults based on type.
*
* For numbers (`type="number"`): `"[\p{N}]"`
* For text (`type="text"`): `"[\p{L}\p{N}]"`
*/
@Prop() pattern?: string;
/**
* If `true`, the user cannot modify the value.
*/
@Prop({ reflect: true }) readonly = false;
/**
* 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.
*/
@Prop() separators?: 'all' | string | number[];
/**
* 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.
*/
@Prop() shape: 'round' | 'rectangular' | 'soft' = 'round';
/**
* The size of the input boxes.
*/
@Prop() size: 'small' | 'medium' | 'large' = 'medium';
/**
* The type of input allowed in the input boxes.
*/
@Prop() type: 'text' | 'number' = 'number';
/**
* The value of the input group.
*/
@Prop({ mutable: true }) value?: string | number | null = '';
/**
* The `ionInput` event is fired each time the user modifies the input's value.
* Unlike the `ionChange` event, the `ionInput` event is fired for each alteration
* to the input's value. This typically happens for each keystroke as the user types.
*
* For elements that accept text input (`type=text`, `type=tel`, etc.), the interface
* is [`InputEvent`](https://developer.mozilla.org/en-US/docs/Web/API/InputEvent); for others,
* the interface is [`Event`](https://developer.mozilla.org/en-US/docs/Web/API/Event). If
* the input is cleared on edit, the type is `null`.
*/
@Event() ionInput!: EventEmitter<InputOtpInputEventDetail>;
/**
* The `ionChange` event is fired when the user modifies the input's value.
* Unlike the `ionInput` event, the `ionChange` event is only fired when changes
* are committed, not as the user types.
*
* The `ionChange` event fires when the `<ion-input-otp>` component loses
* focus after its value has changed.
*
* This event will not emit when programmatically setting the `value` property.
*/
@Event() ionChange!: EventEmitter<InputOtpChangeEventDetail>;
/**
* Emitted when all input boxes have been filled with valid values.
*/
@Event() ionComplete!: EventEmitter<InputOtpCompleteEventDetail>;
/**
* Emitted when the input group loses focus.
*/
@Event() ionBlur!: EventEmitter<FocusEvent>;
/**
* Emitted when the input group has focus.
*/
@Event() ionFocus!: EventEmitter<FocusEvent>;
/**
* Sets focus to an input box.
* @param index - The index of the input box to focus (0-based).
* If provided and the input box has a value, the input box at that index will be focused.
* Otherwise, the first empty input box or the last input if all are filled will be focused.
*/
@Method()
async setFocus(index?: number) {
if (typeof index === 'number') {
const validIndex = Math.max(0, Math.min(index, this.length - 1));
this.inputRefs[validIndex]?.focus();
} else {
const tabbableIndex = this.getTabbableIndex();
this.inputRefs[tabbableIndex]?.focus();
}
}
@Watch('value')
valueChanged() {
this.initializeValues();
this.updateTabIndexes();
}
/**
* Processes the separators prop into an array of numbers.
*
* If the separators prop is not provided, returns an empty array.
* If the separators prop is 'all', returns an array of all valid positions (1 to length-1).
* If the separators prop is an array, returns it as is.
* If the separators prop is a string, splits it by commas and parses each part as a number.
*
* If the separators are greater than the input length, it will warn and ignore the separators.
*/
@Watch('separators')
@Watch('length')
private processSeparators() {
const { separators, length } = this;
if (separators === undefined) {
this.parsedSeparators = [];
return;
}
if (typeof separators === 'string' && separators !== 'all') {
const isValidFormat = /^(\d+)(,\d+)*$/.test(separators);
if (!isValidFormat) {
printIonWarning(
`[ion-input-otp] - Invalid separators format. Expected a comma-separated list of numbers, an array of numbers, or "all". Received: ${separators}`,
this.el
);
this.parsedSeparators = [];
return;
}
}
let separatorValues: number[];
if (separators === 'all') {
separatorValues = Array.from({ length: length - 1 }, (_, i) => i + 1);
} else if (Array.isArray(separators)) {
separatorValues = separators;
} else {
separatorValues = separators
.split(',')
.map((pos) => parseInt(pos, 10))
.filter((pos) => !isNaN(pos));
}
// Check for duplicate separator positions
const duplicates = separatorValues.filter((pos, index) => separatorValues.indexOf(pos) !== index);
if (duplicates.length > 0) {
printIonWarning(
`[ion-input-otp] - Duplicate separator positions are not allowed. Received: ${separators}`,
this.el
);
}
const invalidSeparators = separatorValues.filter((pos) => pos > length);
if (invalidSeparators.length > 0) {
printIonWarning(
`[ion-input-otp] - The following separator positions are greater than the input length (${length}): ${invalidSeparators.join(
', '
)}. These separators will be ignored.`,
this.el
);
}
this.parsedSeparators = separatorValues.filter((pos) => pos <= length);
}
componentWillLoad() {
this.inheritedAttributes = inheritAriaAttributes(this.el);
this.processSeparators();
this.initializeValues();
}
componentDidLoad() {
this.updateTabIndexes();
}
/**
* Get the regex pattern for allowed characters.
* If a pattern is provided, use it to create a regex pattern
* Otherwise, use the default regex pattern based on type
*/
private get validKeyPattern(): RegExp {
return new RegExp(`^${this.getPattern()}$`, 'u');
}
/**
* Gets the string pattern to pass to the input element
* and use in the regex for allowed characters.
*/
private getPattern(): string {
const { pattern, type } = this;
if (pattern) {
return pattern;
}
return type === 'number' ? '[\\p{N}]' : '[\\p{L}\\p{N}]';
}
/**
* Get the default value for inputmode.
* If inputmode is provided, use it.
* Otherwise, use the default inputmode based on type
*/
private getInputmode(): string {
const { inputmode } = this;
if (inputmode) {
return inputmode;
}
if (this.type == 'number') {
return 'numeric';
} else {
return 'text';
}
}
/**
* Initializes the input values array based on the current value prop.
* This splits the value into individual characters and validates them against
* the allowed pattern. The values are then used as the values in the native
* input boxes and the value of the input group is updated.
*/
private initializeValues() {
// Clear all input values
this.inputValues = Array(this.length).fill('');
// If the value is null, undefined, or an empty string, return
if (this.value == null || String(this.value).length === 0) {
return;
}
// Split the value into individual characters and validate
// them against the allowed pattern
const chars = String(this.value).split('').slice(0, this.length);
chars.forEach((char, index) => {
if (this.validKeyPattern.test(char)) {
this.inputValues[index] = char;
}
});
// Update the value without emitting events
this.value = this.inputValues.join('');
}
/**
* Updates the value of the input group.
* This updates the value of the input group and emits an `ionChange` event.
* If all of the input boxes are filled, it emits an `ionComplete` event.
*/
private updateValue(event: Event) {
const { inputValues, length } = this;
const newValue = inputValues.join('');
this.value = newValue;
this.emitIonInput(event);
if (newValue.length === length) {
this.ionComplete.emit({ value: newValue });
}
}
/**
* Emits an `ionChange` event.
* This API should be called for user committed changes.
* This API should not be used for external value changes.
*/
private emitIonChange(event: Event) {
const { value } = this;
// Checks for both null and undefined values
const newValue = value == null ? value : value.toString();
this.ionChange.emit({ value: newValue, event });
}
/**
* Emits an `ionInput` event.
* This is used to emit the input value when the user types,
* backspaces, or pastes.
*/
private emitIonInput(event: Event) {
const { value } = this;
// Checks for both null and undefined values
const newValue = value == null ? value : value.toString();
this.ionInput.emit({ value: newValue, event });
}
/**
* Handles the focus behavior for the input OTP component.
*
* Focus behavior:
* 1. Keyboard navigation: Allow normal focus movement
* 2. Mouse click:
* - If clicked box has value: Focus that box
* - If clicked box is empty: Focus first empty box
*
* Emits the `ionFocus` event when the input group gains focus.
*/
private onFocus = (index: number) => (event: FocusEvent) => {
const { inputRefs } = this;
// Only emit ionFocus and set the focusedValue when the
// component first gains focus
if (!this.hasFocus) {
this.ionFocus.emit(event);
this.focusedValue = this.value;
}
this.hasFocus = true;
let finalIndex = index;
if (!this.isKeyboardNavigation) {
// If the clicked box has a value, focus it
// Otherwise focus the first empty box
const targetIndex = this.inputValues[index] ? index : this.getFirstEmptyIndex();
finalIndex = targetIndex === -1 ? this.length - 1 : targetIndex;
// Focus the target box
this.inputRefs[finalIndex]?.focus();
}
// Update tabIndexes to match the focused box
inputRefs.forEach((input, i) => {
input.tabIndex = i === finalIndex ? 0 : -1;
});
// Reset the keyboard navigation flag
this.isKeyboardNavigation = false;
};
/**
* Handles the blur behavior for the input OTP component.
* Emits the `ionBlur` event when the input group loses focus.
*/
private onBlur = (event: FocusEvent) => {
const { inputRefs } = this;
const relatedTarget = event.relatedTarget as HTMLElement;
// Do not emit blur if we're moving to another input box in the same component
const isInternalFocus = relatedTarget != null && inputRefs.includes(relatedTarget as HTMLInputElement);
if (!isInternalFocus) {
this.hasFocus = false;
// Reset tabIndexes when focus leaves the component
this.updateTabIndexes();
// Always emit ionBlur when focus leaves the component
this.ionBlur.emit(event);
// Only emit ionChange if the value has actually changed
if (this.focusedValue !== this.value) {
this.emitIonChange(event);
}
}
};
/**
* Focuses the next input box.
*/
private focusNext(currentIndex: number) {
const { inputRefs, length } = this;
if (currentIndex < length - 1) {
inputRefs[currentIndex + 1]?.focus();
}
}
/**
* Focuses the previous input box.
*/
private focusPrevious(currentIndex: number) {
const { inputRefs } = this;
if (currentIndex > 0) {
inputRefs[currentIndex - 1]?.focus();
}
}
/**
* Searches through the input values and returns the index
* of the first empty input.
* Returns -1 if all inputs are filled.
*/
private getFirstEmptyIndex() {
const { inputValues, length } = this;
// Create an array of the same length as the input OTP
// and fill it with the input values
const values = Array.from({ length }, (_, i) => inputValues[i] || '');
return values.findIndex((value) => !value || value === '') ?? -1;
}
/**
* Returns the index of the input that should be tabbed to.
* If all inputs are filled, returns the last input's index.
* Otherwise, returns the index of the first empty input.
*/
private getTabbableIndex() {
const { length } = this;
const firstEmptyIndex = this.getFirstEmptyIndex();
return firstEmptyIndex === -1 ? length - 1 : firstEmptyIndex;
}
/**
* Updates the tabIndexes for the input boxes.
* This is used to ensure that the correct input is
* focused when the user navigates using the tab key.
*/
private updateTabIndexes() {
const { inputRefs, inputValues, length } = this;
// Find first empty index after any filled boxes
let firstEmptyIndex = -1;
for (let i = 0; i < length; i++) {
if (!inputValues[i] || inputValues[i] === '') {
firstEmptyIndex = i;
break;
}
}
// Update tabIndex and aria-hidden for all inputs
inputRefs.forEach((input, index) => {
const shouldBeTabbable = firstEmptyIndex === -1 ? index === length - 1 : firstEmptyIndex === index;
input.tabIndex = shouldBeTabbable ? 0 : -1;
// If the input is empty and not the first empty input,
// it should be hidden from screen readers.
const isEmpty = !inputValues[index] || inputValues[index] === '';
input.setAttribute('aria-hidden', isEmpty && !shouldBeTabbable ? 'true' : 'false');
});
}
/**
* Handles keyboard navigation and input for the OTP component.
*
* Navigation:
* - Backspace: Clears current input and moves to previous box if empty
* - Arrow Left/Right: Moves focus between input boxes
* - Tab: Allows normal tab navigation between components
*
* Input Behavior:
* - Validates input against the allowed pattern
* - When entering a key in a filled box:
* - Shifts existing values right if there is room
* - Updates the value of the input group
* - Prevents default behavior to avoid automatic focus shift
*/
private onKeyDown = (index: number) => (event: KeyboardEvent) => {
const { length } = this;
const rtl = isRTL(this.el);
const input = event.target as HTMLInputElement;
const isPasteShortcut = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'v';
const isTextSelection = input.selectionStart !== input.selectionEnd;
// Return if the key is the paste shortcut or the input value
// text is selected and let the onPaste / onInput handler manage it
if (isPasteShortcut || isTextSelection) {
return;
}
if (event.key === 'Backspace') {
if (this.inputValues[index]) {
// Shift all values to the right of the current index left by one
for (let i = index; i < length - 1; i++) {
this.inputValues[i] = this.inputValues[i + 1];
}
// Clear the last box
this.inputValues[length - 1] = '';
// Update all inputRefs to match inputValues
for (let i = 0; i < length; i++) {
this.inputRefs[i].value = this.inputValues[i] || '';
}
this.updateValue(event);
event.preventDefault();
} else if (!this.inputValues[index] && index > 0) {
// If current input is empty, move to previous input
this.focusPrevious(index);
}
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
this.isKeyboardNavigation = true;
event.preventDefault();
const isLeft = event.key === 'ArrowLeft';
const shouldMoveNext = (isLeft && rtl) || (!isLeft && !rtl);
// Only allow moving to the next input if the current has a value
if (shouldMoveNext) {
if (this.inputValues[index] && index < length - 1) {
this.focusNext(index);
}
} else {
this.focusPrevious(index);
}
} else if (event.key === 'Tab') {
this.isKeyboardNavigation = true;
// Let all tab events proceed normally
return;
}
// If the input box contains a value and the key being
// entered is a valid key for the input box update the value
// and shift the values to the right if there is room.
if (this.inputValues[index] && this.validKeyPattern.test(event.key)) {
if (!this.inputValues[length - 1]) {
for (let i = length - 1; i > index; i--) {
this.inputValues[i] = this.inputValues[i - 1];
this.inputRefs[i].value = this.inputValues[i] || '';
}
}
this.inputValues[index] = event.key;
this.inputRefs[index].value = event.key;
this.updateValue(event);
// Prevent default to avoid the browser from
// automatically moving the focus to the next input
event.preventDefault();
}
};
private onInput = (index: number) => (event: InputEvent) => {
const { validKeyPattern } = this;
const value = (event.target as HTMLInputElement).value;
// Only allow input if it's a single character and matches the pattern
if (value.length > 1 || (value.length > 0 && !validKeyPattern.test(value))) {
// Reset the input value if not valid
this.inputRefs[index].value = '';
this.inputValues[index] = '';
return;
}
// Find the first empty box before or at the current index
let targetIndex = index;
for (let i = 0; i < index; i++) {
if (!this.inputValues[i] || this.inputValues[i] === '') {
targetIndex = i;
break;
}
}
// Set the value at the target index
this.inputValues[targetIndex] = value;
// If the value was entered in a later box, clear the current box
if (targetIndex !== index) {
this.inputRefs[index].value = '';
}
if (value.length > 0) {
this.focusNext(targetIndex);
}
this.updateValue(event);
};
/**
* Handles pasting text into the input OTP component.
* This function prevents the default paste behavior and
* validates the pasted text against the allowed pattern.
* It then updates the value of the input group and focuses
* the next empty input after pasting.
*/
private onPaste = (event: ClipboardEvent) => {
const { inputRefs, length, validKeyPattern } = this;
event.preventDefault();
const pastedText = event.clipboardData?.getData('text');
// If there is no pasted text, still emit the input change event
// because this is how the native input element behaves
// but return early because there is nothing to paste.
if (!pastedText) {
this.emitIonInput(event);
return;
}
const validChars = pastedText
.split('')
.filter((char) => validKeyPattern.test(char))
.slice(0, length);
// Always paste starting at the first box
validChars.forEach((char, index) => {
if (index < length) {
this.inputRefs[index].value = char;
this.inputValues[index] = char;
}
});
// Update the value so that all input boxes are updated
this.value = validChars.join('');
this.updateValue(event);
// Focus the next empty input after pasting
// If all boxes are filled, focus the last input
const nextEmptyIndex = validChars.length;
if (nextEmptyIndex < length) {
inputRefs[nextEmptyIndex]?.focus();
} else {
inputRefs[length - 1]?.focus();
}
};
/**
* Determines if a separator should be shown for a given index by
* checking if the index is included in the parsed separators array.
*/
private showSeparator(index: number) {
const { length } = this;
return this.parsedSeparators.includes(index + 1) && index < length - 1;
}
render() {
const {
autocapitalize,
color,
disabled,
el,
fill,
hasFocus,
inheritedAttributes,
inputId,
inputRefs,
inputValues,
length,
readonly,
shape,
size,
} = this;
const mode = getIonMode(this);
const inputmode = this.getInputmode();
const tabbableIndex = this.getTabbableIndex();
const pattern = this.getPattern();
const hasDescription = el.querySelector('.input-otp-description')?.textContent?.trim() !== '';
return (
<Host
class={createColorClasses(color, {
[mode]: true,
'has-focus': hasFocus,
[`input-otp-size-${size}`]: true,
[`input-otp-shape-${shape}`]: true,
[`input-otp-fill-${fill}`]: true,
'input-otp-disabled': disabled,
'input-otp-readonly': readonly,
})}
>
<div role="group" aria-label="One-time password input" class="input-otp-group" {...inheritedAttributes}>
{Array.from({ length }).map((_, index) => (
<>
<div class="native-wrapper">
<input
class="native-input"
id={`${inputId}-${index}`}
aria-label={`Input ${index + 1} of ${length}`}
type="text"
autoCapitalize={autocapitalize}
inputmode={inputmode}
maxLength={1}
pattern={pattern}
disabled={disabled}
readOnly={readonly}
tabIndex={index === tabbableIndex ? 0 : -1}
value={inputValues[index] || ''}
autocomplete={index === 0 ? 'one-time-code' : 'off'}
ref={(el) => (inputRefs[index] = el as HTMLInputElement)}
onInput={this.onInput(index)}
onBlur={this.onBlur}
onFocus={this.onFocus(index)}
onKeyDown={this.onKeyDown(index)}
onPaste={this.onPaste}
/>
</div>
{this.showSeparator(index) && <div class="input-otp-separator" />}
</>
))}
</div>
<div
class={{
'input-otp-description': true,
'input-otp-description-hidden': !hasDescription,
}}
>
<slot></slot>
</div>
</Host>
);
}
}
let inputIds = 0;

View File

@ -0,0 +1,109 @@
import AxeBuilder from '@axe-core/playwright';
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
/**
* Functionality is the same across modes
*/
configs().forEach(({ title, config }) => {
test.describe(title('input-otp: a11y'), () => {
test('should not have accessibility violations', async ({ page }) => {
await page.setContent(
`
<main>
<ion-input-otp></ion-input-otp>
</main>
`,
config
);
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
test('should render with correct aria attributes on initial load', async ({ page }) => {
await page.setContent(`<ion-input-otp></ion-input-otp>`, config);
const inputOtpGroup = page.locator('ion-input-otp .input-otp-group');
await expect(inputOtpGroup).toHaveAttribute('aria-label', 'One-time password input');
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 value is set', async ({ page }) => {
await page.setContent(`<ion-input-otp value="12"></ion-input-otp>`, config);
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', 'false');
await expect(inputBoxes.nth(2)).toHaveAttribute('aria-hidden', 'false');
await expect(inputBoxes.nth(3)).toHaveAttribute('aria-hidden', 'true');
});
test('should update aria-hidden when typing a value', async ({ page }) => {
await page.setContent(`<ion-input-otp></ion-input-otp>`, config);
const inputBoxes = page.locator('ion-input-otp input');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('123');
await expect(inputBoxes.nth(0)).toHaveAttribute('aria-hidden', 'false');
await expect(inputBoxes.nth(1)).toHaveAttribute('aria-hidden', 'false');
await expect(inputBoxes.nth(2)).toHaveAttribute('aria-hidden', 'false');
await expect(inputBoxes.nth(3)).toHaveAttribute('aria-hidden', 'false');
});
test('should update aria-hidden when value is cleared using backspace', async ({ page }) => {
await page.setContent(`<ion-input-otp value="12"></ion-input-otp>`, config);
const inputBoxes = page.locator('ion-input-otp input');
await page.keyboard.press('Tab');
await page.keyboard.press('Backspace');
await page.keyboard.press('Backspace');
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 value is set after initialization', async ({ page }) => {
await page.setContent(`<ion-input-otp></ion-input-otp>`, config);
await page.evaluate(() => {
const inputOtp = document.querySelector('ion-input-otp');
if (inputOtp) {
inputOtp.value = '12';
}
});
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', 'false');
await expect(inputBoxes.nth(2)).toHaveAttribute('aria-hidden', 'false');
await expect(inputBoxes.nth(3)).toHaveAttribute('aria-hidden', 'true');
});
test('should update aria-label and aria-labelledby when set on host', async ({ page }) => {
await page.setContent(
`<ion-input-otp aria-label="Custom label" aria-labelledby="my-label"></ion-input-otp>`,
config
);
const inputOtpGroup = page.locator('ion-input-otp .input-otp-group');
await expect(inputOtpGroup).toHaveAttribute('aria-label', 'Custom label');
await expect(inputOtpGroup).toHaveAttribute('aria-labelledby', 'my-label');
});
});
});

View File

@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Input OTP - Basic</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
h2 {
text-align: center;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
grid-row-gap: 20px;
grid-column-gap: 20px;
}
button {
margin: 8px 2px !important;
}
</style>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Input OTP - Basic</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="grid">
<div class="grid-item">
<h2>Default</h2>
<ion-input-otp> Didn't get a code? <a href="#">Resend the code</a> </ion-input-otp>
<ion-input-otp length="2"> Didn't get a code? <a href="#">Resend the code</a> </ion-input-otp>
<ion-input-otp id="numberValue" length="6">
Didn't get a code? <a href="#">Resend the code</a>
</ion-input-otp>
<ion-input-otp value="5893" length="8"> Didn't get a code? <a href="#">Resend the code</a> </ion-input-otp>
</div>
<div class="grid-item">
<h2>Types</h2>
<ion-input-otp class="input-otp-type"> Numbers only <span id="value"></span> </ion-input-otp>
<ion-input-otp class="input-otp-type" type="text">
Letters and numbers <span id="value"></span>
</ion-input-otp>
<ion-input-otp class="input-otp-type" type="text" pattern="[a-fA-F]">
Custom Pattern: a-f and A-F <span id="value"></span>
</ion-input-otp>
<ion-input-otp class="input-otp-type" type="text" pattern="[D-L]" autocapitalize="on">
Custom Pattern: D-L <span id="value"></span>
</ion-input-otp>
</div>
<div class="grid-item">
<h2>Disabled</h2>
<ion-input-otp value="1234" disabled>Description</ion-input-otp>
<ion-input-otp value="1234" fill="solid" disabled>Description</ion-input-otp>
<h2>Readonly</h2>
<ion-input-otp value="1234" readonly>Description</ion-input-otp>
<ion-input-otp value="1234" fill="solid" readonly>Description</ion-input-otp>
</div>
<div class="grid-item">
<h2>Invalid / Touched</h2>
<ion-input-otp class="ion-invalid ion-touched">Description</ion-input-otp>
<ion-input-otp fill="solid" class="ion-invalid ion-touched">Description</ion-input-otp>
<h2>Valid / Focused</h2>
<ion-input-otp class="ion-valid has-focus">Description</ion-input-otp>
<ion-input-otp fill="solid" class="ion-valid has-focus">Description</ion-input-otp>
</div>
</div>
</ion-content>
<script>
const numberValue = document.getElementById('numberValue');
numberValue.value = 123;
const inputOtpTypes = document.querySelectorAll('.input-otp-type');
// Display value under the different input types on ionInput
inputOtpTypes.forEach((inputOtpType) => {
inputOtpType.addEventListener('ionInput', (event) => {
const displayValue = event.detail.value != '' ? `(value: ${event.detail.value})` : '';
inputOtpType.querySelector('#value').textContent = displayValue;
});
});
document.addEventListener('ionChange', (ev) => {
console.log('ionChange', ev);
});
document.addEventListener('ionInput', (ev) => {
console.log('ionInput', ev);
});
document.addEventListener('ionComplete', (ev) => {
console.log('ionComplete', ev);
});
document.addEventListener('ionFocus', (ev) => {
console.log('ionFocus', ev);
});
document.addEventListener('ionBlur', (ev) => {
console.log('ionBlur', ev);
});
</script>
</ion-app>
</body>
</html>

View File

@ -0,0 +1,947 @@
import { expect } from '@playwright/test';
import type { Locator } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
/**
* Simulates a paste event in an input element with the given value
*/
async function simulatePaste(input: any, value: string) {
await input.evaluate((input: any, value: string) => {
const event = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: new DataTransfer(),
});
if (event.clipboardData) {
event.clipboardData.setData('text', value);
}
input.dispatchEvent(event);
}, value);
}
/**
* Helper function to verify input values in both the input
* boxes and the input-otp component's value property
*/
async function verifyInputValues(inputOtp: Locator, expectedValues: string[]) {
const inputBoxes = inputOtp.locator('input');
for (let i = 0; i < expectedValues.length; i++) {
await expect(inputBoxes.nth(i)).toHaveValue(expectedValues[i]);
}
// Concatenate the expected values and check the JS property
const concatenatedValue = expectedValues.join('');
await expect(inputOtp).toHaveJSProperty('value', concatenatedValue);
}
/**
* Functionality is the same across modes
*/
configs({ modes: ['ios'] }).forEach(({ title, config }) => {
test.describe(title('input-otp: basic functionality'), () => {
test('should render with 4 input boxes and a default value', async ({ page }) => {
await page.setContent(`<ion-input-otp value="12">Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
const inputBoxes = page.locator('ion-input-otp input');
await expect(inputBoxes).toHaveCount(4);
await verifyInputValues(inputOtp, ['1', '2', '', '']);
});
test('should render with 8 input boxes when length is set to 8 and a default value', async ({ page }) => {
await page.setContent(`<ion-input-otp length="8" value="12345678">Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
const inputBoxes = page.locator('ion-input-otp input');
await expect(inputBoxes).toHaveCount(8);
await verifyInputValues(inputOtp, ['1', '2', '3', '4', '5', '6', '7', '8']);
});
test('should accept numbers only by default', async ({ page }) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('A2e468');
await verifyInputValues(inputOtp, ['2', '4', '6', '8']);
});
test('should accept Eastern Arabic numerals by default', async ({ page }) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('١٢٣٤');
// Because Arabic is a right-to-left script, JavaScript's handling of RTL text
// causes the array values to be reversed while input boxes maintain LTR order.
// We reverse our expected values to match this behavior.
await verifyInputValues(inputOtp, ['٤', '٣', '٢', '١'].reverse());
});
test('should accept only Western Arabic numerals when pattern is set to [0-9]', async ({ page }) => {
await page.setContent(`<ion-input-otp pattern="[0-9]">Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('12٣٤34');
await verifyInputValues(inputOtp, ['1', '2', '3', '4']);
});
test('should accept Latin characters when type is text', async ({ page }) => {
await page.setContent(`<ion-input-otp type="text">Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('A2-B5');
await verifyInputValues(inputOtp, ['A', '2', 'B', '5']);
});
test('should accept accented Latin characters when type is text', async ({ page }) => {
await page.setContent(`<ion-input-otp type="text">Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('áéíó');
await verifyInputValues(inputOtp, ['á', 'é', 'í', 'ó']);
});
test('should accept Cyrillic characters when type is text', async ({ page }) => {
await page.setContent(`<ion-input-otp type="text">Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('АбвГ');
await verifyInputValues(inputOtp, ['А', 'б', 'в', 'Г']);
});
test('should accept Chinese characters when type is text', async ({ page }) => {
await page.setContent(`<ion-input-otp type="text">Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('中国北京');
await verifyInputValues(inputOtp, ['中', '国', '北', '京']);
});
test('should accept Japanese characters when type is text', async ({ page }) => {
await page.setContent(`<ion-input-otp type="text">Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('ひらがな');
await verifyInputValues(inputOtp, ['ひ', 'ら', 'が', 'な']);
});
test('should accept Korean characters when type is text', async ({ page }) => {
await page.setContent(`<ion-input-otp type="text">Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('안녕하세');
await verifyInputValues(inputOtp, ['안', '녕', '하', '세']);
});
test('should accept Arabic characters when type is text', async ({ page }) => {
await page.setContent(`<ion-input-otp type="text">Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('أبجد');
// Because Arabic is a right-to-left script, JavaScript's handling of RTL text
// causes the array values to be reversed while input boxes maintain LTR order.
// We reverse our expected values to match this behavior.
await verifyInputValues(inputOtp, ['د', 'ج', 'ب', 'أ'].reverse());
});
test('should accept mixed language characters when type is text', async ({ page }) => {
await page.setContent(`<ion-input-otp type="text">Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('A漢字Б');
await verifyInputValues(inputOtp, ['A', '漢', '字', 'Б']);
});
test('should reject special characters when type is text', async ({ page }) => {
await page.setContent(`<ion-input-otp type="text">Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('!@#$%^&*()-,:;./?+');
await verifyInputValues(inputOtp, ['', '', '', '']);
});
test('should accept custom pattern of lowercase and uppercase letters when pattern is set', async ({ page }) => {
await page.setContent(`<ion-input-otp type="text" pattern="[a-fA-F]">Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('aGBZfD');
await verifyInputValues(inputOtp, ['a', 'B', 'f', 'D']);
});
test('should accept custom pattern of uppercase letters only when pattern is set', async ({ page }) => {
await page.setContent(`<ion-input-otp type="text" pattern="[D-L]">Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('abcdABCDEFG');
await verifyInputValues(inputOtp, ['D', 'E', 'F', 'G']);
});
test('should accept custom pattern of all characters when pattern is set', async ({ page }) => {
await page.setContent(`<ion-input-otp type="text" pattern=".">Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('*#.!');
await verifyInputValues(inputOtp, ['*', '#', '.', '!']);
});
test('should accept only Latin characters and numbers when pattern is set', async ({ page }) => {
await page.setContent(`<ion-input-otp type="text" pattern="[A-Za-z0-9]">Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('Ab中国北京12');
await verifyInputValues(inputOtp, ['A', 'b', '1', '2']);
});
test('should accept only Cyrillic characters when pattern is set', async ({ page }) => {
await page.setContent(
`<ion-input-otp type="text" pattern="[\\p{Script=Cyrillic}]">Description</ion-input-otp>`,
config
);
const inputOtp = page.locator('ion-input-otp');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('АбABC123вГ');
await verifyInputValues(inputOtp, ['А', 'б', 'в', 'Г']);
});
test('should accept only Chinese characters when pattern is set', async ({ page }) => {
await page.setContent(
`<ion-input-otp type="text" pattern="[\\p{Script=Han}]">Description</ion-input-otp>`,
config
);
const inputOtp = page.locator('ion-input-otp');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('中国ABC123北京');
await verifyInputValues(inputOtp, ['中', '国', '北', '京']);
});
test('should accept only Japanese characters when pattern is set', async ({ page }) => {
await page.setContent(
`<ion-input-otp type="text" pattern="\\p{Script=Hiragana}">Description</ion-input-otp>`,
config
);
const inputOtp = page.locator('ion-input-otp');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('ひらABC123がな');
await verifyInputValues(inputOtp, ['ひ', 'ら', 'が', 'な']);
});
test('should accept only Korean characters when pattern is set', async ({ page }) => {
await page.setContent(
`<ion-input-otp type="text" pattern="\\p{Script=Hangul}">Description</ion-input-otp>`,
config
);
const inputOtp = page.locator('ion-input-otp');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('안녕ABC123하세');
await verifyInputValues(inputOtp, ['안', '녕', '하', '세']);
});
test('should accept only Arabic characters when pattern is set', async ({ page }) => {
await page.setContent(
`<ion-input-otp type="text" pattern="\\p{Script=Arabic}">Description</ion-input-otp>`,
config
);
const inputOtp = page.locator('ion-input-otp');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('أبجد123');
// Because Arabic is a right-to-left script, JavaScript's handling of RTL text
// causes the array values to be reversed while input boxes maintain LTR order.
// We reverse our expected values to match this behavior.
await verifyInputValues(inputOtp, ['د', 'ج', 'ب', 'أ'].reverse());
});
});
test.describe(title('input-otp: input functionality'), () => {
test('should update the input value when typing 4 digits from the 1st box', async ({ page }) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
const inputOtp = page.locator('ion-input-otp');
await verifyInputValues(inputOtp, ['', '', '', '']);
await page.keyboard.type('12');
await verifyInputValues(inputOtp, ['1', '2', '', '']);
await page.keyboard.type('34');
await verifyInputValues(inputOtp, ['1', '2', '3', '4']);
});
test('should update the 1st input value when typing in the 3rd box', async ({ page }) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
const thirdInput = page.locator('ion-input-otp input').nth(2);
await thirdInput.focus();
const inputOtp = page.locator('ion-input-otp');
const inputBoxes = page.locator('ion-input-otp input');
await page.keyboard.type('1');
await verifyInputValues(inputOtp, ['1', '', '', '']);
// Focus should be on the 2nd input box
await expect(inputBoxes.nth(1)).toBeFocused();
});
test('should update the 3rd input value and shift the values to the right when typing in the 3rd box containing a value', async ({
page,
}) => {
await page.setContent(`<ion-input-otp value="123">Description</ion-input-otp>`, config);
const thirdInput = page.locator('ion-input-otp input').nth(2);
await thirdInput.focus();
const inputOtp = page.locator('ion-input-otp');
const inputBoxes = page.locator('ion-input-otp input');
await page.keyboard.type('9');
await verifyInputValues(inputOtp, ['1', '2', '9', '3']);
// Focus should remain on the 3rd input box
await expect(inputBoxes.nth(2)).toBeFocused();
});
test('should update the 2nd input value when typing in the 2nd box containing a value', async ({ page }) => {
await page.setContent(`<ion-input-otp value="1234">Description</ion-input-otp>`, config);
const secondInput = page.locator('ion-input-otp input').nth(1);
await secondInput.focus();
const inputOtp = page.locator('ion-input-otp');
const inputBoxes = page.locator('ion-input-otp input');
await page.keyboard.type('9');
await verifyInputValues(inputOtp, ['1', '9', '3', '4']);
// Focus should remain on the 2nd input box
await expect(inputBoxes.nth(1)).toBeFocused();
});
test('should not shift values right when selecting the text in the 2nd input box', async ({ page }) => {
await page.setContent(`<ion-input-otp value="123">Description</ion-input-otp>`, config);
const secondInput = page.locator('ion-input-otp input').nth(1);
await secondInput.focus();
await secondInput.selectText();
const inputOtp = page.locator('ion-input-otp');
await page.keyboard.type('9');
await verifyInputValues(inputOtp, ['1', '9', '3', '']);
});
});
test.describe(title('input-otp: focus functionality'), () => {
test('should focus the first input box when tabbed to', async ({ page }) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
await page.keyboard.press('Tab');
const firstInput = page.locator('ion-input-otp input').first();
await expect(firstInput).toBeFocused();
});
test('should focus the third input box when tabbed to with a default value of 2 digits', async ({ page }) => {
await page.setContent(`<ion-input-otp value="12">Description</ion-input-otp>`, config);
await page.keyboard.press('Tab');
const thirdInput = page.locator('ion-input-otp input').nth(2);
await expect(thirdInput).toBeFocused();
});
test('should focus the last input box when tabbed to with a default value of 4 digits', async ({ page }) => {
await page.setContent(`<ion-input-otp value="1234">Description</ion-input-otp>`, config);
await page.keyboard.press('Tab');
const lastInput = page.locator('ion-input-otp input').last();
await expect(lastInput).toBeFocused();
});
test('should focus the next input otp component when tabbed from the 2nd input box', async ({ page }) => {
await page.setContent(
`
<ion-input-otp id="first" value="12">Description</ion-input-otp>
<ion-input-otp id="second">Description</ion-input-otp>
`,
config
);
await page.keyboard.press('Tab');
await page.keyboard.press('ArrowLeft');
await page.keyboard.press('Tab');
const secondInputOtpFirstInput = page.locator('#second input').first();
await expect(secondInputOtpFirstInput).toBeFocused();
});
test('should focus the first input box when clicking on the 2nd input box without a value', async ({ page }) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
const secondInput = page.locator('ion-input-otp input').nth(1);
await secondInput.click();
const firstInput = page.locator('ion-input-otp input').first();
await expect(firstInput).toBeFocused();
});
});
test.describe(title('input-otp: backspace functionality'), () => {
test('should backspace the first input box when backspace is pressed twice from the 2nd input box and the first input box has a value', async ({
page,
}) => {
await page.setContent(`<ion-input-otp value="1">Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
await page.keyboard.press('Tab');
await page.keyboard.press('Backspace');
await page.keyboard.press('Backspace');
await verifyInputValues(inputOtp, ['', '', '', '']);
});
test('should backspace the last input box when backspace is pressed and all values are filled', async ({
page,
}) => {
await page.setContent(`<ion-input-otp value="1234">Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
await page.keyboard.press('Tab');
await page.keyboard.press('Backspace');
await verifyInputValues(inputOtp, ['1', '2', '3', '']);
});
test('should backspace the 2nd input box and fill it with the 3rd value when backspace is pressed and 3 values are filled', async ({
page,
}) => {
await page.setContent(`<ion-input-otp value="123">Description</ion-input-otp>`, config);
await page.keyboard.press('Tab');
const isRTL = await page.evaluate(() => document.dir === 'rtl');
if (isRTL) {
await page.keyboard.press('ArrowRight');
await page.keyboard.press('ArrowRight');
} else {
await page.keyboard.press('ArrowLeft');
await page.keyboard.press('ArrowLeft');
}
await page.keyboard.press('Backspace');
const inputOtp = page.locator('ion-input-otp');
await verifyInputValues(inputOtp, ['1', '3', '', '']);
});
});
test.describe(title('input-otp: paste functionality'), () => {
test('should paste text into the first and second input box when pasting 2 digits', async ({ page }) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await simulatePaste(firstInput, '12');
const inputOtp = page.locator('ion-input-otp');
const inputBoxes = page.locator('ion-input-otp input');
await verifyInputValues(inputOtp, ['1', '2', '', '']);
// Focus should be on the 3rd input box
await expect(inputBoxes.nth(2)).toBeFocused();
});
test('should paste text into all input boxes when pasting 4 digits', async ({ page }) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await simulatePaste(firstInput, '1234');
const inputOtp = page.locator('ion-input-otp');
const inputBoxes = page.locator('ion-input-otp input');
await verifyInputValues(inputOtp, ['1', '2', '3', '4']);
// Focus should be on the 4th input box
await expect(inputBoxes.nth(3)).toBeFocused();
});
test('should paste text into the first and second input box when pasting 2 digits in the 3rd box', async ({
page,
}) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
const thirdInput = page.locator('ion-input-otp input').nth(2);
await thirdInput.focus();
await simulatePaste(thirdInput, '12');
const inputOtp = page.locator('ion-input-otp');
const inputBoxes = page.locator('ion-input-otp input');
await verifyInputValues(inputOtp, ['1', '2', '', '']);
// Focus should be on the 3rd input box
await expect(inputBoxes.nth(2)).toBeFocused();
});
test('should paste text into the first two input boxes when pasting 2 digits after typing 2 digits', async ({
page,
}) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('12');
await simulatePaste(firstInput, '34');
const inputOtp = page.locator('ion-input-otp');
await verifyInputValues(inputOtp, ['3', '4', '', '']);
});
test('should paste text into all input boxes when pasting 4 digits after typing 4 digits', async ({ page }) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('9999');
await simulatePaste(firstInput, '1234');
const inputOtp = page.locator('ion-input-otp');
await verifyInputValues(inputOtp, ['1', '2', '3', '4']);
});
});
});
/**
* Events are the same across modes & directions
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('input-otp: events: ionInput'), () => {
test('should emit ionInput event when typing', async ({ page }) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
const ionInput = await page.spyOnEvent('ionInput');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('1');
await ionInput.next();
await expect(ionInput).toHaveReceivedEventDetail({ value: '1', event: { isTrusted: true } });
await expect(ionInput).toHaveReceivedEventTimes(1);
await page.keyboard.type('2');
await ionInput.next();
await expect(ionInput).toHaveReceivedEventDetail({ value: '12', event: { isTrusted: true } });
await expect(ionInput).toHaveReceivedEventTimes(2);
await page.keyboard.type('3');
await ionInput.next();
await expect(ionInput).toHaveReceivedEventDetail({ value: '123', event: { isTrusted: true } });
await expect(ionInput).toHaveReceivedEventTimes(3);
await page.keyboard.type('4');
await ionInput.next();
await expect(ionInput).toHaveReceivedEventDetail({ value: '1234', event: { isTrusted: true } });
await expect(ionInput).toHaveReceivedEventTimes(4);
});
test('should emit ionInput event when backspacing', async ({ page }) => {
await page.setContent(`<ion-input-otp value="1234">Description</ion-input-otp>`, config);
const ionInput = await page.spyOnEvent('ionInput');
await page.keyboard.press('Tab');
await page.keyboard.press('Backspace');
await ionInput.next();
await expect(ionInput).toHaveReceivedEventDetail({ value: '123', event: { isTrusted: true } });
await expect(ionInput).toHaveReceivedEventTimes(1);
await page.keyboard.press('Backspace');
await ionInput.next();
await expect(ionInput).toHaveReceivedEventDetail({ value: '12', event: { isTrusted: true } });
await expect(ionInput).toHaveReceivedEventTimes(2);
await page.keyboard.press('Backspace');
await ionInput.next();
await expect(ionInput).toHaveReceivedEventDetail({ value: '1', event: { isTrusted: true } });
await expect(ionInput).toHaveReceivedEventTimes(3);
await page.keyboard.press('Backspace');
await ionInput.next();
await expect(ionInput).toHaveReceivedEventDetail({ value: '', event: { isTrusted: true } });
await expect(ionInput).toHaveReceivedEventTimes(4);
});
test('should emit ionInput event when pasting', async ({ page }) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
const ionInput = await page.spyOnEvent('ionInput');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await simulatePaste(firstInput, '12');
await ionInput.next();
await expect(ionInput).toHaveReceivedEventDetail({ value: '12', event: { isTrusted: false } });
await expect(ionInput).toHaveReceivedEventTimes(1);
});
test('should not emit ionInput event when programmatically setting the value', async ({ page }) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
const ionInput = await page.spyOnEvent('ionInput');
const inputOtp = page.locator('ion-input-otp');
await inputOtp.evaluate((el: HTMLIonInputOtpElement) => {
el.value = '1234';
});
await verifyInputValues(inputOtp, ['1', '2', '3', '4']);
await expect(ionInput).not.toHaveReceivedEvent();
});
});
test.describe(title('input-otp: events: ionChange'), () => {
test('should not emit ionChange event when typing', async ({ page }) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
const ionChange = await page.spyOnEvent('ionChange');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('12');
await expect(ionChange).not.toHaveReceivedEvent();
});
test('should emit ionChange event when pasting and then blurring', async ({ page }) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
const ionChange = await page.spyOnEvent('ionChange');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await simulatePaste(firstInput, '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 } });
await expect(ionChange).toHaveReceivedEventTimes(1);
});
test('should emit ionChange event when blurring with a new value', async ({ page }) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
const ionChange = await page.spyOnEvent('ionChange');
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).toHaveReceivedEvent();
await expect(ionChange).toHaveReceivedEventTimes(1);
});
test('should not emit ionChange event when blurring with the same value', async ({ page }) => {
await page.setContent(`<ion-input-otp value="12">Description</ion-input-otp>`, config);
const ionBlur = await page.spyOnEvent('ionBlur');
const ionChange = await page.spyOnEvent('ionChange');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
// Click outside the input to trigger the blur event
await page.mouse.click(0, 0);
await ionBlur.next();
await expect(ionBlur).toHaveReceivedEvent();
await expect(ionBlur).toHaveReceivedEventTimes(1);
await expect(ionChange).not.toHaveReceivedEvent();
});
test('should not emit ionChange event when programmatically setting the value', async ({ page }) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
const ionChange = await page.spyOnEvent('ionChange');
const inputOtp = page.locator('ion-input-otp');
await inputOtp.evaluate((el: HTMLIonInputOtpElement) => {
el.value = '1234';
});
await verifyInputValues(inputOtp, ['1', '2', '3', '4']);
await expect(ionChange).not.toHaveReceivedEvent();
});
});
test.describe(title('input-otp: events: ionComplete'), () => {
test('should emit ionComplete event when all input boxes are filled', async ({ page }) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
const ionComplete = await page.spyOnEvent('ionComplete');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.type('1234');
await ionComplete.next();
await expect(ionComplete).toHaveReceivedEventDetail({ value: '1234' });
await expect(ionComplete).toHaveReceivedEventTimes(1);
});
});
test.describe(title('input-otp: events: ionFocus'), () => {
test('should emit ionFocus event when input box is focused', async ({ page }) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
const ionFocus = await page.spyOnEvent('ionFocus');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await ionFocus.next();
await expect(ionFocus).toHaveReceivedEvent();
await expect(ionFocus).toHaveReceivedEventTimes(1);
});
test('should not emit ionFocus event when focus is moved to another input in the same component', async ({
page,
}) => {
await page.setContent(`<ion-input-otp value="1234">Description</ion-input-otp>`, config);
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
const ionFocus = await page.spyOnEvent('ionFocus');
await page.keyboard.press('ArrowRight');
await expect(ionFocus).not.toHaveReceivedEvent();
});
});
test.describe(title('input-otp: events: ionBlur'), () => {
test('should emit ionBlur event when focus leaves the component', async ({ page }) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
const ionBlur = await page.spyOnEvent('ionBlur');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
// Click outside the input to trigger the blur event
await page.mouse.click(0, 0);
await ionBlur.next();
await expect(ionBlur).toHaveReceivedEvent();
await expect(ionBlur).toHaveReceivedEventTimes(1);
});
test('should not emit ionBlur event when focus is moved to another input in the same component', async ({
page,
}) => {
await page.setContent(`<ion-input-otp value="1234">Description</ion-input-otp>`, config);
const ionBlur = await page.spyOnEvent('ionBlur');
const firstInput = page.locator('ion-input-otp input').first();
await firstInput.focus();
await page.keyboard.press('ArrowRight');
await expect(ionBlur).not.toHaveReceivedEvent();
});
});
});
/**
* Methods are the same across modes & directions
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('input-otp: setFocus method'), () => {
test('should not focus the specified input box when index is provided and value is not set', async ({ page }) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
await inputOtp.evaluate((el: HTMLIonInputOtpElement) => {
el.setFocus(2);
});
const thirdInput = page.locator('ion-input-otp input').nth(2);
await expect(thirdInput).not.toBeFocused();
});
test('should focus the specified input box when index is provided and value is set', async ({ page }) => {
await page.setContent(`<ion-input-otp value="1234">Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
await inputOtp.evaluate((el: HTMLIonInputOtpElement) => {
el.setFocus(2);
});
const thirdInput = page.locator('ion-input-otp input').nth(2);
await expect(thirdInput).toBeFocused();
});
test('should focus first empty input when no index is provided and not all inputs are filled', async ({ page }) => {
await page.setContent(`<ion-input-otp value="12">Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
await inputOtp.evaluate((el: HTMLIonInputOtpElement) => {
el.setFocus();
});
const thirdInput = page.locator('ion-input-otp input').nth(2);
await expect(thirdInput).toBeFocused();
});
test('should focus last input when no index is provided and all inputs are filled', async ({ page }) => {
await page.setContent(`<ion-input-otp value="1234">Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
await inputOtp.evaluate((el: HTMLIonInputOtpElement) => {
el.setFocus();
});
const lastInput = page.locator('ion-input-otp input').last();
await expect(lastInput).toBeFocused();
});
test('should clamp invalid indices to valid range', async ({ page }) => {
await page.setContent(`<ion-input-otp value="1234">Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
// Test negative index
await inputOtp.evaluate((el: HTMLIonInputOtpElement) => {
el.setFocus(-1);
});
const firstInput = page.locator('ion-input-otp input').first();
await expect(firstInput).toBeFocused();
// Test index beyond length
await inputOtp.evaluate((el: HTMLIonInputOtpElement) => {
el.setFocus(10);
});
const lastInput = page.locator('ion-input-otp input').last();
await expect(lastInput).toBeFocused();
});
});
});

View File

@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Input OTP - Color</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
h2 {
text-align: center;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
grid-row-gap: 20px;
grid-column-gap: 20px;
}
</style>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Input OTP - Color</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="grid">
<div class="grid-item">
<h2>Outline Colors</h2>
<ion-input-otp value="12" color="primary"></ion-input-otp>
<ion-input-otp value="12" color="secondary"></ion-input-otp>
<ion-input-otp value="12" color="tertiary"></ion-input-otp>
<ion-input-otp value="12" color="success"></ion-input-otp>
<ion-input-otp value="12" color="warning"></ion-input-otp>
<ion-input-otp value="12" color="danger"></ion-input-otp>
<ion-input-otp value="12" color="light"></ion-input-otp>
<ion-input-otp value="12" color="medium"></ion-input-otp>
<ion-input-otp value="12" color="dark"></ion-input-otp>
</div>
<div class="grid-item">
<h2>Solid Colors</h2>
<ion-input-otp value="12" fill="solid" color="primary"></ion-input-otp>
<ion-input-otp value="12" fill="solid" color="secondary"></ion-input-otp>
<ion-input-otp value="12" fill="solid" color="tertiary"></ion-input-otp>
<ion-input-otp value="12" fill="solid" color="success"></ion-input-otp>
<ion-input-otp value="12" fill="solid" color="warning"></ion-input-otp>
<ion-input-otp value="12" fill="solid" color="danger"></ion-input-otp>
<ion-input-otp value="12" fill="solid" color="light"></ion-input-otp>
<ion-input-otp value="12" fill="solid" color="medium"></ion-input-otp>
<ion-input-otp value="12" fill="solid" color="dark"></ion-input-otp>
</div>
<div class="grid-item">
<h2>Disabled</h2>
<ion-input-otp value="12" color="tertiary" disabled>Outline</ion-input-otp>
<ion-input-otp value="12" color="tertiary" fill="solid" disabled>Solid</ion-input-otp>
<h2>Readonly</h2>
<ion-input-otp value="12" color="tertiary" readonly>Outline</ion-input-otp>
<ion-input-otp value="12" color="tertiary" fill="solid" readonly>Solid</ion-input-otp>
</div>
<div class="grid-item">
<h2>Invalid / Touched</h2>
<ion-input-otp value="12" color="tertiary" class="ion-invalid ion-touched">Outline</ion-input-otp>
<ion-input-otp value="12" color="tertiary" fill="solid" class="ion-invalid ion-touched"
>Solid</ion-input-otp
>
<h2>Valid / Focused</h2>
<ion-input-otp value="12" color="tertiary" class="ion-valid has-focus">Outline</ion-input-otp>
<ion-input-otp value="12" color="tertiary" fill="solid" class="ion-valid has-focus">Solid</ion-input-otp>
</div>
</div>
</ion-content>
</ion-app>
</body>
</html>

View File

@ -0,0 +1,81 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
const VALID_FILLS = ['outline', 'solid'];
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('input-otp: color'), () => {
// Test all colors with all fills
VALID_FILLS.forEach((fill) => {
test(`color with ${fill} fill should not have visual regressions`, async ({ page }) => {
await page.setContent(
`
<div id="container">
<ion-input-otp color="primary" fill="${fill}" value="12"></ion-input-otp>
<ion-input-otp color="secondary" fill="${fill}" value="12"></ion-input-otp>
<ion-input-otp color="tertiary" fill="${fill}" value="12"></ion-input-otp>
<ion-input-otp color="success" fill="${fill}" value="12"></ion-input-otp>
<ion-input-otp color="warning" fill="${fill}" value="12"></ion-input-otp>
<ion-input-otp color="danger" fill="${fill}" value="12"></ion-input-otp>
<ion-input-otp color="light" fill="${fill}" value="12"></ion-input-otp>
<ion-input-otp color="medium" fill="${fill}" value="12"></ion-input-otp>
<ion-input-otp color="dark" fill="${fill}" value="12"></ion-input-otp>
</div>
`,
config
);
const container = page.locator('#container');
// Set viewport size to ensure the entire height is visible
await page.setViewportSize({ width: 393, height: 900 });
await expect(container).toHaveScreenshot(screenshot(`input-otp-color-${fill}`));
});
test(`disabled color with ${fill} fill should not have visual regressions`, async ({ page }) => {
await page.setContent(
`npx
<div id="container">
<ion-input-otp color="primary" fill="${fill}" value="12" disabled></ion-input-otp>
<ion-input-otp color="secondary" fill="${fill}" value="12" disabled></ion-input-otp>
<ion-input-otp color="tertiary" fill="${fill}" value="12" disabled></ion-input-otp>
<ion-input-otp color="success" fill="${fill}" value="12" disabled></ion-input-otp>
<ion-input-otp color="warning" fill="${fill}" value="12" disabled></ion-input-otp>
<ion-input-otp color="danger" fill="${fill}" value="12" disabled></ion-input-otp>
<ion-input-otp color="light" fill="${fill}" value="12" disabled></ion-input-otp>
<ion-input-otp color="medium" fill="${fill}" value="12" disabled></ion-input-otp>
<ion-input-otp color="dark" fill="${fill}" value="12" disabled></ion-input-otp>
</div>
`,
config
);
const container = page.locator('#container');
// Set viewport size to ensure the entire height is visible
await page.setViewportSize({ width: 393, height: 900 });
await expect(container).toHaveScreenshot(screenshot(`input-otp-color-${fill}-disabled`));
});
test(`readonly color with ${fill} fill should not have visual regressions`, async ({ page }) => {
await page.setContent(
`
<div id="container">
<ion-input-otp color="primary" fill="${fill}" value="12" readonly></ion-input-otp>
<ion-input-otp color="secondary" fill="${fill}" value="12" readonly></ion-input-otp>
<ion-input-otp color="tertiary" fill="${fill}" value="12" readonly></ion-input-otp>
<ion-input-otp color="success" fill="${fill}" value="12" readonly></ion-input-otp>
<ion-input-otp color="warning" fill="${fill}" value="12" readonly></ion-input-otp>
<ion-input-otp color="danger" fill="${fill}" value="12" readonly></ion-input-otp>
<ion-input-otp color="light" fill="${fill}" value="12" readonly></ion-input-otp>
<ion-input-otp color="medium" fill="${fill}" value="12" readonly></ion-input-otp>
<ion-input-otp color="dark" fill="${fill}" value="12" readonly></ion-input-otp>
</div>
`,
config
);
const container = page.locator('#container');
// Set viewport size to ensure the entire height is visible
await page.setViewportSize({ width: 393, height: 900 });
await expect(container).toHaveScreenshot(screenshot(`input-otp-color-${fill}-readonly`));
});
});
});
});

View File

@ -0,0 +1,44 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
const VALID_FILLS = ['outline', 'solid'];
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('input-otp: fill'), () => {
VALID_FILLS.forEach((fill) => {
test(`${fill} fill should not have visual regressions`, async ({ page }) => {
await page.setContent(
`
<ion-input-otp fill="${fill}" value="12">Description</ion-input-otp>
`,
config
);
const inputOtp = page.locator('ion-input-otp');
await expect(inputOtp).toHaveScreenshot(screenshot(`input-otp-${fill}`));
});
test(`disabled ${fill} fill should not have visual regressions`, async ({ page }) => {
await page.setContent(
`
<ion-input-otp fill="${fill}" value="12" disabled>Description</ion-input-otp>
`,
config
);
const inputOtp = page.locator('ion-input-otp');
await expect(inputOtp).toHaveScreenshot(screenshot(`input-otp-${fill}-disabled`));
});
test(`readonly ${fill} fill should not have visual regressions`, async ({ page }) => {
await page.setContent(
`
<ion-input-otp fill="${fill}" value="12" readonly>Description</ion-input-otp>
`,
config
);
const inputOtp = page.locator('ion-input-otp');
await expect(inputOtp).toHaveScreenshot(screenshot(`input-otp-${fill}-readonly`));
});
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Input OTP - Separators</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
h2 {
text-align: center;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
grid-row-gap: 20px;
grid-column-gap: 20px;
}
</style>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Input OTP - Separators</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="grid">
<div class="grid-item">
<h2>Separators (small)</h2>
<ion-input-otp size="small" length="6"> Enter your one-time password (numbers only) </ion-input-otp>
<ion-input-otp size="small" length="6" separators="1">
Enter your one-time password (numbers only)
</ion-input-otp>
<ion-input-otp size="small" length="6" separators="2">
Enter your one-time password (numbers only)
</ion-input-otp>
<ion-input-otp size="small" length="6" separators="3">
Enter your one-time password (numbers only)
</ion-input-otp>
<ion-input-otp size="small" length="6" separators="4">
Enter your one-time password (numbers only)
</ion-input-otp>
<ion-input-otp size="small" length="6" separators="5">
Enter your one-time password (numbers only)
</ion-input-otp>
<ion-input-otp size="small" separators="all" length="6"></ion-input-otp>
</div>
<div class="grid-item">
<h2>Separators (medium)</h2>
<ion-input-otp size="medium" length="6"> Enter your one-time password (numbers only) </ion-input-otp>
<ion-input-otp size="medium" length="6" separators="1">
Enter your one-time password (numbers only)
</ion-input-otp>
<ion-input-otp size="medium" length="6" separators="2">
Enter your one-time password (numbers only)
</ion-input-otp>
<ion-input-otp size="medium" length="6" separators="3">
Enter your one-time password (numbers only)
</ion-input-otp>
<ion-input-otp size="medium" length="6" separators="4">
Enter your one-time password (numbers only)
</ion-input-otp>
<ion-input-otp size="medium" length="6" separators="5">
Enter your one-time password (numbers only)
</ion-input-otp>
<ion-input-otp size="medium" separators="all" length="6"></ion-input-otp>
</div>
<div class="grid-item">
<h2>Separators (large)</h2>
<ion-input-otp size="large" length="6"> Enter your one-time password (numbers only) </ion-input-otp>
<ion-input-otp size="large" length="6" separators="1">
Enter your one-time password (numbers only)
</ion-input-otp>
<ion-input-otp size="large" length="6" separators="2">
Enter your one-time password (numbers only)
</ion-input-otp>
<ion-input-otp size="large" length="6" separators="3">
Enter your one-time password (numbers only)
</ion-input-otp>
<ion-input-otp size="large" length="6" separators="4">
Enter your one-time password (numbers only)
</ion-input-otp>
<ion-input-otp size="large" length="6" separators="5">
Enter your one-time password (numbers only)
</ion-input-otp>
<ion-input-otp size="large" separators="all" length="6"></ion-input-otp>
</div>
</div>
</ion-content>
</ion-app>
</body>
</html>

View File

@ -0,0 +1,180 @@
import type { ConsoleMessage } from '@playwright/test';
import { expect } from '@playwright/test';
import type { E2EPage } from '@utils/test/playwright';
import { configs, test } from '@utils/test/playwright';
const DEFAULT_INPUT_LENGTH = 4;
const VALID_SIZES = ['small', 'medium', 'large'];
/**
* Helper function to check if the next sibling after
* the input box is a separator
*/
const hasSeparatorAfter = async (page: E2EPage, index: number): Promise<boolean> => {
const wrappers = page.locator('.input-otp-group > .native-wrapper');
return await wrappers
.nth(index)
.evaluate((el: Element) => el.nextElementSibling?.classList.contains('input-otp-separator') ?? false);
};
/**
* Helper function to collect console warnings
*/
const collectWarnings = async (page: E2EPage): Promise<string[]> => {
const warnings: string[] = [];
page.on('console', (ev: ConsoleMessage) => {
if (ev.type() === 'warning') {
warnings.push(ev.text());
}
});
return warnings;
};
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('input-otp: separators'), () => {
// Test separators with all sizes
VALID_SIZES.forEach((size) => {
test(`one separator with ${size} size should not have visual regressions`, async ({ page }) => {
await page.setContent(
`
<ion-input-otp size="${size}" value="12" separators="1">Description</ion-input-otp>
`,
config
);
const inputOtp = page.locator('ion-input-otp');
await expect(inputOtp).toHaveScreenshot(screenshot(`input-otp-separators-one-${size}`));
});
test(`two separators with ${size} size should not have visual regressions`, async ({ page }) => {
await page.setContent(
`
<ion-input-otp size="${size}" value="12" length="6" separators="2,4">Description</ion-input-otp>
`,
config
);
const inputOtp = page.locator('ion-input-otp');
await expect(inputOtp).toHaveScreenshot(screenshot(`input-otp-separators-two-${size}`));
});
test(`all separators with ${size} size should not have visual regressions`, async ({ page }) => {
await page.setContent(
`
<ion-input-otp size="${size}" value="12" separators="all">Description</ion-input-otp>
`,
config
);
const inputOtp = page.locator('ion-input-otp');
await expect(inputOtp).toHaveScreenshot(screenshot(`input-otp-separators-all-${size}`));
});
});
});
});
/**
* Functionality is the same across modes and directions
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('input-otp: separators functionality'), () => {
test('should render separators after the first and third input box', async ({ page }) => {
await page.setContent(`<ion-input-otp separators="1,3">Description</ion-input-otp>`, config);
await expect(await hasSeparatorAfter(page, 0)).toBe(true);
await expect(await hasSeparatorAfter(page, 1)).toBe(false);
await expect(await hasSeparatorAfter(page, 2)).toBe(true);
await expect(await hasSeparatorAfter(page, 3)).toBe(false);
});
test('should render separators after the second and third input box', async ({ page }) => {
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
const inputOtp = page.locator('ion-input-otp');
await inputOtp.evaluate((el: HTMLIonInputOtpElement) => {
el.separators = [2, 3];
});
await expect(await hasSeparatorAfter(page, 0)).toBe(false);
await expect(await hasSeparatorAfter(page, 1)).toBe(true);
await expect(await hasSeparatorAfter(page, 2)).toBe(true);
await expect(await hasSeparatorAfter(page, 3)).toBe(false);
});
test('should render all separators', async ({ page }) => {
await page.setContent(`<ion-input-otp separators="all">Description</ion-input-otp>`, config);
await expect(await hasSeparatorAfter(page, 0)).toBe(true);
await expect(await hasSeparatorAfter(page, 1)).toBe(true);
await expect(await hasSeparatorAfter(page, 2)).toBe(true);
await expect(await hasSeparatorAfter(page, 3)).toBe(false);
});
test('should handle empty separators string', async ({ page }) => {
await page.setContent(`<ion-input-otp separators="">Description</ion-input-otp>`, config);
await expect(await hasSeparatorAfter(page, 0)).toBe(false);
await expect(await hasSeparatorAfter(page, 1)).toBe(false);
await expect(await hasSeparatorAfter(page, 2)).toBe(false);
await expect(await hasSeparatorAfter(page, 3)).toBe(false);
});
test('should warn when setting separators to a position greater than the input length', async ({ page }) => {
const warnings = await collectWarnings(page);
await page.setContent(`<ion-input-otp separators="1,3,5,6,7">Description</ion-input-otp>`, config);
expect(warnings.length).toBe(1);
expect(warnings[0]).toContain(
`[Ionic Warning]: [ion-input-otp] - The following separator positions are greater than the input length (${DEFAULT_INPUT_LENGTH}): 5, 6, 7. These separators will be ignored.`
);
});
test('should warn when setting separators to an invalid space-separated string', async ({ page }) => {
const warnings = await collectWarnings(page);
const invalidSeparators = '1 2 3';
await page.setContent(`<ion-input-otp separators="${invalidSeparators}">Description</ion-input-otp>`, config);
expect(warnings.length).toBe(1);
expect(warnings[0]).toContain(
`[Ionic Warning]: [ion-input-otp] - Invalid separators format. Expected a comma-separated list of numbers, an array of numbers, or "all". Received: ${invalidSeparators}`
);
});
test('should warn when setting separators to an invalid comma-separated string', async ({ page }) => {
const warnings = await collectWarnings(page);
const invalidSeparators = '1,d,3';
await page.setContent(`<ion-input-otp separators="${invalidSeparators}">Description</ion-input-otp>`, config);
expect(warnings.length).toBe(1);
expect(warnings[0]).toContain(
`[Ionic Warning]: [ion-input-otp] - Invalid separators format. Expected a comma-separated list of numbers, an array of numbers, or "all". Received: ${invalidSeparators}`
);
});
test('should warn when setting separators to negative numbers', async ({ page }) => {
const warnings = await collectWarnings(page);
const invalidSeparators = '-1,2,3';
await page.setContent(`<ion-input-otp separators="${invalidSeparators}">Description</ion-input-otp>`, config);
expect(warnings.length).toBe(1);
expect(warnings[0]).toContain(
`[Ionic Warning]: [ion-input-otp] - Invalid separators format. Expected a comma-separated list of numbers, an array of numbers, or "all". Received: ${invalidSeparators}`
);
});
test('should warn when setting separators to duplicate positions', async ({ page }) => {
const warnings = await collectWarnings(page);
const invalidSeparators = '1,1,2';
await page.setContent(`<ion-input-otp separators="${invalidSeparators}">Description</ion-input-otp>`, config);
expect(warnings.length).toBe(1);
expect(warnings[0]).toContain(
`[Ionic Warning]: [ion-input-otp] - Duplicate separator positions are not allowed. Received: ${invalidSeparators}`
);
});
});
});

Some files were not shown because too many files have changed in this diff Show More