Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 30 commits into
base: feature-8.6
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
12b7500
feat(input-otp): add input-otp component
brandyscarney Apr 30, 2025
231b2b0
test(input-otp): add tests for different props
brandyscarney Apr 30, 2025
eb1a8d5
fix(input-otp): fix styles for sizes, spaces and separators
brandyscarney Apr 30, 2025
ce38d95
style: lint
brandyscarney Apr 30, 2025
ee6f29d
fix(input-otp): design fixes and invalid state
brandyscarney May 1, 2025
d5c7c22
Merge branch 'feature-8.6' into FW-6410
brandyscarney May 1, 2025
2d0441c
fix(input-otp): update implementation to match input's more closely
brandyscarney May 1, 2025
5214878
feat(input-otp): add readonly support
brandyscarney May 1, 2025
30eed51
feat(input-otp): support disabled and readonly colors
brandyscarney May 1, 2025
46e69c8
fix(input-otp): resolve a11y error with input labels
brandyscarney May 1, 2025
2e2eebd
refactor(input-otp): clean up and build
brandyscarney May 1, 2025
fcb8e1d
test(input-otp): add tests for basic, color, fill, separators, shape,…
brandyscarney May 2, 2025
3e4fe5a
fix(input-otp): shift input boxes over 1 when clearing a value in the…
brandyscarney May 2, 2025
aeace76
refactor(input-otp): rename allowedKeys to pattern
brandyscarney May 2, 2025
5c7a311
fix(input-otp): paste from first empty box or first box if all are fi…
brandyscarney May 5, 2025
b260644
test(input-otp): paste functionality
brandyscarney May 5, 2025
9aaeef2
fix(input-otp): improve separator collapse
brandyscarney May 5, 2025
6175dfb
fix(input-otp): move focus to last input when all are filled
brandyscarney May 5, 2025
bb7065b
fix(input-otp): always start value at first empty input when typing
brandyscarney May 5, 2025
ecf1c11
fix(input-otp): allow entering keys in the middle of the input group
brandyscarney May 6, 2025
46a3fb7
refactor(input-otp): return when pasted text is empty
brandyscarney May 6, 2025
97649a0
fix(input-otp): respect case when checking pattern
brandyscarney May 6, 2025
eb6ae3f
test(input-otp): add another focus test
brandyscarney May 6, 2025
9b39100
fix(input-otp): update emitted events to match the input component
brandyscarney May 7, 2025
1666307
fix(input-otp): handle arrow navigation in RTL and add RTL tests
brandyscarney May 7, 2025
f9a33ae
test(input): revert logs
brandyscarney May 7, 2025
a9a31ab
fix(input-otp): print a warning when separators exceed length
brandyscarney May 7, 2025
618575d
fix(input-otp): warn for incorrectly formatted separator strings
brandyscarney May 7, 2025
344c72a
feat(input-otp): expose reset and setFocus methods
brandyscarney May 8, 2025
c4743fe
fix(input-otp): move separator next to native wrapper to properly col…
brandyscarney May 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions core/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,73 @@ ion-input,css-prop,--placeholder-font-weight,md
ion-input,css-prop,--placeholder-opacity,ios
ion-input,css-prop,--placeholder-opacity,md

ion-input-otp,scoped
ion-input-otp,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "secondary" | "success" | "tertiary" | "warning" | string & Record<never, never> | undefined,undefined,false,true
ion-input-otp,prop,disabled,boolean,false,false,true
ion-input-otp,prop,fill,"outline" | "solid" | undefined,'outline',false,false
ion-input-otp,prop,inputmode,"decimal" | "email" | "none" | "numeric" | "search" | "tel" | "text" | "url" | undefined,undefined,false,false
ion-input-otp,prop,length,number,4,false,false
ion-input-otp,prop,pattern,string | undefined,undefined,false,false
ion-input-otp,prop,readonly,boolean,false,false,true
ion-input-otp,prop,separators,number[] | string | undefined,undefined,false,false
ion-input-otp,prop,shape,"rectangular" | "round" | "soft",'round',false,false
ion-input-otp,prop,size,"large" | "medium" | "small",'medium',false,false
ion-input-otp,prop,type,"number" | "text",'number',false,false
ion-input-otp,prop,value,null | number | string | undefined,'',false,false
ion-input-otp,method,reset,reset() => Promise<void>
ion-input-otp,method,setFocus,setFocus(index?: number) => Promise<void>
ion-input-otp,event,ionBlur,FocusEvent,true
ion-input-otp,event,ionChange,InputOtpChangeEventDetail,true
ion-input-otp,event,ionComplete,InputOtpCompleteEventDetail,true
ion-input-otp,event,ionFocus,FocusEvent,true
ion-input-otp,event,ionInput,InputOtpInputEventDetail,true
ion-input-otp,css-prop,--background,ios
ion-input-otp,css-prop,--background,md
ion-input-otp,css-prop,--background-focused,ios
ion-input-otp,css-prop,--background-focused,md
ion-input-otp,css-prop,--border-color,ios
ion-input-otp,css-prop,--border-color,md
ion-input-otp,css-prop,--border-color-focused,ios
ion-input-otp,css-prop,--border-color-focused,md
ion-input-otp,css-prop,--border-radius,ios
ion-input-otp,css-prop,--border-radius,md
ion-input-otp,css-prop,--border-width,ios
ion-input-otp,css-prop,--border-width,md
ion-input-otp,css-prop,--color,ios
ion-input-otp,css-prop,--color,md
ion-input-otp,css-prop,--height,ios
ion-input-otp,css-prop,--height,md
ion-input-otp,css-prop,--highlight-color-invalid,ios
ion-input-otp,css-prop,--highlight-color-invalid,md
ion-input-otp,css-prop,--margin-bottom,ios
ion-input-otp,css-prop,--margin-bottom,md
ion-input-otp,css-prop,--margin-end,ios
ion-input-otp,css-prop,--margin-end,md
ion-input-otp,css-prop,--margin-start,ios
ion-input-otp,css-prop,--margin-start,md
ion-input-otp,css-prop,--margin-top,ios
ion-input-otp,css-prop,--margin-top,md
ion-input-otp,css-prop,--min-width,ios
ion-input-otp,css-prop,--min-width,md
ion-input-otp,css-prop,--padding-bottom,ios
ion-input-otp,css-prop,--padding-bottom,md
ion-input-otp,css-prop,--padding-end,ios
ion-input-otp,css-prop,--padding-end,md
ion-input-otp,css-prop,--padding-start,ios
ion-input-otp,css-prop,--padding-start,md
ion-input-otp,css-prop,--padding-top,ios
ion-input-otp,css-prop,--padding-top,md
ion-input-otp,css-prop,--separator-border-radius,ios
ion-input-otp,css-prop,--separator-border-radius,md
ion-input-otp,css-prop,--separator-color,ios
ion-input-otp,css-prop,--separator-color,md
ion-input-otp,css-prop,--separator-height,ios
ion-input-otp,css-prop,--separator-height,md
ion-input-otp,css-prop,--separator-width,ios
ion-input-otp,css-prop,--separator-width,md
ion-input-otp,css-prop,--width,ios
ion-input-otp,css-prop,--width,md

ion-input-password-toggle,shadow
ion-input-password-toggle,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "secondary" | "success" | "tertiary" | "warning" | string & Record<never, never> | undefined,undefined,false,true
ion-input-password-toggle,prop,hideIcon,string | undefined,undefined,false,false
Expand Down
159 changes: 159 additions & 0 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { ScrollBaseDetail, ScrollDetail } from "./components/content/content-int
import { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface";
import { SpinnerTypes } from "./components/spinner/spinner-configs";
import { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
import { InputOtpChangeEventDetail, InputOtpCompleteEventDetail, InputOtpInputEventDetail } from "./components/input-otp/input-otp-interface";
import { MenuChangeEventDetail, MenuCloseEventDetail, MenuType, Side } from "./components/menu/menu-interface";
import { ModalBreakpointChangeEventDetail, ModalHandleBehavior } from "./components/modal/modal-interface";
import { NavComponent, NavComponentWithProps, NavOptions, RouterOutletOptions, SwipeGestureHandler, TransitionDoneFn, TransitionInstruction } from "./components/nav/nav-interface";
Expand Down Expand Up @@ -55,6 +56,7 @@ export { ScrollBaseDetail, ScrollDetail } from "./components/content/content-int
export { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface";
export { SpinnerTypes } from "./components/spinner/spinner-configs";
export { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
export { InputOtpChangeEventDetail, InputOtpCompleteEventDetail, InputOtpInputEventDetail } from "./components/input-otp/input-otp-interface";
export { MenuChangeEventDetail, MenuCloseEventDetail, MenuType, Side } from "./components/menu/menu-interface";
export { ModalBreakpointChangeEventDetail, ModalHandleBehavior } from "./components/modal/modal-interface";
export { NavComponent, NavComponentWithProps, NavOptions, RouterOutletOptions, SwipeGestureHandler, TransitionDoneFn, TransitionInstruction } from "./components/nav/nav-interface";
Expand Down Expand Up @@ -1319,6 +1321,65 @@ export namespace Components {
*/
"value"?: string | number | null;
}
interface IonInputOtp {
/**
* 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).
*/
"color"?: Color;
/**
* If `true`, the user cannot interact with the input.
*/
"disabled": boolean;
/**
* 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.
*/
"fill"?: 'outline' | 'solid';
/**
* 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"
*/
"inputmode"?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search';
/**
* The number of input boxes to display.
*/
"length": number;
/**
* A regex pattern string for allowed characters. Defaults based on type. For numbers (type="number"): "[0-9]" For text (type="text"): "[a-zA-Z0-9]"
*/
"pattern"?: string;
/**
* If `true`, the user cannot modify the value.
*/
"readonly": boolean;
/**
* Resets the input values and focus state.
*/
"reset": () => Promise<void>;
/**
* 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.
*/
"separators"?: 'all' | string | number[];
/**
* Sets focus to an input box.
* @param index The index of the input box to focus. If not provided, focuses the first empty input box or the last input if all are filled. The input boxes start at index 0.
*/
"setFocus": (index?: number) => Promise<void>;
/**
* 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.
*/
"shape": 'round' | 'rectangular' | 'soft';
/**
* The size of the input boxes.
*/
"size": 'small' | 'medium' | 'large';
/**
* The type of input allowed in the input boxes.
*/
"type": 'text' | 'number';
/**
* The value of the input group.
*/
"value"?: string | number | null;
}
interface IonInputPasswordToggle {
/**
* 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).
Expand Down Expand Up @@ -3404,6 +3465,10 @@ export interface IonInputCustomEvent<T> extends CustomEvent<T> {
detail: T;
target: HTMLIonInputElement;
}
export interface IonInputOtpCustomEvent<T> extends CustomEvent<T> {
detail: T;
target: HTMLIonInputOtpElement;
}
export interface IonItemOptionsCustomEvent<T> extends CustomEvent<T> {
detail: T;
target: HTMLIonItemOptionsElement;
Expand Down Expand Up @@ -3933,6 +3998,27 @@ declare global {
prototype: HTMLIonInputElement;
new (): HTMLIonInputElement;
};
interface HTMLIonInputOtpElementEventMap {
"ionInput": InputOtpInputEventDetail;
"ionChange": InputOtpChangeEventDetail;
"ionComplete": InputOtpCompleteEventDetail;
"ionBlur": FocusEvent;
"ionFocus": FocusEvent;
}
interface HTMLIonInputOtpElement extends Components.IonInputOtp, HTMLStencilElement {
addEventListener<K extends keyof HTMLIonInputOtpElementEventMap>(type: K, listener: (this: HTMLIonInputOtpElement, ev: IonInputOtpCustomEvent<HTMLIonInputOtpElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
removeEventListener<K extends keyof HTMLIonInputOtpElementEventMap>(type: K, listener: (this: HTMLIonInputOtpElement, ev: IonInputOtpCustomEvent<HTMLIonInputOtpElementEventMap[K]>) => any, options?: boolean | EventListenerOptions): void;
removeEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
removeEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
}
var HTMLIonInputOtpElement: {
prototype: HTMLIonInputOtpElement;
new (): HTMLIonInputOtpElement;
};
interface HTMLIonInputPasswordToggleElement extends Components.IonInputPasswordToggle, HTMLStencilElement {
}
var HTMLIonInputPasswordToggleElement: {
Expand Down Expand Up @@ -4792,6 +4878,7 @@ declare global {
"ion-infinite-scroll": HTMLIonInfiniteScrollElement;
"ion-infinite-scroll-content": HTMLIonInfiniteScrollContentElement;
"ion-input": HTMLIonInputElement;
"ion-input-otp": HTMLIonInputOtpElement;
"ion-input-password-toggle": HTMLIonInputPasswordToggleElement;
"ion-item": HTMLIonItemElement;
"ion-item-divider": HTMLIonItemDividerElement;
Expand Down Expand Up @@ -6178,6 +6265,76 @@ declare namespace LocalJSX {
*/
"value"?: string | number | null;
}
interface IonInputOtp {
/**
* 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).
*/
"color"?: Color;
/**
* If `true`, the user cannot interact with the input.
*/
"disabled"?: boolean;
/**
* 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.
*/
"fill"?: 'outline' | 'solid';
/**
* 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"
*/
"inputmode"?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search';
/**
* The number of input boxes to display.
*/
"length"?: number;
/**
* Emitted when the input group loses focus.
*/
"onIonBlur"?: (event: IonInputOtpCustomEvent<FocusEvent>) => void;
/**
* 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.
*/
"onIonChange"?: (event: IonInputOtpCustomEvent<InputOtpChangeEventDetail>) => void;
/**
* Emitted when all input boxes have been filled with valid values.
*/
"onIonComplete"?: (event: IonInputOtpCustomEvent<InputOtpCompleteEventDetail>) => void;
/**
* Emitted when the input group has focus.
*/
"onIonFocus"?: (event: IonInputOtpCustomEvent<FocusEvent>) => void;
/**
* 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`.
*/
"onIonInput"?: (event: IonInputOtpCustomEvent<InputOtpInputEventDetail>) => void;
/**
* A regex pattern string for allowed characters. Defaults based on type. For numbers (type="number"): "[0-9]" For text (type="text"): "[a-zA-Z0-9]"
*/
"pattern"?: string;
/**
* If `true`, the user cannot modify the value.
*/
"readonly"?: boolean;
/**
* 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.
*/
"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.
*/
"shape"?: 'round' | 'rectangular' | 'soft';
/**
* The size of the input boxes.
*/
"size"?: 'small' | 'medium' | 'large';
/**
* The type of input allowed in the input boxes.
*/
"type"?: 'text' | 'number';
/**
* The value of the input group.
*/
"value"?: string | number | null;
}
interface IonInputPasswordToggle {
/**
* 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).
Expand Down Expand Up @@ -8309,6 +8466,7 @@ declare namespace LocalJSX {
"ion-infinite-scroll": IonInfiniteScroll;
"ion-infinite-scroll-content": IonInfiniteScrollContent;
"ion-input": IonInput;
"ion-input-otp": IonInputOtp;
"ion-input-password-toggle": IonInputPasswordToggle;
"ion-item": IonItem;
"ion-item-divider": IonItemDivider;
Expand Down Expand Up @@ -8411,6 +8569,7 @@ declare module "@stencil/core" {
"ion-infinite-scroll": LocalJSX.IonInfiniteScroll & JSXBase.HTMLAttributes<HTMLIonInfiniteScrollElement>;
"ion-infinite-scroll-content": LocalJSX.IonInfiniteScrollContent & JSXBase.HTMLAttributes<HTMLIonInfiniteScrollContentElement>;
"ion-input": LocalJSX.IonInput & JSXBase.HTMLAttributes<HTMLIonInputElement>;
"ion-input-otp": LocalJSX.IonInputOtp & JSXBase.HTMLAttributes<HTMLIonInputOtpElement>;
"ion-input-password-toggle": LocalJSX.IonInputPasswordToggle & JSXBase.HTMLAttributes<HTMLIonInputPasswordToggleElement>;
"ion-item": LocalJSX.IonItem & JSXBase.HTMLAttributes<HTMLIonItemElement>;
"ion-item-divider": LocalJSX.IonItemDivider & JSXBase.HTMLAttributes<HTMLIonItemDividerElement>;
Expand Down
23 changes: 23 additions & 0 deletions core/src/components/input-otp/input-otp-interface.ts
Original file line number Diff line number Diff line change
@@ -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;
}
17 changes: 17 additions & 0 deletions core/src/components/input-otp/input-otp.ios.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@import "./input-otp";
@import "../../themes/ionic.globals.ios";

// iOS Input OTP
// --------------------------------------------------

:host {
--border-width: 0.55px;
}

.native-input {
-webkit-appearance: none;

&:focus {
border-width: 1px;
}
}
19 changes: 19 additions & 0 deletions core/src/components/input-otp/input-otp.md.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
@import "./input-otp";
@import "../../themes/ionic.globals.md";

// Material Design Input OTP
// --------------------------------------------------

:host {
--border-width: 1px;
}

.native-input {
transition: border-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);

&:focus {
border-width: 2px;
}
}

Loading
Loading