Skip to content
Open
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
81 changes: 73 additions & 8 deletions core/src/components/input/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,15 @@ export class Input implements ComponentInterface {
*/
@State() hasFocus = false;

/**
* Track validation state for proper aria-live announcements
*/
@State() isInvalid = false;

@Element() el!: HTMLIonInputElement;

private validationObserver?: MutationObserver;

/**
* The color to use from your application's color palette.
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
Expand Down Expand Up @@ -396,6 +403,24 @@ export class Input implements ComponentInterface {
};
}

/**
* Checks if the input is in an invalid state based on validation classes
*/
private checkValidationState(): boolean {
// Check for both Ionic and Angular validation classes on the element itself
// Angular applies ng-touched/ng-invalid directly to the host element with ngModel
const hasIonTouched = this.el.classList.contains('ion-touched');
const hasIonInvalid = this.el.classList.contains('ion-invalid');
const hasNgTouched = this.el.classList.contains('ng-touched');
const hasNgInvalid = this.el.classList.contains('ng-invalid');
Comment on lines +414 to +415
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't have Angular checks in core, Angular should be adding ion-touched and ion-invalid at the same time. If we need to adjust this we should do it in Angular.


// Return true if we have both touched and invalid states from either framework
const isTouched = hasIonTouched || hasNgTouched;
const isInvalid = hasIonInvalid || hasNgInvalid;

return isTouched && isInvalid;
}

connectedCallback() {
const { el } = this;

Expand All @@ -406,6 +431,26 @@ export class Input implements ComponentInterface {
() => this.labelSlot
);

// Watch for class changes to update validation state
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
this.validationObserver = new MutationObserver(() => {
const newIsInvalid = this.checkValidationState();
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.checkValidationState();

this.debounceChanged();
if (Build.isBrowser) {
document.dispatchEvent(
Expand Down Expand Up @@ -451,6 +496,12 @@ export class Input implements ComponentInterface {
this.notchController.destroy();
this.notchController = undefined;
}

// Clean up validation observer to prevent memory leaks
if (this.validationObserver) {
this.validationObserver.disconnect();
this.validationObserver = undefined;
}
}

/**
Expand Down Expand Up @@ -549,6 +600,20 @@ export class Input implements ComponentInterface {
this.didInputClearOnEdit = false;

this.ionBlur.emit(ev);

/**
* Check validation state after blur to handle framework-managed classes.
* Frameworks like Angular update classes asynchronously, often using
* requestAnimationFrame or promises. Using setTimeout ensures we check
* after all microtasks and animation frames have completed.
*/
setTimeout(() => {
const newIsInvalid = this.checkValidationState();
if (this.isInvalid !== newIsInvalid) {
this.isInvalid = newIsInvalid;
forceUpdate(this);
}
}, 100);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is 100 needed here? Does no number work?

};

private onFocus = (ev: FocusEvent) => {
Expand Down Expand Up @@ -626,22 +691,22 @@ export class Input implements ComponentInterface {
* Renders the helper text or error text values
*/
private renderHintText() {
const { helperText, errorText, helperTextId, errorTextId } = this;
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;

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

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

if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
if (isInvalid && errorText) {
return errorTextId;
}

Expand Down Expand Up @@ -864,7 +929,7 @@ export class Input implements ComponentInterface {
onCompositionstart={this.onCompositionStart}
onCompositionend={this.onCompositionEnd}
aria-describedby={this.getHintTextID()}
aria-invalid={this.getHintTextID() === this.errorTextId}
aria-invalid={this.isInvalid ? 'true' : undefined}
{...this.inheritedAttributes}
/>
{this.clearInput && !readonly && !disabled && (
Expand Down
Loading
Loading