Skip to content

Commit 693446d

Browse files
committed
feat(input-otp): add helperText and errorText props
1 parent 49f7cc7 commit 693446d

File tree

5 files changed

+132
-3
lines changed

5 files changed

+132
-3
lines changed

core/api.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,7 +784,9 @@ ion-input-otp,scoped
784784
ion-input-otp,prop,autocapitalize,string,'off',false,false
785785
ion-input-otp,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "secondary" | "success" | "tertiary" | "warning" | string & Record<never, never> | undefined,undefined,false,true
786786
ion-input-otp,prop,disabled,boolean,false,false,true
787+
ion-input-otp,prop,errorText,string | undefined,undefined,false,false
787788
ion-input-otp,prop,fill,"outline" | "solid" | undefined,'outline',false,false
789+
ion-input-otp,prop,helperText,string | undefined,undefined,false,false
788790
ion-input-otp,prop,inputmode,"decimal" | "email" | "none" | "numeric" | "search" | "tel" | "text" | "url" | undefined,undefined,false,false
789791
ion-input-otp,prop,length,number,4,false,false
790792
ion-input-otp,prop,pattern,string | undefined,undefined,false,false

core/src/components.d.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1457,11 +1457,19 @@ export namespace Components {
14571457
* @default false
14581458
*/
14591459
"disabled": boolean;
1460+
/**
1461+
* Text that is placed under the input boxes and displayed when an error is detected.
1462+
*/
1463+
"errorText"?: string;
14601464
/**
14611465
* 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.
14621466
* @default 'outline'
14631467
*/
14641468
"fill"?: 'outline' | 'solid';
1469+
/**
1470+
* Text that is placed under the input and displayed when no error is detected.
1471+
*/
1472+
"helperText"?: string;
14651473
/**
14661474
* 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"
14671475
*/
@@ -6739,11 +6747,19 @@ declare namespace LocalJSX {
67396747
* @default false
67406748
*/
67416749
"disabled"?: boolean;
6750+
/**
6751+
* Text that is placed under the input boxes and displayed when an error is detected.
6752+
*/
6753+
"errorText"?: string;
67426754
/**
67436755
* 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.
67446756
* @default 'outline'
67456757
*/
67466758
"fill"?: 'outline' | 'solid';
6759+
/**
6760+
* Text that is placed under the input and displayed when no error is detected.
6761+
*/
6762+
"helperText"?: string;
67476763
/**
67486764
* 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"
67496765
*/

core/src/components/input-otp/input-otp.tsx

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ComponentInterface, EventEmitter } from '@stencil/core';
2-
import { Component, Element, Event, Fragment, Host, Prop, State, h, Watch } from '@stencil/core';
2+
import { Build, Component, Element, Event, Fragment, Host, Prop, State, h, Watch, forceUpdate } from '@stencil/core';
33
import type { Attributes } from '@utils/helpers';
44
import { inheritAriaAttributes } from '@utils/helpers';
55
import { printIonWarning } from '@utils/logging';
@@ -28,7 +28,10 @@ export class InputOTP implements ComponentInterface {
2828
private inheritedAttributes: Attributes = {};
2929
private inputRefs: HTMLInputElement[] = [];
3030
private inputId = `ion-input-otp-${inputIds++}`;
31+
private helperTextId = `${this.inputId}-helper-text`;
32+
private errorTextId = `${this.inputId}-error-text`;
3133
private parsedSeparators: number[] = [];
34+
private validationObserver?: MutationObserver;
3235

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

56+
/**
57+
* Track validation state for proper aria-live announcements
58+
*/
59+
@State() isInvalid = false;
60+
5361
/**
5462
* Indicates whether and how the text value should be automatically capitalized as it is entered/edited by the user.
5563
* Available options: `"off"`, `"none"`, `"on"`, `"sentences"`, `"words"`, `"characters"`.
@@ -136,6 +144,16 @@ export class InputOTP implements ComponentInterface {
136144
*/
137145
@Prop({ mutable: true }) value?: string | number | null = '';
138146

147+
/**
148+
* Text that is placed under the input and displayed when no error is detected.
149+
*/
150+
@Prop() helperText?: string;
151+
152+
/**
153+
* Text that is placed under the input boxes and displayed when an error is detected.
154+
*/
155+
@Prop() errorText?: string;
156+
139157
/**
140158
* The `ionInput` event is fired each time the user modifies the input's value.
141159
* Unlike the `ionChange` event, the `ionInput` event is fired for each alteration
@@ -263,6 +281,30 @@ export class InputOTP implements ComponentInterface {
263281
this.parsedSeparators = separatorValues.filter((pos) => pos <= length);
264282
}
265283

284+
connectedCallback() {
285+
const { el } = this;
286+
287+
// Watch for class changes to update validation state
288+
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
289+
this.validationObserver = new MutationObserver(() => {
290+
const newIsInvalid = this.checkInvalidState();
291+
if (this.isInvalid !== newIsInvalid) {
292+
this.isInvalid = newIsInvalid;
293+
// Force a re-render to update aria-describedby immediately
294+
forceUpdate(this);
295+
}
296+
});
297+
298+
this.validationObserver.observe(el, {
299+
attributes: true,
300+
attributeFilter: ['class'],
301+
});
302+
}
303+
304+
// Always set initial state
305+
this.isInvalid = this.checkInvalidState();
306+
}
307+
266308
componentWillLoad() {
267309
this.inheritedAttributes = inheritAriaAttributes(this.el);
268310
this.processSeparators();
@@ -781,6 +823,70 @@ export class InputOTP implements ComponentInterface {
781823
return this.parsedSeparators.includes(index + 1) && index < length - 1;
782824
}
783825

826+
/**
827+
* Renders the helper text or error text values
828+
*/
829+
private renderHintText() {
830+
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
831+
832+
return [
833+
<div id={helperTextId} class="helper-text" aria-live="polite">
834+
{!isInvalid ? helperText : null}
835+
</div>,
836+
<div id={errorTextId} class="error-text" role="alert">
837+
{isInvalid ? errorText : null}
838+
</div>,
839+
];
840+
}
841+
842+
private getHintTextID(): string | undefined {
843+
const { isInvalid, helperText, errorText, helperTextId, errorTextId } = this;
844+
845+
if (isInvalid && errorText) {
846+
return errorTextId;
847+
}
848+
849+
if (helperText) {
850+
return helperTextId;
851+
}
852+
853+
return undefined;
854+
}
855+
856+
/**
857+
* Responsible for rendering helper text and
858+
* error text. This element should only
859+
* be rendered if hint text is set.
860+
* It will not conflict with
861+
* the description slot since the description
862+
* will always be rendered regardless of
863+
* whether helper or error text is present.
864+
*/
865+
private renderBottomContent() {
866+
const { helperText, errorText } = this;
867+
868+
/**
869+
* undefined and empty string values should
870+
* be treated as not having helper/error text.
871+
*/
872+
const hasHintText = !!helperText || !!errorText;
873+
if (!hasHintText) {
874+
return;
875+
}
876+
877+
return <div class="input-otp-bottom">{this.renderHintText()}</div>;
878+
}
879+
880+
/**
881+
* Checks if the input otp is in an invalid state based on Ionic validation classes
882+
*/
883+
private checkInvalidState(): boolean {
884+
const hasIonTouched = this.el.classList.contains('ion-touched');
885+
const hasIonInvalid = this.el.classList.contains('ion-invalid');
886+
887+
return hasIonTouched && hasIonInvalid;
888+
}
889+
784890
render() {
785891
const {
786892
autocapitalize,
@@ -823,6 +929,8 @@ export class InputOTP implements ComponentInterface {
823929
<input
824930
class="native-input"
825931
id={`${inputId}-${index}`}
932+
aria-describedby={this.getHintTextID()}
933+
aria-invalid={this.isInvalid ? 'true' : undefined}
826934
aria-label={`Input ${index + 1} of ${length}`}
827935
type="text"
828936
autoCapitalize={autocapitalize}
@@ -853,6 +961,7 @@ export class InputOTP implements ComponentInterface {
853961
>
854962
<slot></slot>
855963
</div>
964+
{this.renderBottomContent()}
856965
</Host>
857966
);
858967
}

packages/angular/src/directives/proxies.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,15 +1018,15 @@ This event will not emit when programmatically setting the `value` property.
10181018

10191019

10201020
@ProxyCmp({
1021-
inputs: ['autocapitalize', 'color', 'disabled', 'fill', 'inputmode', 'length', 'pattern', 'readonly', 'separators', 'shape', 'size', 'type', 'value'],
1021+
inputs: ['autocapitalize', 'color', 'disabled', 'errorText', 'fill', 'helperText', 'inputmode', 'length', 'pattern', 'readonly', 'separators', 'shape', 'size', 'type', 'value'],
10221022
methods: ['setFocus']
10231023
})
10241024
@Component({
10251025
selector: 'ion-input-otp',
10261026
changeDetection: ChangeDetectionStrategy.OnPush,
10271027
template: '<ng-content></ng-content>',
10281028
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
1029-
inputs: ['autocapitalize', 'color', 'disabled', 'fill', 'inputmode', 'length', 'pattern', 'readonly', 'separators', 'shape', 'size', 'type', 'value'],
1029+
inputs: ['autocapitalize', 'color', 'disabled', 'errorText', 'fill', 'helperText', 'inputmode', 'length', 'pattern', 'readonly', 'separators', 'shape', 'size', 'type', 'value'],
10301030
})
10311031
export class IonInputOtp {
10321032
protected el: HTMLIonInputOtpElement;

packages/vue/src/proxies.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,8 @@ export const IonInputOtp: StencilVueComponent<JSX.IonInputOtp, JSX.IonInputOtp["
506506
'size',
507507
'type',
508508
'value',
509+
'helperText',
510+
'errorText',
509511
'ionInput',
510512
'ionChange',
511513
'ionComplete',

0 commit comments

Comments
 (0)