From ec83531a0ada2c46c5e490c295e5b603b79e5967 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Thu, 14 Aug 2025 09:47:48 -0700 Subject: [PATCH 1/6] fix(input): add aria-live attributes to error text --- core/src/components/input/input.tsx | 54 ++++++++++++++-- core/src/components/input/test/input.spec.ts | 63 +++++++++++++++++++ .../components/textarea/test/textarea.spec.ts | 63 +++++++++++++++++++ core/src/components/textarea/textarea.tsx | 55 ++++++++++++++-- 4 files changed, 225 insertions(+), 10 deletions(-) diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index 69281c5e899..36ae3f390ce 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -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"`. @@ -396,6 +403,13 @@ export class Input implements ComponentInterface { }; } + /** + * Checks if the input is in an invalid state based on validation classes + */ + private checkValidationState(): boolean { + return this.el.classList.contains('ion-touched') && this.el.classList.contains('ion-invalid'); + } + connectedCallback() { const { el } = this; @@ -406,6 +420,24 @@ export class Input implements ComponentInterface { () => this.labelSlot ); + // Watch for class changes to update validation state + if (Build.isBrowser) { + this.validationObserver = new MutationObserver(() => { + const newIsInvalid = this.checkValidationState(); + if (this.isInvalid !== newIsInvalid) { + this.isInvalid = newIsInvalid; + } + }); + + this.validationObserver.observe(el, { + attributes: true, + attributeFilter: ['class'], + }); + + // Set initial state + this.isInvalid = this.checkValidationState(); + } + this.debounceChanged(); if (Build.isBrowser) { document.dispatchEvent( @@ -451,6 +483,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; + } } /** @@ -626,22 +664,28 @@ 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 [
{helperText}
, -
- {errorText} +
+ {isInvalid && errorText ? errorText : ''}
, ]; } 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; } diff --git a/core/src/components/input/test/input.spec.ts b/core/src/components/input/test/input.spec.ts index af9faac9f3c..ba943ae6fce 100644 --- a/core/src/components/input/test/input.spec.ts +++ b/core/src/components/input/test/input.spec.ts @@ -133,3 +133,66 @@ describe('input: clear icon', () => { expect(icon.getAttribute('icon')).toBe('foo'); }); }); + +// Regression tests for screen reader accessibility of error messages +describe('input: error text accessibility', () => { + it('should have error text element with proper structure', async () => { + const page = await newSpecPage({ + components: [Input], + html: ``, + }); + + const errorTextEl = page.body.querySelector('ion-input .error-text'); + expect(errorTextEl).not.toBe(null); + + // Error text element should always exist and have aria-atomic + expect(errorTextEl!.getAttribute('aria-atomic')).toBe('true'); + expect(errorTextEl!.getAttribute('id')).toContain('error-text'); + }); + + it('should maintain error text structure when error text changes dynamically', async () => { + const page = await newSpecPage({ + components: [Input], + html: ``, + }); + + const input = page.body.querySelector('ion-input')!; + + // Add error text dynamically + input.setAttribute('error-text', 'Invalid email format'); + await page.waitForChanges(); + + const errorTextEl = page.body.querySelector('ion-input .error-text'); + expect(errorTextEl).not.toBe(null); + expect(errorTextEl!.getAttribute('aria-atomic')).toBe('true'); + expect(errorTextEl!.getAttribute('id')).toContain('error-text'); + }); + + it('should have proper aria-describedby reference structure', async () => { + const page = await newSpecPage({ + components: [Input], + html: ``, + }); + + const errorTextEl = page.body.querySelector('ion-input .error-text')!; + + // Verify the error text element has an ID + const errorId = errorTextEl.getAttribute('id'); + expect(errorId).toContain('error-text'); + + // Note: aria-describedby is dynamically set based on validation state + // The actual connection happens when the input becomes invalid + }); + + it('should have helper text element with proper structure', async () => { + const page = await newSpecPage({ + components: [Input], + html: ``, + }); + + const helperTextEl = page.body.querySelector('ion-input .helper-text'); + expect(helperTextEl).not.toBe(null); + expect(helperTextEl!.getAttribute('id')).toContain('helper-text'); + expect(helperTextEl!.textContent).toBe('Enter a valid value'); + }); +}); diff --git a/core/src/components/textarea/test/textarea.spec.ts b/core/src/components/textarea/test/textarea.spec.ts index f1611a3e291..779239034aa 100644 --- a/core/src/components/textarea/test/textarea.spec.ts +++ b/core/src/components/textarea/test/textarea.spec.ts @@ -85,3 +85,66 @@ describe('textarea: label rendering', () => { expect(labelText.textContent).toBe('Label Prop Text'); }); }); + +// Accessibility tests for error text announcements to screen readers +describe('textarea: error text accessibility', () => { + it('should have error text element with proper structure', async () => { + const page = await newSpecPage({ + components: [Textarea], + html: ``, + }); + + const errorTextEl = page.body.querySelector('ion-textarea .error-text'); + expect(errorTextEl).not.toBe(null); + + // Error text element should always exist and have aria-atomic + expect(errorTextEl!.getAttribute('aria-atomic')).toBe('true'); + expect(errorTextEl!.getAttribute('id')).toContain('error-text'); + }); + + it('should maintain error text structure when error text changes dynamically', async () => { + const page = await newSpecPage({ + components: [Textarea], + html: ``, + }); + + const textarea = page.body.querySelector('ion-textarea')!; + + // Add error text dynamically + textarea.setAttribute('error-text', 'Invalid content'); + await page.waitForChanges(); + + const errorTextEl = page.body.querySelector('ion-textarea .error-text'); + expect(errorTextEl).not.toBe(null); + expect(errorTextEl!.getAttribute('aria-atomic')).toBe('true'); + expect(errorTextEl!.getAttribute('id')).toContain('error-text'); + }); + + it('should have proper aria-describedby reference structure', async () => { + const page = await newSpecPage({ + components: [Textarea], + html: ``, + }); + + const errorTextEl = page.body.querySelector('ion-textarea .error-text')!; + + // Verify the error text element has an ID + const errorId = errorTextEl.getAttribute('id'); + expect(errorId).toContain('error-text'); + + // Note: aria-describedby is dynamically set based on validation state + // The actual connection happens when the textarea becomes invalid + }); + + it('should have helper text element with proper structure', async () => { + const page = await newSpecPage({ + components: [Textarea], + html: ``, + }); + + const helperTextEl = page.body.querySelector('ion-textarea .helper-text'); + expect(helperTextEl).not.toBe(null); + expect(helperTextEl!.getAttribute('id')).toContain('helper-text'); + expect(helperTextEl!.textContent).toBe('Enter your comments'); + }); +}); diff --git a/core/src/components/textarea/textarea.tsx b/core/src/components/textarea/textarea.tsx index 64ff00c9225..dccadc8e1e4 100644 --- a/core/src/components/textarea/textarea.tsx +++ b/core/src/components/textarea/textarea.tsx @@ -81,6 +81,13 @@ export class Textarea implements ComponentInterface { */ @State() hasFocus = false; + /** + * Track validation state for proper aria-live announcements + */ + @State() isInvalid = false; + + 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"`. @@ -328,6 +335,13 @@ export class Textarea implements ComponentInterface { } } + /** + * Checks if the textarea is in an invalid state based on validation classes + */ + private checkValidationState(): boolean { + return this.el.classList.contains('ion-touched') && this.el.classList.contains('ion-invalid'); + } + connectedCallback() { const { el } = this; this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => forceUpdate(this)); @@ -336,6 +350,25 @@ export class Textarea implements ComponentInterface { () => this.notchSpacerEl, () => this.labelSlot ); + + // Watch for class changes to update validation state + if (Build.isBrowser) { + this.validationObserver = new MutationObserver(() => { + const newIsInvalid = this.checkValidationState(); + if (this.isInvalid !== newIsInvalid) { + this.isInvalid = newIsInvalid; + } + }); + + this.validationObserver.observe(el, { + attributes: true, + attributeFilter: ['class'], + }); + + // Set initial state + this.isInvalid = this.checkValidationState(); + } + this.debounceChanged(); if (Build.isBrowser) { document.dispatchEvent( @@ -364,6 +397,12 @@ export class Textarea 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; + } } componentWillLoad() { @@ -628,22 +667,28 @@ export class Textarea 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 [
{helperText}
, -
- {errorText} +
+ {isInvalid && errorText ? errorText : ''}
, ]; } 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; } From a7295b46e87192fa2662b915bb4f7387380d5a0c Mon Sep 17 00:00:00 2001 From: ShaneK Date: Tue, 19 Aug 2025 08:41:09 -0700 Subject: [PATCH 2/6] fix(input): making validation reading work more consistently across browsers --- core/src/components/input/input.tsx | 22 +- core/src/components/input/test/input.spec.ts | 63 ++-- .../input/test/validation/index.html | 299 +++++++++++++++++ .../components/textarea/test/textarea.spec.ts | 63 ++-- .../textarea/test/validation/index.html | 300 ++++++++++++++++++ core/src/components/textarea/textarea.tsx | 22 +- 6 files changed, 701 insertions(+), 68 deletions(-) create mode 100644 core/src/components/input/test/validation/index.html create mode 100644 core/src/components/textarea/test/validation/index.html diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index 36ae3f390ce..b55158a5de6 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -421,7 +421,7 @@ export class Input implements ComponentInterface { ); // Watch for class changes to update validation state - if (Build.isBrowser) { + if (Build.isBrowser && typeof MutationObserver !== 'undefined') { this.validationObserver = new MutationObserver(() => { const newIsInvalid = this.checkValidationState(); if (this.isInvalid !== newIsInvalid) { @@ -433,11 +433,11 @@ export class Input implements ComponentInterface { attributes: true, attributeFilter: ['class'], }); - - // Set initial state - this.isInvalid = this.checkValidationState(); } + // Always set initial state + this.isInvalid = this.checkValidationState(); + this.debounceChanged(); if (Build.isBrowser) { document.dispatchEvent( @@ -664,20 +664,14 @@ export class Input implements ComponentInterface { * Renders the helper text or error text values */ private renderHintText() { - const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this; + const { helperText, errorText, helperTextId, errorTextId } = this; return [
{helperText}
, -
- {isInvalid && errorText ? errorText : ''} +
+ {errorText}
, ]; } @@ -908,7 +902,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' : 'false'} {...this.inheritedAttributes} /> {this.clearInput && !readonly && !disabled && ( diff --git a/core/src/components/input/test/input.spec.ts b/core/src/components/input/test/input.spec.ts index ba943ae6fce..25062d6e78a 100644 --- a/core/src/components/input/test/input.spec.ts +++ b/core/src/components/input/test/input.spec.ts @@ -144,44 +144,49 @@ describe('input: error text accessibility', () => { const errorTextEl = page.body.querySelector('ion-input .error-text'); expect(errorTextEl).not.toBe(null); - - // Error text element should always exist and have aria-atomic - expect(errorTextEl!.getAttribute('aria-atomic')).toBe('true'); expect(errorTextEl!.getAttribute('id')).toContain('error-text'); + expect(errorTextEl!.textContent).toBe('This field is required'); }); - it('should maintain error text structure when error text changes dynamically', async () => { + it('should set aria-invalid when input is invalid', async () => { const page = await newSpecPage({ components: [Input], - html: ``, + html: ``, }); - const input = page.body.querySelector('ion-input')!; - - // Add error text dynamically - input.setAttribute('error-text', 'Invalid email format'); - await page.waitForChanges(); + const nativeInput = page.body.querySelector('ion-input input')!; - const errorTextEl = page.body.querySelector('ion-input .error-text'); - expect(errorTextEl).not.toBe(null); - expect(errorTextEl!.getAttribute('aria-atomic')).toBe('true'); - expect(errorTextEl!.getAttribute('id')).toContain('error-text'); + // Should be invalid because of the classes + expect(nativeInput.getAttribute('aria-invalid')).toBe('true'); }); - it('should have proper aria-describedby reference structure', async () => { + it('should set aria-describedby to error text when invalid', async () => { const page = await newSpecPage({ components: [Input], - html: ``, + html: ``, }); + const nativeInput = page.body.querySelector('ion-input input')!; const errorTextEl = page.body.querySelector('ion-input .error-text')!; - // Verify the error text element has an ID + // Verify aria-describedby points to error text const errorId = errorTextEl.getAttribute('id'); - expect(errorId).toContain('error-text'); + expect(nativeInput.getAttribute('aria-describedby')).toBe(errorId); + }); + + it('should set aria-describedby to helper text when valid', async () => { + const page = await newSpecPage({ + components: [Input], + html: ``, + }); + + const nativeInput = page.body.querySelector('ion-input input')!; + const helperTextEl = page.body.querySelector('ion-input .helper-text')!; - // Note: aria-describedby is dynamically set based on validation state - // The actual connection happens when the input becomes invalid + // When not invalid, should point to helper text + const helperId = helperTextEl.getAttribute('id'); + expect(nativeInput.getAttribute('aria-describedby')).toBe(helperId); + expect(nativeInput.getAttribute('aria-invalid')).toBe('false'); }); it('should have helper text element with proper structure', async () => { @@ -195,4 +200,22 @@ describe('input: error text accessibility', () => { expect(helperTextEl!.getAttribute('id')).toContain('helper-text'); expect(helperTextEl!.textContent).toBe('Enter a valid value'); }); + + it('should maintain error text content when error text changes dynamically', async () => { + const page = await newSpecPage({ + components: [Input], + html: ``, + }); + + const input = page.body.querySelector('ion-input')!; + + // Add error text dynamically + input.setAttribute('error-text', 'Invalid email format'); + await page.waitForChanges(); + + const errorTextEl = page.body.querySelector('ion-input .error-text'); + expect(errorTextEl).not.toBe(null); + expect(errorTextEl!.getAttribute('id')).toContain('error-text'); + expect(errorTextEl!.textContent).toBe('Invalid email format'); + }); }); diff --git a/core/src/components/input/test/validation/index.html b/core/src/components/input/test/validation/index.html new file mode 100644 index 00000000000..dabda5a48c5 --- /dev/null +++ b/core/src/components/input/test/validation/index.html @@ -0,0 +1,299 @@ + + + + + Input - Validation + + + + + + + + + + + + + + Input - Validation Test + + + + +
+

Screen Reader Testing Instructions:

+
    +
  1. Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)
  2. +
  3. Tab through the form fields
  4. +
  5. When you tab away from an empty required field, the error should be announced immediately
  6. +
  7. The error text should be announced BEFORE the next field is announced
  8. +
  9. Test in Chrome, Safari, and Firefox to verify consistent behavior
  10. +
+
+ +
+
+

Required Email Field

+ +
+ +
+

Required Name Field

+ +
+ +
+

Phone Number (Pattern Validation)

+ +
+ +
+

Password (Min Length)

+ +
+ +
+

Age (Number Range)

+ +
+ +
+

Optional Field (No Validation)

+ +
+
+ +
+ Submit Form + Reset Form +
+ + +
+
+
+ + + + \ No newline at end of file diff --git a/core/src/components/textarea/test/textarea.spec.ts b/core/src/components/textarea/test/textarea.spec.ts index 779239034aa..5820c3592c4 100644 --- a/core/src/components/textarea/test/textarea.spec.ts +++ b/core/src/components/textarea/test/textarea.spec.ts @@ -96,44 +96,49 @@ describe('textarea: error text accessibility', () => { const errorTextEl = page.body.querySelector('ion-textarea .error-text'); expect(errorTextEl).not.toBe(null); - - // Error text element should always exist and have aria-atomic - expect(errorTextEl!.getAttribute('aria-atomic')).toBe('true'); expect(errorTextEl!.getAttribute('id')).toContain('error-text'); + expect(errorTextEl!.textContent).toBe('This field is required'); }); - it('should maintain error text structure when error text changes dynamically', async () => { + it('should set aria-invalid when textarea is invalid', async () => { const page = await newSpecPage({ components: [Textarea], - html: ``, + html: ``, }); - const textarea = page.body.querySelector('ion-textarea')!; - - // Add error text dynamically - textarea.setAttribute('error-text', 'Invalid content'); - await page.waitForChanges(); + const nativeTextarea = page.body.querySelector('ion-textarea textarea')!; - const errorTextEl = page.body.querySelector('ion-textarea .error-text'); - expect(errorTextEl).not.toBe(null); - expect(errorTextEl!.getAttribute('aria-atomic')).toBe('true'); - expect(errorTextEl!.getAttribute('id')).toContain('error-text'); + // Should be invalid because of the classes + expect(nativeTextarea.getAttribute('aria-invalid')).toBe('true'); }); - it('should have proper aria-describedby reference structure', async () => { + it('should set aria-describedby to error text when invalid', async () => { const page = await newSpecPage({ components: [Textarea], - html: ``, + html: ``, }); + const nativeTextarea = page.body.querySelector('ion-textarea textarea')!; const errorTextEl = page.body.querySelector('ion-textarea .error-text')!; - // Verify the error text element has an ID + // Verify aria-describedby points to error text const errorId = errorTextEl.getAttribute('id'); - expect(errorId).toContain('error-text'); + expect(nativeTextarea.getAttribute('aria-describedby')).toBe(errorId); + }); + + it('should set aria-describedby to helper text when valid', async () => { + const page = await newSpecPage({ + components: [Textarea], + html: ``, + }); + + const nativeTextarea = page.body.querySelector('ion-textarea textarea')!; + const helperTextEl = page.body.querySelector('ion-textarea .helper-text')!; - // Note: aria-describedby is dynamically set based on validation state - // The actual connection happens when the textarea becomes invalid + // When not invalid, should point to helper text + const helperId = helperTextEl.getAttribute('id'); + expect(nativeTextarea.getAttribute('aria-describedby')).toBe(helperId); + expect(nativeTextarea.getAttribute('aria-invalid')).toBe('false'); }); it('should have helper text element with proper structure', async () => { @@ -147,4 +152,22 @@ describe('textarea: error text accessibility', () => { expect(helperTextEl!.getAttribute('id')).toContain('helper-text'); expect(helperTextEl!.textContent).toBe('Enter your comments'); }); + + it('should maintain error text content when error text changes dynamically', async () => { + const page = await newSpecPage({ + components: [Textarea], + html: ``, + }); + + const textarea = page.body.querySelector('ion-textarea')!; + + // Add error text dynamically + textarea.setAttribute('error-text', 'Invalid content'); + await page.waitForChanges(); + + const errorTextEl = page.body.querySelector('ion-textarea .error-text'); + expect(errorTextEl).not.toBe(null); + expect(errorTextEl!.getAttribute('id')).toContain('error-text'); + expect(errorTextEl!.textContent).toBe('Invalid content'); + }); }); diff --git a/core/src/components/textarea/test/validation/index.html b/core/src/components/textarea/test/validation/index.html new file mode 100644 index 00000000000..933b18f3047 --- /dev/null +++ b/core/src/components/textarea/test/validation/index.html @@ -0,0 +1,300 @@ + + + + + Textarea - Validation + + + + + + + + + + + + + + Textarea - Validation Test + + + + +
+

Screen Reader Testing Instructions:

+
    +
  1. Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)
  2. +
  3. Tab through the form fields
  4. +
  5. When you tab away from an empty required field, the error should be announced immediately
  6. +
  7. The error text should be announced BEFORE the next field is announced
  8. +
  9. Test in Chrome, Safari, and Firefox to verify consistent behavior
  10. +
+
+ +
+
+

Required Description (Min Length)

+ +
+ +
+

Required Comments

+ +
+ +
+

Bio (Max Length)

+ +
+ +
+

Address (Pattern Validation)

+ +
+ +
+

Review (Min/Max Length)

+ +
+ +
+

Optional Notes

+ +
+
+ +
+ Submit Form + Reset Form +
+ + +
+
+
+ + + + \ No newline at end of file diff --git a/core/src/components/textarea/textarea.tsx b/core/src/components/textarea/textarea.tsx index dccadc8e1e4..3767a9f9a7b 100644 --- a/core/src/components/textarea/textarea.tsx +++ b/core/src/components/textarea/textarea.tsx @@ -352,7 +352,7 @@ export class Textarea implements ComponentInterface { ); // Watch for class changes to update validation state - if (Build.isBrowser) { + if (Build.isBrowser && typeof MutationObserver !== 'undefined') { this.validationObserver = new MutationObserver(() => { const newIsInvalid = this.checkValidationState(); if (this.isInvalid !== newIsInvalid) { @@ -364,11 +364,11 @@ export class Textarea implements ComponentInterface { attributes: true, attributeFilter: ['class'], }); - - // Set initial state - this.isInvalid = this.checkValidationState(); } + // Always set initial state + this.isInvalid = this.checkValidationState(); + this.debounceChanged(); if (Build.isBrowser) { document.dispatchEvent( @@ -667,20 +667,14 @@ export class Textarea implements ComponentInterface { * Renders the helper text or error text values */ private renderHintText() { - const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this; + const { helperText, errorText, helperTextId, errorTextId } = this; return [
{helperText}
, -
- {isInvalid && errorText ? errorText : ''} +
+ {errorText}
, ]; } @@ -822,7 +816,7 @@ export class Textarea implements ComponentInterface { onFocus={this.onFocus} onKeyDown={this.onKeyDown} aria-describedby={this.getHintTextID()} - aria-invalid={this.getHintTextID() === this.errorTextId} + aria-invalid={this.isInvalid ? 'true' : 'false'} {...this.inheritedAttributes} > {value} From fc0581592fb1d7892c56ac4f81b31e6183e68847 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Tue, 19 Aug 2025 09:29:24 -0700 Subject: [PATCH 3/6] fix(lint): fixing lint in test files --- core/src/components/input/input.tsx | 2 +- core/src/components/input/test/input.spec.ts | 2 +- core/src/components/input/test/validation/index.html | 12 ++++++------ core/src/components/textarea/test/textarea.spec.ts | 2 +- .../components/textarea/test/validation/index.html | 12 ++++++------ core/src/components/textarea/textarea.tsx | 2 +- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index b55158a5de6..4c4f775e3d8 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -902,7 +902,7 @@ export class Input implements ComponentInterface { onCompositionstart={this.onCompositionStart} onCompositionend={this.onCompositionEnd} aria-describedby={this.getHintTextID()} - aria-invalid={this.isInvalid ? 'true' : 'false'} + aria-invalid={this.isInvalid ? 'true' : undefined} {...this.inheritedAttributes} /> {this.clearInput && !readonly && !disabled && ( diff --git a/core/src/components/input/test/input.spec.ts b/core/src/components/input/test/input.spec.ts index 25062d6e78a..4275325d23a 100644 --- a/core/src/components/input/test/input.spec.ts +++ b/core/src/components/input/test/input.spec.ts @@ -186,7 +186,7 @@ describe('input: error text accessibility', () => { // When not invalid, should point to helper text const helperId = helperTextEl.getAttribute('id'); expect(nativeInput.getAttribute('aria-describedby')).toBe(helperId); - expect(nativeInput.getAttribute('aria-invalid')).toBe('false'); + expect(nativeInput.getAttribute('aria-invalid')).toBeNull(); }); it('should have helper text element with proper structure', async () => { diff --git a/core/src/components/input/test/validation/index.html b/core/src/components/input/test/validation/index.html index dabda5a48c5..8fffb0f8080 100644 --- a/core/src/components/input/test/validation/index.html +++ b/core/src/components/input/test/validation/index.html @@ -202,7 +202,7 @@

Optional Field (No Validation)

const age = parseInt(value); return age >= 18 && age <= 120; }, - 'optional-input': () => true // Always valid + 'optional-input': () => true, // Always valid }; function validateField(input) { @@ -227,7 +227,7 @@

Optional Field (No Validation)

function validateForm() { let allValid = true; - inputs.forEach(input => { + inputs.forEach((input) => { if (input.id !== 'optional-input') { const isValid = validateField(input); if (!isValid) { @@ -240,13 +240,13 @@

Optional Field (No Validation)

} // Add event listeners - inputs.forEach(input => { + inputs.forEach((input) => { // Mark as touched on blur input.addEventListener('ionBlur', (e) => { touchedFields.add(input.id); validateField(input); validateForm(); - + // Debug: Log to hidden aria-live region for testing const isInvalid = input.classList.contains('ion-invalid'); if (isInvalid) { @@ -276,7 +276,7 @@

Optional Field (No Validation)

// Reset button resetBtn.addEventListener('click', () => { - inputs.forEach(input => { + inputs.forEach((input) => { input.value = ''; input.classList.remove('ion-valid', 'ion-invalid', 'ion-touched'); }); @@ -296,4 +296,4 @@

Optional Field (No Validation)

validateForm(); - \ No newline at end of file + diff --git a/core/src/components/textarea/test/textarea.spec.ts b/core/src/components/textarea/test/textarea.spec.ts index 5820c3592c4..75f376719d9 100644 --- a/core/src/components/textarea/test/textarea.spec.ts +++ b/core/src/components/textarea/test/textarea.spec.ts @@ -138,7 +138,7 @@ describe('textarea: error text accessibility', () => { // When not invalid, should point to helper text const helperId = helperTextEl.getAttribute('id'); expect(nativeTextarea.getAttribute('aria-describedby')).toBe(helperId); - expect(nativeTextarea.getAttribute('aria-invalid')).toBe('false'); + expect(nativeTextarea.getAttribute('aria-invalid')).toBeNull(); }); it('should have helper text element with proper structure', async () => { diff --git a/core/src/components/textarea/test/validation/index.html b/core/src/components/textarea/test/validation/index.html index 933b18f3047..0c12b879f75 100644 --- a/core/src/components/textarea/test/validation/index.html +++ b/core/src/components/textarea/test/validation/index.html @@ -203,7 +203,7 @@

Optional Notes

'review-textarea': (value) => { return value && value.length >= 50 && value.length <= 500; }, - 'notes-textarea': () => true // Always valid (optional) + 'notes-textarea': () => true, // Always valid (optional) }; function validateField(textarea) { @@ -228,7 +228,7 @@

Optional Notes

function validateForm() { let allValid = true; - textareas.forEach(textarea => { + textareas.forEach((textarea) => { if (textarea.id !== 'notes-textarea') { const isValid = validateField(textarea); if (!isValid) { @@ -241,13 +241,13 @@

Optional Notes

} // Add event listeners - textareas.forEach(textarea => { + textareas.forEach((textarea) => { // Mark as touched on blur textarea.addEventListener('ionBlur', (e) => { touchedFields.add(textarea.id); validateField(textarea); validateForm(); - + // Debug: Log to hidden aria-live region for testing const isInvalid = textarea.classList.contains('ion-invalid'); if (isInvalid) { @@ -277,7 +277,7 @@

Optional Notes

// Reset button resetBtn.addEventListener('click', () => { - textareas.forEach(textarea => { + textareas.forEach((textarea) => { textarea.value = ''; textarea.classList.remove('ion-valid', 'ion-invalid', 'ion-touched'); }); @@ -297,4 +297,4 @@

Optional Notes

validateForm(); - \ No newline at end of file + diff --git a/core/src/components/textarea/textarea.tsx b/core/src/components/textarea/textarea.tsx index 3767a9f9a7b..b844c191182 100644 --- a/core/src/components/textarea/textarea.tsx +++ b/core/src/components/textarea/textarea.tsx @@ -816,7 +816,7 @@ export class Textarea implements ComponentInterface { onFocus={this.onFocus} onKeyDown={this.onKeyDown} aria-describedby={this.getHintTextID()} - aria-invalid={this.isInvalid ? 'true' : 'false'} + aria-invalid={this.isInvalid ? 'true' : undefined} {...this.inheritedAttributes} > {value} From 9ebada9f00f351df3539f9035840d0bd91b01b6d Mon Sep 17 00:00:00 2001 From: ShaneK Date: Thu, 21 Aug 2025 09:30:50 -0700 Subject: [PATCH 4/6] App validation tests --- .../standalone/app-standalone/app.routes.ts | 8 + .../home-page/home-page.component.html | 16 ++ .../input-validation.component.html | 170 ++++++++++++++ .../input-validation.component.scss | 44 ++++ .../input-validation.component.ts | 207 +++++++++++++++++ .../textarea-validation.component.html | 172 ++++++++++++++ .../textarea-validation.component.scss | 44 ++++ .../textarea-validation.component.ts | 217 ++++++++++++++++++ 8 files changed, 878 insertions(+) create mode 100644 packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.html create mode 100644 packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.scss create mode 100644 packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.ts create mode 100644 packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.html create mode 100644 packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.scss create mode 100644 packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.ts diff --git a/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts b/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts index ae6ee66193c..fafb69c62ad 100644 --- a/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts +++ b/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts @@ -40,6 +40,14 @@ export const routes: Routes = [ ] }, { path: 'tabs-basic', loadComponent: () => import('../tabs-basic/tabs-basic.component').then(c => c.TabsBasicComponent) }, + { + path: 'validation', + children: [ + { path: 'input-validation', loadComponent: () => import('../validation/input-validation/input-validation.component').then(c => c.InputValidationComponent) }, + { path: 'textarea-validation', loadComponent: () => import('../validation/textarea-validation/textarea-validation.component').then(c => c.TextareaValidationComponent) }, + { path: '**', redirectTo: 'input-validation' } + ] + }, { path: 'value-accessors', children: [ diff --git a/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html b/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html index 163e438d42c..7900bdfb64e 100644 --- a/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html +++ b/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html @@ -107,6 +107,22 @@ + + + Validation Tests + + + + Input Validation Test + + + + + Textarea Validation Test + + + + Value Accessors diff --git a/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.html b/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.html new file mode 100644 index 00000000000..bf7fed90f32 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.html @@ -0,0 +1,170 @@ + + + Input - Validation Test + + + + +
+

Screen Reader Testing Instructions:

+
    +
  1. Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)
  2. +
  3. Tab through the form fields
  4. +
  5. When you tab away from an empty required field, the error should be announced immediately
  6. +
  7. The error text should be announced BEFORE the next field is announced
  8. +
  9. Test in Chrome, Safari, and Firefox to verify consistent behavior
  10. +
+
+ +
+
+
+

Required Email Field

+ +
+ +
+

Required Name Field

+ +
+ +
+

Phone Number (Pattern Validation)

+ +
+ +
+

Password (Min Length)

+ +
+ +
+

Age (Number Range)

+ +
+ +
+

Optional Field (No Validation)

+ +
+
+
+ +
+ Submit Form + Reset Form +
+ + +
+
diff --git a/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.scss b/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.scss new file mode 100644 index 00000000000..abf7b3d12d7 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.scss @@ -0,0 +1,44 @@ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + grid-row-gap: 20px; + grid-column-gap: 20px; +} + +h2 { + font-size: 12px; + font-weight: normal; + color: var(--ion-color-step-600); + margin-top: 10px; + margin-bottom: 5px; +} + +.aria-live-region { + position: absolute; + left: -10000px; + width: 1px; + height: 1px; + overflow: hidden; +} + +.validation-info { + margin: 20px; + padding: 10px; + background: var(--ion-color-light); + border-radius: 4px; +} + +.validation-info h2 { + font-size: 14px; + font-weight: 600; + margin-bottom: 10px; +} + +.validation-info ol { + margin: 0; + padding-left: 20px; +} + +.validation-info li { + margin-bottom: 5px; +} diff --git a/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.ts b/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.ts new file mode 100644 index 00000000000..18315ef8e5e --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.ts @@ -0,0 +1,207 @@ +import { Component, ElementRef, ViewChild } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + FormBuilder, + ReactiveFormsModule, + Validators, + AbstractControl, + ValidationErrors +} from '@angular/forms'; +import { + IonInput, + IonButton, + IonHeader, + IonToolbar, + IonTitle, + IonContent, + IonApp, + IonButtons, + IonItem, + IonList +} from '@ionic/angular/standalone'; + +// Custom validator for phone pattern +function phoneValidator(control: AbstractControl): ValidationErrors | null { + const value = control.value; + if (!value) return null; + const phonePattern = /^\(\d{3}\) \d{3}-\d{4}$/; + return phonePattern.test(value) ? null : { invalidPhone: true }; +} + +@Component({ + selector: 'app-input-validation', + templateUrl: './input-validation.component.html', + styleUrls: ['./input-validation.component.scss'], + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + IonApp, + IonInput, + IonButton, + IonHeader, + IonToolbar, + IonTitle, + IonContent, + IonButtons, + IonItem, + IonList + ] +}) +export class InputValidationComponent { + @ViewChild('debugRegion', { static: true }) debugRegion?: ElementRef; + + // Track which fields have been touched (using Set like vanilla test) + touchedFields = new Set(); + + // Field metadata for labels and error messages + fieldMetadata = { + email: { + label: 'Email', + helperText: "We'll never share your email", + errorText: 'Please enter a valid email address' + }, + name: { + label: 'Full Name', + helperText: 'First and last name', + errorText: 'Name is required' + }, + phone: { + label: 'Phone', + helperText: 'Format: (555) 555-5555', + errorText: 'Please enter a valid phone number' + }, + password: { + label: 'Password', + helperText: 'At least 8 characters', + errorText: 'Password must be at least 8 characters' + }, + age: { + label: 'Age', + helperText: 'Must be 18 or older', + errorText: 'Please enter a valid age (18-120)' + }, + optional: { + label: 'Optional Info', + helperText: 'You can skip this field', + errorText: '' + } + }; + + form = this.fb.group({ + email: ['', [Validators.required, Validators.email]], + name: ['', Validators.required], + phone: ['', [Validators.required, Validators.pattern(/^\(\d{3}\) \d{3}-\d{4}$/)]], + password: ['', [Validators.required, Validators.minLength(8)]], + age: ['', [Validators.required, Validators.min(18), Validators.max(120)]], + optional: [''] + }); + + constructor(private fb: FormBuilder) {} + + // Check if a field has been touched + isTouched(fieldName: string): boolean { + return this.touchedFields.has(fieldName); + } + + // Check if a field is invalid + isInvalid(fieldName: string): boolean { + const control = this.form.get(fieldName); + return !!(control && control.invalid && this.isTouched(fieldName)); + } + + // Check if a field is valid + isValid(fieldName: string): boolean { + const control = this.form.get(fieldName); + return !!(control && control.valid && this.isTouched(fieldName)); + } + + // Mark a field as touched + markTouched(fieldName: string): void { + this.touchedFields.add(fieldName); + } + + // Handle blur event + onIonBlur(fieldName: string, inputElement: IonInput): void { + this.markTouched(fieldName); + this.updateValidationClasses(fieldName, inputElement); + + // Update aria-live region if invalid + if (this.isInvalid(fieldName) && this.debugRegion) { + const metadata = this.fieldMetadata[fieldName as keyof typeof this.fieldMetadata]; + this.debugRegion.nativeElement.textContent = + `Field ${metadata.label} is invalid: ${metadata.errorText}`; + console.log('Field marked invalid:', metadata.label, metadata.errorText); + } + } + + // Handle input event + onIonInput(fieldName: string, inputElement: IonInput): void { + if (this.isTouched(fieldName)) { + this.updateValidationClasses(fieldName, inputElement); + } + } + + // Handle focusout event (with timeout to match vanilla test) + onFocusOut(fieldName: string, inputElement: IonInput): void { + setTimeout(() => { + this.markTouched(fieldName); + this.updateValidationClasses(fieldName, inputElement); + }, 10); + } + + // Update validation classes on the input element + private updateValidationClasses(fieldName: string, inputElement: IonInput): void { + const element = inputElement as any; + + if (this.isTouched(fieldName)) { + // Add ion-touched class + element.classList.add('ion-touched'); + + // Update ion-valid/ion-invalid classes + if (this.isInvalid(fieldName)) { + element.classList.remove('ion-valid'); + element.classList.add('ion-invalid'); + } else if (this.isValid(fieldName)) { + element.classList.remove('ion-invalid'); + element.classList.add('ion-valid'); + } + } + } + + // Check if form is valid (excluding optional field) + isFormValid(): boolean { + const requiredFields = ['email', 'name', 'phone', 'password', 'age']; + return requiredFields.every(field => { + const control = this.form.get(field); + return control && control.valid; + }); + } + + // Submit form + onSubmit(): void { + if (this.isFormValid()) { + alert('Form submitted successfully!'); + } + } + + // Reset form + onReset(): void { + // Reset form values + this.form.reset(); + + // Clear touched fields + this.touchedFields.clear(); + + // Remove validation classes from all inputs + const inputs = document.querySelectorAll('ion-input'); + inputs.forEach(input => { + input.classList.remove('ion-valid', 'ion-invalid', 'ion-touched'); + }); + + // Clear aria-live region + if (this.debugRegion) { + this.debugRegion.nativeElement.textContent = ''; + } + } +} diff --git a/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.html b/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.html new file mode 100644 index 00000000000..61a41d9b593 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.html @@ -0,0 +1,172 @@ + + + Textarea - Validation Test + + + + +
+

Screen Reader Testing Instructions:

+
    +
  1. Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)
  2. +
  3. Tab through the form fields
  4. +
  5. When you tab away from an empty required field, the error should be announced immediately
  6. +
  7. The error text should be announced BEFORE the next field is announced
  8. +
  9. Test in Chrome, Safari, and Firefox to verify consistent behavior
  10. +
+
+ +
+
+
+

Required Description (Min Length)

+ +
+ +
+

Required Comments

+ +
+ +
+

Bio (Max Length)

+ +
+ +
+

Address (Pattern Validation)

+ +
+ +
+

Review (Min/Max Length)

+ +
+ +
+

Optional Notes

+ +
+
+
+ +
+ Submit Form + Reset Form +
+ + +
+
diff --git a/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.scss b/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.scss new file mode 100644 index 00000000000..8c0400b3756 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.scss @@ -0,0 +1,44 @@ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + grid-row-gap: 20px; + grid-column-gap: 20px; +} + +h2 { + font-size: 12px; + font-weight: normal; + color: var(--ion-color-step-600); + margin-top: 10px; + margin-bottom: 5px; +} + +.aria-live-region { + position: absolute; + left: -10000px; + width: 1px; + height: 1px; + overflow: hidden; +} + +.validation-info { + margin: 20px; + padding: 10px; + background: var(--ion-color-light); + border-radius: 4px; +} + +.validation-info h2 { + font-size: 14px; + font-weight: 600; + margin-bottom: 10px; +} + +.validation-info ol { + margin: 0; + padding-left: 20px; +} + +.validation-info li { + margin-bottom: 5px; +} diff --git a/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.ts b/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.ts new file mode 100644 index 00000000000..f5176aadb4d --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.ts @@ -0,0 +1,217 @@ +import { Component, ElementRef, ViewChild } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + FormBuilder, + ReactiveFormsModule, + Validators, + AbstractControl, + ValidationErrors +} from '@angular/forms'; +import { + IonTextarea, + IonButton, + IonHeader, + IonToolbar, + IonTitle, + IonContent, + IonApp, + IonButtons, + IonItem, + IonList +} from '@ionic/angular/standalone'; + +// Custom validator for address (must be at least 10 chars and contain a digit) +function addressValidator(control: AbstractControl): ValidationErrors | null { + const value = control.value; + if (!value || value.length < 10) { + return { invalidAddress: true }; + } + // Check if it contains at least one number (for street/zip) + return /\d/.test(value) ? null : { invalidAddress: true }; +} + +@Component({ + selector: 'app-textarea-validation', + templateUrl: './textarea-validation.component.html', + styleUrls: ['./textarea-validation.component.scss'], + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + IonApp, + IonTextarea, + IonButton, + IonHeader, + IonToolbar, + IonTitle, + IonContent, + IonButtons, + IonItem, + IonList + ] +}) +export class TextareaValidationComponent { + @ViewChild('debugRegion', { static: true }) debugRegion?: ElementRef; + + // Track which fields have been touched (using Set like vanilla test) + touchedFields = new Set(); + + // Field metadata for labels and error messages + fieldMetadata = { + description: { + label: 'Description', + helperText: 'At least 20 characters', + errorText: 'Description must be at least 20 characters', + rows: 4 + }, + comments: { + label: 'Comments', + helperText: 'Please provide your feedback', + errorText: 'Comments are required', + rows: 4 + }, + bio: { + label: 'Bio', + helperText: 'Maximum 200 characters', + errorText: 'Bio is required', + rows: 4, + counter: true + }, + address: { + label: 'Address', + helperText: 'Include street, city, state, and zip', + errorText: 'Please enter a complete address', + rows: 3 + }, + review: { + label: 'Product Review', + helperText: 'Between 50-500 characters', + errorText: 'Review must be between 50-500 characters', + rows: 5, + counter: true + }, + notes: { + label: 'Additional Notes', + helperText: 'This field is optional', + errorText: '', + rows: 3 + } + }; + + form = this.fb.group({ + description: ['', [Validators.required, Validators.minLength(20)]], + comments: ['', Validators.required], + bio: ['', [Validators.required, Validators.maxLength(200)]], + address: ['', [Validators.required, addressValidator]], + review: ['', [Validators.required, Validators.minLength(50), Validators.maxLength(500)]], + notes: [''] + }); + + constructor(private fb: FormBuilder) {} + + // Check if a field has been touched + isTouched(fieldName: string): boolean { + return this.touchedFields.has(fieldName); + } + + // Check if a field is invalid + isInvalid(fieldName: string): boolean { + const control = this.form.get(fieldName); + return !!(control && control.invalid && this.isTouched(fieldName)); + } + + // Check if a field is valid + isValid(fieldName: string): boolean { + const control = this.form.get(fieldName); + return !!(control && control.valid && this.isTouched(fieldName)); + } + + // Mark a field as touched + markTouched(fieldName: string): void { + this.touchedFields.add(fieldName); + } + + // Handle blur event + onIonBlur(fieldName: string, textareaElement: IonTextarea): void { + this.markTouched(fieldName); + this.updateValidationClasses(fieldName, textareaElement); + + // Update aria-live region if invalid + if (this.isInvalid(fieldName) && this.debugRegion) { + const metadata = this.fieldMetadata[fieldName as keyof typeof this.fieldMetadata]; + this.debugRegion.nativeElement.textContent = + `Field ${metadata.label} is invalid: ${metadata.errorText}`; + console.log('Field marked invalid:', metadata.label, metadata.errorText); + } + } + + // Handle input event + onIonInput(fieldName: string, textareaElement: IonTextarea): void { + if (this.isTouched(fieldName)) { + this.updateValidationClasses(fieldName, textareaElement); + } + } + + // Handle focusout event (with timeout to match vanilla test) + onFocusOut(fieldName: string, textareaElement: IonTextarea): void { + setTimeout(() => { + this.markTouched(fieldName); + this.updateValidationClasses(fieldName, textareaElement); + }, 10); + } + + // Update validation classes on the textarea element + private updateValidationClasses(fieldName: string, textareaElement: IonTextarea): void { + const element = textareaElement as any; + + if (this.isTouched(fieldName)) { + // Add ion-touched class + element.classList.add('ion-touched'); + + // Update ion-valid/ion-invalid classes + if (this.isInvalid(fieldName)) { + element.classList.remove('ion-valid'); + element.classList.add('ion-invalid'); + } else if (this.isValid(fieldName)) { + element.classList.remove('ion-invalid'); + element.classList.add('ion-valid'); + } + } + } + + // Check if form is valid (excluding optional field) + isFormValid(): boolean { + const requiredFields = ['description', 'comments', 'bio', 'address', 'review']; + return requiredFields.every(field => { + const control = this.form.get(field); + return control && control.valid; + }); + } + + // Submit form + onSubmit(): void { + if (this.isFormValid()) { + alert('Form submitted successfully!'); + } + } + + // Reset form + onReset(): void { + // Reset form values + this.form.reset(); + + // Clear touched fields + this.touchedFields.clear(); + + // Remove validation classes from all textareas + const textareas = document.querySelectorAll('ion-textarea'); + textareas.forEach(textarea => { + textarea.classList.remove('ion-valid', 'ion-invalid', 'ion-touched'); + }); + + // Clear aria-live region + if (this.debugRegion) { + this.debugRegion.nativeElement.textContent = ''; + } + } +} From 1baf39e80e43a0e78c82c3f87145ae125aadc749 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Thu, 21 Aug 2025 11:17:49 -0700 Subject: [PATCH 5/6] fix(input): improve validation state reactivity --- core/src/components/input/input.tsx | 16 ++++++ core/src/components/textarea/textarea.tsx | 16 ++++++ .../input-validation.component.ts | 53 ++++++++----------- .../textarea-validation.component.ts | 47 ++++++++-------- 4 files changed, 76 insertions(+), 56 deletions(-) diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index 4c4f775e3d8..2d9b4acc8c8 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -426,6 +426,8 @@ export class Input implements ComponentInterface { const newIsInvalid = this.checkValidationState(); if (this.isInvalid !== newIsInvalid) { this.isInvalid = newIsInvalid; + // Force a re-render to update aria-describedby immediately + forceUpdate(this); } }); @@ -587,6 +589,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); }; private onFocus = (ev: FocusEvent) => { diff --git a/core/src/components/textarea/textarea.tsx b/core/src/components/textarea/textarea.tsx index b844c191182..f6ee4db6eed 100644 --- a/core/src/components/textarea/textarea.tsx +++ b/core/src/components/textarea/textarea.tsx @@ -357,6 +357,8 @@ export class Textarea implements ComponentInterface { const newIsInvalid = this.checkValidationState(); if (this.isInvalid !== newIsInvalid) { this.isInvalid = newIsInvalid; + // Force a re-render to update aria-describedby immediately + forceUpdate(this); } }); @@ -572,6 +574,20 @@ export class Textarea implements ComponentInterface { } this.didTextareaClearOnEdit = 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); }; private onKeyDown = (ev: KeyboardEvent) => { diff --git a/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.ts b/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.ts index 18315ef8e5e..07893fa93d5 100644 --- a/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.ts +++ b/packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.ts @@ -1,33 +1,19 @@ -import { Component, ElementRef, ViewChild } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { Component, ElementRef, ViewChild } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, - Validators, - AbstractControl, - ValidationErrors + Validators } from '@angular/forms'; import { - IonInput, IonButton, + IonContent, IonHeader, - IonToolbar, + IonInput, IonTitle, - IonContent, - IonApp, - IonButtons, - IonItem, - IonList + IonToolbar } from '@ionic/angular/standalone'; -// Custom validator for phone pattern -function phoneValidator(control: AbstractControl): ValidationErrors | null { - const value = control.value; - if (!value) return null; - const phonePattern = /^\(\d{3}\) \d{3}-\d{4}$/; - return phonePattern.test(value) ? null : { invalidPhone: true }; -} - @Component({ selector: 'app-input-validation', templateUrl: './input-validation.component.html', @@ -36,16 +22,12 @@ function phoneValidator(control: AbstractControl): ValidationErrors | null { imports: [ CommonModule, ReactiveFormsModule, - IonApp, IonInput, IonButton, IonHeader, IonToolbar, IonTitle, - IonContent, - IonButtons, - IonItem, - IonList + IonContent ] }) export class InputValidationComponent { @@ -125,11 +107,11 @@ export class InputValidationComponent { onIonBlur(fieldName: string, inputElement: IonInput): void { this.markTouched(fieldName); this.updateValidationClasses(fieldName, inputElement); - + // Update aria-live region if invalid if (this.isInvalid(fieldName) && this.debugRegion) { const metadata = this.fieldMetadata[fieldName as keyof typeof this.fieldMetadata]; - this.debugRegion.nativeElement.textContent = + this.debugRegion.nativeElement.textContent = `Field ${metadata.label} is invalid: ${metadata.errorText}`; console.log('Field marked invalid:', metadata.label, metadata.errorText); } @@ -152,12 +134,19 @@ export class InputValidationComponent { // Update validation classes on the input element private updateValidationClasses(fieldName: string, inputElement: IonInput): void { - const element = inputElement as any; - + // Access the native element through the Angular component + const element = (inputElement as any).el || (inputElement as any).nativeElement; + + // Ensure we have a valid element with classList + if (!element || !element.classList) { + console.warn('Could not access native element for validation classes'); + return; + } + if (this.isTouched(fieldName)) { // Add ion-touched class element.classList.add('ion-touched'); - + // Update ion-valid/ion-invalid classes if (this.isInvalid(fieldName)) { element.classList.remove('ion-valid'); @@ -189,16 +178,16 @@ export class InputValidationComponent { onReset(): void { // Reset form values this.form.reset(); - + // Clear touched fields this.touchedFields.clear(); - + // Remove validation classes from all inputs const inputs = document.querySelectorAll('ion-input'); inputs.forEach(input => { input.classList.remove('ion-valid', 'ion-invalid', 'ion-touched'); }); - + // Clear aria-live region if (this.debugRegion) { this.debugRegion.nativeElement.textContent = ''; diff --git a/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.ts b/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.ts index f5176aadb4d..3de365abf84 100644 --- a/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.ts +++ b/packages/angular/test/base/src/app/standalone/validation/textarea-validation/textarea-validation.component.ts @@ -1,23 +1,19 @@ -import { Component, ElementRef, ViewChild } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { Component, ElementRef, ViewChild } from '@angular/core'; import { + AbstractControl, FormBuilder, ReactiveFormsModule, - Validators, - AbstractControl, - ValidationErrors + ValidationErrors, + Validators } from '@angular/forms'; import { - IonTextarea, IonButton, + IonContent, IonHeader, - IonToolbar, + IonTextarea, IonTitle, - IonContent, - IonApp, - IonButtons, - IonItem, - IonList + IonToolbar } from '@ionic/angular/standalone'; // Custom validator for address (must be at least 10 chars and contain a digit) @@ -38,16 +34,12 @@ function addressValidator(control: AbstractControl): ValidationErrors | null { imports: [ CommonModule, ReactiveFormsModule, - IonApp, IonTextarea, IonButton, IonHeader, IonToolbar, IonTitle, - IonContent, - IonButtons, - IonItem, - IonList + IonContent ] }) export class TextareaValidationComponent { @@ -135,11 +127,11 @@ export class TextareaValidationComponent { onIonBlur(fieldName: string, textareaElement: IonTextarea): void { this.markTouched(fieldName); this.updateValidationClasses(fieldName, textareaElement); - + // Update aria-live region if invalid if (this.isInvalid(fieldName) && this.debugRegion) { const metadata = this.fieldMetadata[fieldName as keyof typeof this.fieldMetadata]; - this.debugRegion.nativeElement.textContent = + this.debugRegion.nativeElement.textContent = `Field ${metadata.label} is invalid: ${metadata.errorText}`; console.log('Field marked invalid:', metadata.label, metadata.errorText); } @@ -162,12 +154,19 @@ export class TextareaValidationComponent { // Update validation classes on the textarea element private updateValidationClasses(fieldName: string, textareaElement: IonTextarea): void { - const element = textareaElement as any; - + // Access the native element through the Angular component + const element = (textareaElement as any).el || (textareaElement as any).nativeElement; + + // Ensure we have a valid element with classList + if (!element || !element.classList) { + console.warn('Could not access native element for validation classes'); + return; + } + if (this.isTouched(fieldName)) { // Add ion-touched class element.classList.add('ion-touched'); - + // Update ion-valid/ion-invalid classes if (this.isInvalid(fieldName)) { element.classList.remove('ion-valid'); @@ -199,16 +198,16 @@ export class TextareaValidationComponent { onReset(): void { // Reset form values this.form.reset(); - + // Clear touched fields this.touchedFields.clear(); - + // Remove validation classes from all textareas const textareas = document.querySelectorAll('ion-textarea'); textareas.forEach(textarea => { textarea.classList.remove('ion-valid', 'ion-invalid', 'ion-touched'); }); - + // Clear aria-live region if (this.debugRegion) { this.debugRegion.nativeElement.textContent = ''; From 1a7702bc65ae458b9bf2075e27f3762efc40e604 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Thu, 21 Aug 2025 12:33:17 -0700 Subject: [PATCH 6/6] fix(input): fixing input validation accessibility for template-driven validation --- core/src/components/input/input.tsx | 38 ++++-- core/src/components/textarea/textarea.tsx | 38 ++++-- .../base/src/app/lazy/app-lazy/app.module.ts | 4 +- .../base/src/app/lazy/app-lazy/app.routes.ts | 2 + .../lazy/home-page/home-page.component.html | 5 + .../template-form.component.html | 116 ++++++++++++++++++ .../template-form/template-form.component.ts | 26 ++++ 7 files changed, 212 insertions(+), 17 deletions(-) create mode 100644 packages/angular/test/base/src/app/lazy/template-form/template-form.component.html create mode 100644 packages/angular/test/base/src/app/lazy/template-form/template-form.component.ts diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index 2d9b4acc8c8..d1beb1ff2ec 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -407,7 +407,18 @@ export class Input implements ComponentInterface { * Checks if the input is in an invalid state based on validation classes */ private checkValidationState(): boolean { - return this.el.classList.contains('ion-touched') && this.el.classList.contains('ion-invalid'); + // 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'); + + // 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() { @@ -680,15 +691,26 @@ 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 [ -
- {helperText} -
, -
- {errorText} -
, + helperText && ( +
+ {helperText} +
+ ), + errorText && ( + + ), ]; } diff --git a/core/src/components/textarea/textarea.tsx b/core/src/components/textarea/textarea.tsx index f6ee4db6eed..627338b191f 100644 --- a/core/src/components/textarea/textarea.tsx +++ b/core/src/components/textarea/textarea.tsx @@ -339,7 +339,18 @@ export class Textarea implements ComponentInterface { * Checks if the textarea is in an invalid state based on validation classes */ private checkValidationState(): boolean { - return this.el.classList.contains('ion-touched') && this.el.classList.contains('ion-invalid'); + // 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'); + + // 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() { @@ -683,15 +694,26 @@ export class Textarea 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 [ -
- {helperText} -
, -
- {errorText} -
, + helperText && ( +
+ {helperText} +
+ ), + errorText && ( + + ), ]; } diff --git a/packages/angular/test/base/src/app/lazy/app-lazy/app.module.ts b/packages/angular/test/base/src/app/lazy/app-lazy/app.module.ts index caf27670d2d..ac0ebd501fb 100644 --- a/packages/angular/test/base/src/app/lazy/app-lazy/app.module.ts +++ b/packages/angular/test/base/src/app/lazy/app-lazy/app.module.ts @@ -28,6 +28,7 @@ import { AlertComponent } from '../alert/alert.component'; import { AccordionComponent } from '../accordion/accordion.component'; import { AccordionModalComponent } from '../accordion/accordion-modal/accordion-modal.component'; import { TabsBasicComponent } from '../tabs-basic/tabs-basic.component'; +import { TemplateFormComponent } from '../template-form/template-form.component'; @NgModule({ declarations: [ @@ -53,7 +54,8 @@ import { TabsBasicComponent } from '../tabs-basic/tabs-basic.component'; AlertComponent, AccordionComponent, AccordionModalComponent, - TabsBasicComponent + TabsBasicComponent, + TemplateFormComponent ], imports: [ CommonModule, diff --git a/packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts b/packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts index 0e15ea2867d..1a46992f92c 100644 --- a/packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts +++ b/packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts @@ -19,6 +19,7 @@ import { NavigationPage3Component } from '../navigation-page3/navigation-page3.c import { AlertComponent } from '../alert/alert.component'; import { AccordionComponent } from '../accordion/accordion.component'; import { TabsBasicComponent } from '../tabs-basic/tabs-basic.component'; +import { TemplateFormComponent } from '../template-form/template-form.component'; export const routes: Routes = [ { @@ -33,6 +34,7 @@ export const routes: Routes = [ { path: 'textarea', loadChildren: () => import('../textarea/textarea.module').then(m => m.TextareaModule) }, { path: 'searchbar', loadChildren: () => import('../searchbar/searchbar.module').then(m => m.SearchbarModule) }, { path: 'form', component: FormComponent }, + { path: 'template-form', component: TemplateFormComponent }, { path: 'modals', component: ModalComponent }, { path: 'modal-inline', loadChildren: () => import('../modal-inline').then(m => m.ModalInlineModule) }, { path: 'view-child', component: ViewChildComponent }, diff --git a/packages/angular/test/base/src/app/lazy/home-page/home-page.component.html b/packages/angular/test/base/src/app/lazy/home-page/home-page.component.html index 80418148c5e..136a0119d34 100644 --- a/packages/angular/test/base/src/app/lazy/home-page/home-page.component.html +++ b/packages/angular/test/base/src/app/lazy/home-page/home-page.component.html @@ -25,6 +25,11 @@ Form Test + + + Template-Driven Form Test + + Modals Test diff --git a/packages/angular/test/base/src/app/lazy/template-form/template-form.component.html b/packages/angular/test/base/src/app/lazy/template-form/template-form.component.html new file mode 100644 index 00000000000..d33aa4ae1e5 --- /dev/null +++ b/packages/angular/test/base/src/app/lazy/template-form/template-form.component.html @@ -0,0 +1,116 @@ + + + Template-Driven Form Validation Test + + + + +
+ + + + + + + + + + +

Input Touched: {{inputField.touched}}

+

Input Invalid: {{inputField.invalid}}

+

Input Errors: {{inputField.errors | json}}

+
+
+ + + + + + + + + + +

Textarea Touched: {{textareaField.touched}}

+

Textarea Invalid: {{textareaField.invalid}}

+

Textarea Errors: {{textareaField.errors | json}}

+
+
+ + + + + + + + + + +

MinLength Touched: {{minLengthField.touched}}

+

MinLength Invalid: {{minLengthField.invalid}}

+

MinLength Errors: {{minLengthField.errors | json}}

+
+
+
+ +
+

Form Valid: {{templateForm.valid}}

+

Form Submitted: {{submitted}}

+ + + Submit Form + + + + Reset Form + + + + Mark All as Touched + +
+ +
+

Form Values:

+
{{templateForm.value | json}}
+
+
+ +
+

Instructions to reproduce issue:

+
    +
  1. Click in the "Required Input" field
  2. +
  3. Click outside without entering text
  4. +
  5. The field should show as touched and invalid
  6. +
  7. The error text should appear below the input
  8. +
  9. For screen readers, the validation state should be announced
  10. +
+

Note: With template-driven forms, Angular applies validation classes to the wrapper element, not directly to ion-input/ion-textarea.

+
+
diff --git a/packages/angular/test/base/src/app/lazy/template-form/template-form.component.ts b/packages/angular/test/base/src/app/lazy/template-form/template-form.component.ts new file mode 100644 index 00000000000..1ecdaa5e5d0 --- /dev/null +++ b/packages/angular/test/base/src/app/lazy/template-form/template-form.component.ts @@ -0,0 +1,26 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-template-form', + templateUrl: './template-form.component.html', + standalone: false +}) +export class TemplateFormComponent { + inputValue = ''; + textareaValue = ''; + minLengthValue = ''; + + // Track if form has been submitted + submitted = false; + + onSubmit(form: any) { + this.submitted = true; + console.log('Form submitted:', form.value); + console.log('Form valid:', form.valid); + } + + resetForm(form: any) { + form.reset(); + this.submitted = false; + } +}