Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions core/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -784,9 +784,12 @@ ion-input-otp,scoped
ion-input-otp,prop,autocapitalize,string,'off',false,false
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,errorText,string | undefined,undefined,false,false
ion-input-otp,prop,fill,"outline" | "solid" | undefined,'outline',false,false
ion-input-otp,prop,helperText,string | undefined,undefined,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,mode,"ios" | "md",undefined,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
Expand Down
24 changes: 24 additions & 0 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1457,11 +1457,19 @@ export namespace Components {
* @default false
*/
"disabled": boolean;
/**
* Text that is placed under the input boxes and displayed when an error is detected.
*/
"errorText"?: string;
/**
* 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.
* @default 'outline'
*/
"fill"?: 'outline' | 'solid';
/**
* Text that is placed under the input and displayed when no error is detected.
*/
"helperText"?: string;
/**
* 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"
*/
Expand All @@ -1471,6 +1479,10 @@ export namespace Components {
* @default 4
*/
"length": number;
/**
* The mode determines which platform styles to use.
*/
"mode"?: "ios" | "md";
/**
* 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}]"`
*/
Expand Down Expand Up @@ -6739,11 +6751,19 @@ declare namespace LocalJSX {
* @default false
*/
"disabled"?: boolean;
/**
* Text that is placed under the input boxes and displayed when an error is detected.
*/
"errorText"?: string;
/**
* 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.
* @default 'outline'
*/
"fill"?: 'outline' | 'solid';
/**
* Text that is placed under the input and displayed when no error is detected.
*/
"helperText"?: string;
/**
* 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"
*/
Expand All @@ -6753,6 +6773,10 @@ declare namespace LocalJSX {
* @default 4
*/
"length"?: number;
/**
* The mode determines which platform styles to use.
*/
"mode"?: "ios" | "md";
/**
* Emitted when the input group loses focus.
*/
Expand Down
42 changes: 37 additions & 5 deletions core/src/components/input-otp/input-otp.scss
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,15 @@
// Input Description
// ----------------------------------------------------------------

.input-otp-description {
.input-otp-description-hidden {
display: none;
}

// Input Description & Bottom Content
// ----------------------------------------------------------------

.input-otp-description,
.input-otp-bottom {
color: $text-color-step-300;

font-size: dynamic-font(12px);
Expand All @@ -130,10 +138,6 @@
text-align: center;
}

.input-otp-description-hidden {
display: none;
}

// Input Separator
// ----------------------------------------------------------------

Expand Down Expand Up @@ -271,6 +275,34 @@
--border-color: var(--highlight-color);
}

// Input Hint Text
// ----------------------------------------------------------------

/**
* Error text should only be shown when .ion-invalid is
* present on the input. Otherwise the helper text should
* be shown.
*/
.input-otp-bottom .error-text {
display: none;

color: var(--highlight-color-invalid);
}

.input-otp-bottom .helper-text {
display: block;

color: $text-color-step-300;
}

:host(.ion-touched.ion-invalid) .input-otp-bottom .error-text {
display: block;
}

:host(.ion-touched.ion-invalid) .input-otp-bottom .helper-text {
display: none;
}

// Colors
// ----------------------------------------------------------------

Expand Down
122 changes: 120 additions & 2 deletions core/src/components/input-otp/input-otp.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Fragment, Host, Prop, State, h, Watch } from '@stencil/core';
import { Build, Component, Element, Event, Fragment, Host, Prop, State, h, Watch, forceUpdate } from '@stencil/core';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes } from '@utils/helpers';
import { printIonWarning } from '@utils/logging';
Expand All @@ -16,6 +16,10 @@ import type {
InputOtpInputEventDetail,
} from './input-otp-interface';

/**
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
* @slot - The default slot is for the input-otp's description.
*/
@Component({
tag: 'ion-input-otp',
styleUrls: {
Expand All @@ -28,7 +32,10 @@ export class InputOTP implements ComponentInterface {
private inheritedAttributes: Attributes = {};
private inputRefs: HTMLInputElement[] = [];
private inputId = `ion-input-otp-${inputIds++}`;
private helperTextId = `${this.inputId}-helper-text`;
private errorTextId = `${this.inputId}-error-text`;
private parsedSeparators: number[] = [];
private validationObserver?: MutationObserver;

/**
* Stores the initial value of the input when it receives focus.
Expand All @@ -50,6 +57,11 @@ export class InputOTP implements ComponentInterface {
@State() hasFocus = false;
@State() private previousInputValues: string[] = [];

/**
* Track validation state for proper aria-live announcements
*/
@State() isInvalid = 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"`.
Expand Down Expand Up @@ -136,6 +148,16 @@ export class InputOTP implements ComponentInterface {
*/
@Prop({ mutable: true }) value?: string | number | null = '';

/**
* Text that is placed under the input and displayed when no error is detected.
*/
@Prop() helperText?: string;

/**
* Text that is placed under the input boxes and displayed when an error is detected.
*/
@Prop() errorText?: string;

/**
* 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
Expand Down Expand Up @@ -263,6 +285,30 @@ export class InputOTP implements ComponentInterface {
this.parsedSeparators = separatorValues.filter((pos) => pos <= length);
}

connectedCallback() {
const { el } = this;

// Watch for class changes to update validation state
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
this.validationObserver = new MutationObserver(() => {
const newIsInvalid = this.checkInvalidState();
if (this.isInvalid !== newIsInvalid) {
this.isInvalid = newIsInvalid;
// Force a re-render to update aria-describedby immediately
forceUpdate(this);
}
});

this.validationObserver.observe(el, {
attributes: true,
attributeFilter: ['class'],
});
}

// Always set initial state
this.isInvalid = this.checkInvalidState();
}

componentWillLoad() {
this.inheritedAttributes = inheritAriaAttributes(this.el);
this.processSeparators();
Expand Down Expand Up @@ -781,6 +827,70 @@ export class InputOTP implements ComponentInterface {
return this.parsedSeparators.includes(index + 1) && index < length - 1;
}

/**
* Renders the helper text or error text values
*/
private renderHintText() {
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;

return [
<div id={helperTextId} class="helper-text" aria-live="polite">
{!isInvalid ? helperText : null}
</div>,
<div id={errorTextId} class="error-text" role="alert">
{isInvalid ? errorText : null}
</div>,
];
}

private getHintTextID(): string | undefined {
const { isInvalid, helperText, errorText, helperTextId, errorTextId } = this;

if (isInvalid && errorText) {
return errorTextId;
}

if (helperText) {
return helperTextId;
}

return undefined;
}

/**
* Responsible for rendering helper text and
* error text. This element should only
* be rendered if hint text is set.
* It will not conflict with
* the description slot since the description
* will always be rendered regardless of
* whether helper or error text is present.
*/
private renderBottomContent() {
const { helperText, errorText } = this;

/**
* undefined and empty string values should
* be treated as not having helper/error text.
*/
const hasHintText = !!helperText || !!errorText;
if (!hasHintText) {
return;
}

return <div class="input-otp-bottom">{this.renderHintText()}</div>;
}

/**
* Checks if the input otp is in an invalid state based on Ionic validation classes
*/
private checkInvalidState(): boolean {
const hasIonTouched = this.el.classList.contains('ion-touched');
const hasIonInvalid = this.el.classList.contains('ion-invalid');

return hasIonTouched && hasIonInvalid;
}

render() {
const {
autocapitalize,
Expand Down Expand Up @@ -816,7 +926,14 @@ export class InputOTP implements ComponentInterface {
'input-otp-readonly': readonly,
})}
>
<div role="group" aria-label="One-time password input" class="input-otp-group" {...inheritedAttributes}>
<div
role="group"
aria-describedby={this.getHintTextID()}
aria-invalid={this.isInvalid ? 'true' : undefined}
aria-label="One-time password input"
class="input-otp-group"
{...inheritedAttributes}
>
{Array.from({ length }).map((_, index) => (
<>
<div class="native-wrapper">
Expand Down Expand Up @@ -853,6 +970,7 @@ export class InputOTP implements ComponentInterface {
>
<slot></slot>
</div>
{this.renderBottomContent()}
</Host>
);
}
Expand Down
Loading
Loading