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>
23
core/src/components/input-otp/input-otp-interface.ts
Normal 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;
|
||||
}
|
||||
20
core/src/components/input-otp/input-otp.ios.scss
Normal 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};
|
||||
}
|
||||
20
core/src/components/input-otp/input-otp.md.scss
Normal 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};
|
||||
}
|
||||
307
core/src/components/input-otp/input-otp.scss
Normal 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);
|
||||
}
|
||||
789
core/src/components/input-otp/input-otp.tsx
Normal 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;
|
||||
109
core/src/components/input-otp/test/a11y/input-otp.e2e.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
125
core/src/components/input-otp/test/basic/index.html
Normal 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>
|
||||
947
core/src/components/input-otp/test/basic/input-otp.e2e.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
91
core/src/components/input-otp/test/color/index.html
Normal 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>
|
||||
81
core/src/components/input-otp/test/color/input-otp.e2e.ts
Normal 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`));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 14 KiB |
44
core/src/components/input-otp/test/fill/input-otp.e2e.ts
Normal 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`));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
106
core/src/components/input-otp/test/separators/index.html
Normal 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>
|
||||
180
core/src/components/input-otp/test/separators/input-otp.e2e.ts
Normal 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}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |