From ec83531a0ada2c46c5e490c295e5b603b79e5967 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Thu, 14 Aug 2025 09:47:48 -0700 Subject: [PATCH 01/16] 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 02/16] 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 03/16] 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 04/16] 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 05/16] 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 5760856ffbeb04971efe7f45bc374d553564e7f1 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Thu, 21 Aug 2025 12:33:17 -0700 Subject: [PATCH 06/16] fix(input): fixing input validation accessibility for template-driven validation --- core/src/components/input/input.tsx | 37 +++- core/src/components/textarea/textarea.tsx | 37 +++- .../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 +++ test-accessibility.html | 197 ++++++++++++++++++ 8 files changed, 407 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 create mode 100644 test-accessibility.html diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index 2d9b4acc8c8..b2677430540 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,25 @@ 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 && !isInvalid && ( +
+ {helperText} +
+ ), + errorText && isInvalid && ( + + ), ]; } diff --git a/core/src/components/textarea/textarea.tsx b/core/src/components/textarea/textarea.tsx index f6ee4db6eed..68c3de3da89 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,25 @@ 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 && !isInvalid && ( +
+ {helperText} +
+ ), + errorText && isInvalid && ( + + ), ]; } 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; + } +} diff --git a/test-accessibility.html b/test-accessibility.html new file mode 100644 index 00000000000..399447f8cb1 --- /dev/null +++ b/test-accessibility.html @@ -0,0 +1,197 @@ + + + + + + Ionic Input/Textarea Accessibility Test + + + + + + + + + +
+

Ionic Input/Textarea Accessibility Test

+ +
+

Testing Instructions with VoiceOver (macOS)

+
    +
  1. Enable VoiceOver: Press Cmd + F5
  2. +
  3. Navigate to the first input field below
  4. +
  5. Leave the field empty and tab away (press Tab key)
  6. +
  7. Listen for the error message announcement
  8. +
  9. Navigate back to the field and enter valid text
  10. +
  11. Tab away again and confirm the error is cleared
  12. +
  13. Repeat for the textarea field
  14. +
+

Expected behavior: VoiceOver should announce "Error: This field is required" when leaving an empty field, and the error should be cleared when valid text is entered.

+
+ +
+

Input Field Test

+ + + + +
+ +
+

Textarea Field Test

+ + + + +
+ +
+ Submit Form +
+ +
+ Console Output:
+
+
+
+
+ + + + From 85b8cbf2f49cc13cf0d2bb18b9f1519c504c3f76 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Tue, 26 Aug 2025 08:03:06 -0700 Subject: [PATCH 07/16] fix(input): cleanup on input validation --- core/src/components/input/input.tsx | 26 +++++++---------------- core/src/components/textarea/textarea.tsx | 26 +++++++---------------- 2 files changed, 16 insertions(+), 36 deletions(-) diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index b2677430540..2aa2447d459 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -413,11 +413,11 @@ export class Input implements ComponentInterface { 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; } @@ -694,22 +694,12 @@ export class Input implements ComponentInterface { const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this; return [ - helperText && !isInvalid && ( -
- {helperText} -
- ), - errorText && isInvalid && ( - - ), +
+ {!isInvalid ? helperText : null} +
, + , ]; } diff --git a/core/src/components/textarea/textarea.tsx b/core/src/components/textarea/textarea.tsx index 68c3de3da89..e993acfe806 100644 --- a/core/src/components/textarea/textarea.tsx +++ b/core/src/components/textarea/textarea.tsx @@ -345,11 +345,11 @@ export class Textarea implements ComponentInterface { 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; } @@ -697,22 +697,12 @@ export class Textarea implements ComponentInterface { const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this; return [ - helperText && !isInvalid && ( -
- {helperText} -
- ), - errorText && isInvalid && ( - - ), +
+ {!isInvalid ? helperText : null} +
, + , ]; } From 1d0c191074e6e1149308cb58a497334538f31f23 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Tue, 26 Aug 2025 08:10:11 -0700 Subject: [PATCH 08/16] fix(cleanup): removing file --- test-accessibility.html | 197 ---------------------------------------- 1 file changed, 197 deletions(-) delete mode 100644 test-accessibility.html diff --git a/test-accessibility.html b/test-accessibility.html deleted file mode 100644 index 399447f8cb1..00000000000 --- a/test-accessibility.html +++ /dev/null @@ -1,197 +0,0 @@ - - - - - - Ionic Input/Textarea Accessibility Test - - - - - - - - - -
-

Ionic Input/Textarea Accessibility Test

- -
-

Testing Instructions with VoiceOver (macOS)

-
    -
  1. Enable VoiceOver: Press Cmd + F5
  2. -
  3. Navigate to the first input field below
  4. -
  5. Leave the field empty and tab away (press Tab key)
  6. -
  7. Listen for the error message announcement
  8. -
  9. Navigate back to the field and enter valid text
  10. -
  11. Tab away again and confirm the error is cleared
  12. -
  13. Repeat for the textarea field
  14. -
-

Expected behavior: VoiceOver should announce "Error: This field is required" when leaving an empty field, and the error should be cleared when valid text is entered.

-
- -
-

Input Field Test

- - - - -
- -
-

Textarea Field Test

- - - - -
- -
- Submit Form -
- -
- Console Output:
-
-
-
-
- - - - From 65dd166f50b26ae41102cf827f9b5a33e9cd59bc Mon Sep 17 00:00:00 2001 From: ShaneK Date: Tue, 26 Aug 2025 08:19:16 -0700 Subject: [PATCH 09/16] Restoring tests --- core/src/components/input/test/input.spec.ts | 86 ------------------- .../components/textarea/test/textarea.spec.ts | 86 ------------------- 2 files changed, 172 deletions(-) diff --git a/core/src/components/input/test/input.spec.ts b/core/src/components/input/test/input.spec.ts index 4275325d23a..af9faac9f3c 100644 --- a/core/src/components/input/test/input.spec.ts +++ b/core/src/components/input/test/input.spec.ts @@ -133,89 +133,3 @@ 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); - expect(errorTextEl!.getAttribute('id')).toContain('error-text'); - expect(errorTextEl!.textContent).toBe('This field is required'); - }); - - it('should set aria-invalid when input is invalid', async () => { - const page = await newSpecPage({ - components: [Input], - html: ``, - }); - - const nativeInput = page.body.querySelector('ion-input input')!; - - // Should be invalid because of the classes - expect(nativeInput.getAttribute('aria-invalid')).toBe('true'); - }); - - it('should set aria-describedby to error text when invalid', async () => { - const page = await newSpecPage({ - components: [Input], - html: ``, - }); - - const nativeInput = page.body.querySelector('ion-input input')!; - const errorTextEl = page.body.querySelector('ion-input .error-text')!; - - // Verify aria-describedby points to error text - const errorId = errorTextEl.getAttribute('id'); - 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')!; - - // 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')).toBeNull(); - }); - - 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'); - }); - - 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/textarea/test/textarea.spec.ts b/core/src/components/textarea/test/textarea.spec.ts index 75f376719d9..f1611a3e291 100644 --- a/core/src/components/textarea/test/textarea.spec.ts +++ b/core/src/components/textarea/test/textarea.spec.ts @@ -85,89 +85,3 @@ 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); - expect(errorTextEl!.getAttribute('id')).toContain('error-text'); - expect(errorTextEl!.textContent).toBe('This field is required'); - }); - - it('should set aria-invalid when textarea is invalid', async () => { - const page = await newSpecPage({ - components: [Textarea], - html: ``, - }); - - const nativeTextarea = page.body.querySelector('ion-textarea textarea')!; - - // Should be invalid because of the classes - expect(nativeTextarea.getAttribute('aria-invalid')).toBe('true'); - }); - - it('should set aria-describedby to error text when invalid', async () => { - const page = await newSpecPage({ - components: [Textarea], - html: ``, - }); - - const nativeTextarea = page.body.querySelector('ion-textarea textarea')!; - const errorTextEl = page.body.querySelector('ion-textarea .error-text')!; - - // Verify aria-describedby points to error text - const errorId = errorTextEl.getAttribute('id'); - 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')!; - - // 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')).toBeNull(); - }); - - 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'); - }); - - 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'); - }); -}); From 65867ce299fa345845fc7ac6d439f2671f224935 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Tue, 2 Sep 2025 10:57:51 -0700 Subject: [PATCH 10/16] refactor(input): removing debug code --- .../input/test/validation/index.html | 6 -- .../textarea/test/validation/index.html | 6 -- .../input-validation.component.html | 39 ---------- .../input-validation.component.ts | 77 +----------------- .../textarea-validation.component.html | 39 ---------- .../textarea-validation.component.ts | 78 +------------------ 6 files changed, 6 insertions(+), 239 deletions(-) diff --git a/core/src/components/input/test/validation/index.html b/core/src/components/input/test/validation/index.html index 8fffb0f8080..3d84301bf99 100644 --- a/core/src/components/input/test/validation/index.html +++ b/core/src/components/input/test/validation/index.html @@ -165,9 +165,6 @@

Optional Field (No Validation)

Submit Form Reset Form
- - -
@@ -176,7 +173,6 @@

Optional Field (No Validation)

const inputs = document.querySelectorAll('ion-input'); const submitBtn = document.getElementById('submit-btn'); const resetBtn = document.getElementById('reset-btn'); - const debugRegion = document.getElementById('debug-region'); // Track which fields have been touched const touchedFields = new Set(); @@ -250,7 +246,6 @@

Optional Field (No Validation)

// Debug: Log to hidden aria-live region for testing const isInvalid = input.classList.contains('ion-invalid'); if (isInvalid) { - debugRegion.textContent = `Field ${input.label} is invalid: ${input.errorText}`; console.log('Field marked invalid:', input.label, input.errorText); } }); @@ -282,7 +277,6 @@

Optional Field (No Validation)

}); touchedFields.clear(); submitBtn.disabled = true; - debugRegion.textContent = ''; }); // Submit button diff --git a/core/src/components/textarea/test/validation/index.html b/core/src/components/textarea/test/validation/index.html index 0c12b879f75..8657c57574e 100644 --- a/core/src/components/textarea/test/validation/index.html +++ b/core/src/components/textarea/test/validation/index.html @@ -167,9 +167,6 @@

Optional Notes

Submit Form Reset Form
- - -
@@ -178,7 +175,6 @@

Optional Notes

const textareas = document.querySelectorAll('ion-textarea'); const submitBtn = document.getElementById('submit-btn'); const resetBtn = document.getElementById('reset-btn'); - const debugRegion = document.getElementById('debug-region'); // Track which fields have been touched const touchedFields = new Set(); @@ -251,7 +247,6 @@

Optional Notes

// Debug: Log to hidden aria-live region for testing const isInvalid = textarea.classList.contains('ion-invalid'); if (isInvalid) { - debugRegion.textContent = `Field ${textarea.label} is invalid: ${textarea.errorText}`; console.log('Field marked invalid:', textarea.label, textarea.errorText); } }); @@ -283,7 +278,6 @@

Optional Notes

}); touchedFields.clear(); submitBtn.disabled = true; - debugRegion.textContent = ''; }); // Submit button 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 index bf7fed90f32..d3c085b084b 100644 --- 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 @@ -32,12 +32,6 @@

Required Email Field

[errorText]="fieldMetadata.email.errorText" formControlName="email" required - [class.ion-touched]="isTouched('email')" - [class.ion-invalid]="isInvalid('email')" - [class.ion-valid]="isValid('email')" - (ionBlur)="onIonBlur('email', emailInput)" - (ionInput)="onIonInput('email', emailInput)" - (focusout)="onFocusOut('email', emailInput)" >
@@ -55,12 +49,6 @@

Required Name Field

[errorText]="fieldMetadata.name.errorText" formControlName="name" required - [class.ion-touched]="isTouched('name')" - [class.ion-invalid]="isInvalid('name')" - [class.ion-valid]="isValid('name')" - (ionBlur)="onIonBlur('name', nameInput)" - (ionInput)="onIonInput('name', nameInput)" - (focusout)="onFocusOut('name', nameInput)" >
@@ -79,12 +67,6 @@

Phone Number (Pattern Validation)

[errorText]="fieldMetadata.phone.errorText" formControlName="phone" required - [class.ion-touched]="isTouched('phone')" - [class.ion-invalid]="isInvalid('phone')" - [class.ion-valid]="isValid('phone')" - (ionBlur)="onIonBlur('phone', phoneInput)" - (ionInput)="onIonInput('phone', phoneInput)" - (focusout)="onFocusOut('phone', phoneInput)" > @@ -103,12 +85,6 @@

Password (Min Length)

[errorText]="fieldMetadata.password.errorText" formControlName="password" required - [class.ion-touched]="isTouched('password')" - [class.ion-invalid]="isInvalid('password')" - [class.ion-valid]="isValid('password')" - (ionBlur)="onIonBlur('password', passwordInput)" - (ionInput)="onIonInput('password', passwordInput)" - (focusout)="onFocusOut('password', passwordInput)" > @@ -128,12 +104,6 @@

Age (Number Range)

[errorText]="fieldMetadata.age.errorText" formControlName="age" required - [class.ion-touched]="isTouched('age')" - [class.ion-invalid]="isInvalid('age')" - [class.ion-valid]="isValid('age')" - (ionBlur)="onIonBlur('age', ageInput)" - (ionInput)="onIonInput('age', ageInput)" - (focusout)="onFocusOut('age', ageInput)" > @@ -149,12 +119,6 @@

Optional Field (No Validation)

placeholder="This field is optional" [helperText]="fieldMetadata.optional.helperText" formControlName="optional" - [class.ion-touched]="isTouched('optional')" - [class.ion-invalid]="isInvalid('optional')" - [class.ion-valid]="isValid('optional')" - (ionBlur)="onIonBlur('optional', optionalInput)" - (ionInput)="onIonInput('optional', optionalInput)" - (focusout)="onFocusOut('optional', optionalInput)" > @@ -164,7 +128,4 @@

Optional Field (No Validation)

Submit Form Reset Form - - -
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 07893fa93d5..f7d67754cd9 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,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, ElementRef, ViewChild } from '@angular/core'; +import { Component } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, @@ -31,8 +31,6 @@ import { ] }) export class InputValidationComponent { - @ViewChild('debugRegion', { static: true }) debugRegion?: ElementRef; - // Track which fields have been touched (using Set like vanilla test) touchedFields = new Set(); @@ -81,82 +79,18 @@ export class InputValidationComponent { 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)); + return !!(control && control.invalid && control.touched); } // 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); + return !!(control && control.valid && control.touched); } - // Update validation classes on the input element - private updateValidationClasses(fieldName: string, inputElement: IonInput): void { - // 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'); - 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 { @@ -187,10 +121,5 @@ export class InputValidationComponent { 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 index 61a41d9b593..8aa8f506b61 100644 --- 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 @@ -33,12 +33,6 @@

Required Description (Min Length)

[errorText]="fieldMetadata.description.errorText" formControlName="description" required - [class.ion-touched]="isTouched('description')" - [class.ion-invalid]="isInvalid('description')" - [class.ion-valid]="isValid('description')" - (ionBlur)="onIonBlur('description', descriptionTextarea)" - (ionInput)="onIonInput('description', descriptionTextarea)" - (focusout)="onFocusOut('description', descriptionTextarea)" > @@ -56,12 +50,6 @@

Required Comments

[errorText]="fieldMetadata.comments.errorText" formControlName="comments" required - [class.ion-touched]="isTouched('comments')" - [class.ion-invalid]="isInvalid('comments')" - [class.ion-valid]="isValid('comments')" - (ionBlur)="onIonBlur('comments', commentsTextarea)" - (ionInput)="onIonInput('comments', commentsTextarea)" - (focusout)="onFocusOut('comments', commentsTextarea)" > @@ -81,12 +69,6 @@

Bio (Max Length)

[errorText]="fieldMetadata.bio.errorText" formControlName="bio" required - [class.ion-touched]="isTouched('bio')" - [class.ion-invalid]="isInvalid('bio')" - [class.ion-valid]="isValid('bio')" - (ionBlur)="onIonBlur('bio', bioTextarea)" - (ionInput)="onIonInput('bio', bioTextarea)" - (focusout)="onFocusOut('bio', bioTextarea)" > @@ -104,12 +86,6 @@

Address (Pattern Validation)

[errorText]="fieldMetadata.address.errorText" formControlName="address" required - [class.ion-touched]="isTouched('address')" - [class.ion-invalid]="isInvalid('address')" - [class.ion-valid]="isValid('address')" - (ionBlur)="onIonBlur('address', addressTextarea)" - (ionInput)="onIonInput('address', addressTextarea)" - (focusout)="onFocusOut('address', addressTextarea)" > @@ -130,12 +106,6 @@

Review (Min/Max Length)

[errorText]="fieldMetadata.review.errorText" formControlName="review" required - [class.ion-touched]="isTouched('review')" - [class.ion-invalid]="isInvalid('review')" - [class.ion-valid]="isValid('review')" - (ionBlur)="onIonBlur('review', reviewTextarea)" - (ionInput)="onIonInput('review', reviewTextarea)" - (focusout)="onFocusOut('review', reviewTextarea)" > @@ -151,12 +121,6 @@

Optional Notes

[rows]="fieldMetadata.notes.rows" [helperText]="fieldMetadata.notes.helperText" formControlName="notes" - [class.ion-touched]="isTouched('notes')" - [class.ion-invalid]="isInvalid('notes')" - [class.ion-valid]="isValid('notes')" - (ionBlur)="onIonBlur('notes', notesTextarea)" - (ionInput)="onIonInput('notes', notesTextarea)" - (focusout)="onFocusOut('notes', notesTextarea)" > @@ -166,7 +130,4 @@

Optional Notes

Submit Form Reset Form - - -
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 3de365abf84..3756ddefcd0 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,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, ElementRef, ViewChild } from '@angular/core'; +import { Component } from '@angular/core'; import { AbstractControl, FormBuilder, @@ -43,8 +43,6 @@ function addressValidator(control: AbstractControl): ValidationErrors | null { ] }) export class TextareaValidationComponent { - @ViewChild('debugRegion', { static: true }) debugRegion?: ElementRef; - // Track which fields have been touched (using Set like vanilla test) touchedFields = new Set(); @@ -101,81 +99,16 @@ export class TextareaValidationComponent { 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)); + return !!(control && control.invalid && control.touched); } // 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 { - // 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'); - element.classList.add('ion-invalid'); - } else if (this.isValid(fieldName)) { - element.classList.remove('ion-invalid'); - element.classList.add('ion-valid'); - } - } + return !!(control && control.valid && control.touched); } // Check if form is valid (excluding optional field) @@ -207,10 +140,5 @@ export class TextareaValidationComponent { textareas.forEach(textarea => { textarea.classList.remove('ion-valid', 'ion-invalid', 'ion-touched'); }); - - // Clear aria-live region - if (this.debugRegion) { - this.debugRegion.nativeElement.textContent = ''; - } } } From df8fa7d555b8a2d7bd4592c67f0cb756a5a00834 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Tue, 2 Sep 2025 12:01:04 -0700 Subject: [PATCH 11/16] refactor(input): removing angular-specific code from core --- core/src/components/input/input.tsx | 25 ++--------------------- core/src/components/textarea/textarea.tsx | 25 ++--------------------- 2 files changed, 4 insertions(+), 46 deletions(-) diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index 2aa2447d459..6ea263e5454 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -404,21 +404,13 @@ export class Input implements ComponentInterface { } /** - * Checks if the input is in an invalid state based on validation classes + * Checks if the input is in an invalid state based on Ionic 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'); - // Return true if we have both touched and invalid states from either framework - const isTouched = hasIonTouched || hasNgTouched; - const isInvalid = hasIonInvalid || hasNgInvalid; - - return isTouched && isInvalid; + return hasIonTouched && hasIonInvalid; } connectedCallback() { @@ -601,19 +593,6 @@ export class Input implements ComponentInterface { 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 e993acfe806..88d71b478e6 100644 --- a/core/src/components/textarea/textarea.tsx +++ b/core/src/components/textarea/textarea.tsx @@ -336,21 +336,13 @@ export class Textarea implements ComponentInterface { } /** - * Checks if the textarea is in an invalid state based on validation classes + * Checks if the textarea is in an invalid state based on Ionic 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'); - // Return true if we have both touched and invalid states from either framework - const isTouched = hasIonTouched || hasNgTouched; - const isInvalid = hasIonInvalid || hasNgInvalid; - - return isTouched && isInvalid; + return hasIonTouched && hasIonInvalid; } connectedCallback() { @@ -586,19 +578,6 @@ 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) => { From 0180454bbd593081089128b03554d7ff359137de Mon Sep 17 00:00:00 2001 From: ShaneK Date: Tue, 2 Sep 2025 12:02:16 -0700 Subject: [PATCH 12/16] fix(lint): fixing lint in core --- core/src/components/input/input.tsx | 1 - core/src/components/textarea/textarea.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index 6ea263e5454..829f2e34df6 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -592,7 +592,6 @@ export class Input implements ComponentInterface { this.didInputClearOnEdit = false; this.ionBlur.emit(ev); - }; private onFocus = (ev: FocusEvent) => { diff --git a/core/src/components/textarea/textarea.tsx b/core/src/components/textarea/textarea.tsx index 88d71b478e6..83c1b91c2e4 100644 --- a/core/src/components/textarea/textarea.tsx +++ b/core/src/components/textarea/textarea.tsx @@ -577,7 +577,6 @@ export class Textarea implements ComponentInterface { } this.didTextareaClearOnEdit = false; this.ionBlur.emit(ev); - }; private onKeyDown = (ev: KeyboardEvent) => { From 7fc8cbcf96965be9e50502806a768be7b5c679fb Mon Sep 17 00:00:00 2001 From: Shane Date: Wed, 3 Sep 2025 10:24:59 -0700 Subject: [PATCH 13/16] Update packages/angular/test/base/src/app/standalone/validation/input-validation/input-validation.component.scss Co-authored-by: Brandy Smith --- .../input-validation/input-validation.component.scss | 8 -------- 1 file changed, 8 deletions(-) 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 index abf7b3d12d7..add228ccab1 100644 --- 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 @@ -13,14 +13,6 @@ h2 { margin-bottom: 5px; } -.aria-live-region { - position: absolute; - left: -10000px; - width: 1px; - height: 1px; - overflow: hidden; -} - .validation-info { margin: 20px; padding: 10px; From 2db2b1f54d5ceb6accf75cc2ed102e7c281ac72d Mon Sep 17 00:00:00 2001 From: Shane Date: Wed, 3 Sep 2025 10:29:12 -0700 Subject: [PATCH 14/16] Update core/src/components/input/test/validation/index.html Co-authored-by: Brandy Smith --- core/src/components/input/test/validation/index.html | 1 - 1 file changed, 1 deletion(-) diff --git a/core/src/components/input/test/validation/index.html b/core/src/components/input/test/validation/index.html index 3d84301bf99..556ba15545f 100644 --- a/core/src/components/input/test/validation/index.html +++ b/core/src/components/input/test/validation/index.html @@ -243,7 +243,6 @@

Optional Field (No Validation)

validateField(input); validateForm(); - // Debug: Log to hidden aria-live region for testing const isInvalid = input.classList.contains('ion-invalid'); if (isInvalid) { console.log('Field marked invalid:', input.label, input.errorText); From 7926f08ce20d6cc82daab9fb8e6b9a45b5433885 Mon Sep 17 00:00:00 2001 From: Shane Date: Wed, 3 Sep 2025 10:29:23 -0700 Subject: [PATCH 15/16] Update core/src/components/textarea/test/validation/index.html Co-authored-by: Brandy Smith --- core/src/components/textarea/test/validation/index.html | 1 - 1 file changed, 1 deletion(-) diff --git a/core/src/components/textarea/test/validation/index.html b/core/src/components/textarea/test/validation/index.html index 8657c57574e..3c832e7979e 100644 --- a/core/src/components/textarea/test/validation/index.html +++ b/core/src/components/textarea/test/validation/index.html @@ -244,7 +244,6 @@

Optional Notes

validateField(textarea); validateForm(); - // Debug: Log to hidden aria-live region for testing const isInvalid = textarea.classList.contains('ion-invalid'); if (isInvalid) { console.log('Field marked invalid:', textarea.label, textarea.errorText); From 1493bf7e321fd4c8ba2396c60885336b72d568c7 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Thu, 4 Sep 2025 07:03:39 -0700 Subject: [PATCH 16/16] refactor(tests, input): doing cleanup across input and tests --- core/src/components/input/input.tsx | 6 +-- .../input/test/validation/index.html | 8 ---- .../textarea/test/validation/index.html | 8 ---- .../input-validation.component.html | 4 +- .../input-validation.component.ts | 42 +------------------ .../textarea-validation.component.html | 4 +- .../textarea-validation.component.scss | 8 ---- .../textarea-validation.component.ts | 41 +----------------- 8 files changed, 9 insertions(+), 112 deletions(-) diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index 829f2e34df6..ccb80120ca8 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -406,7 +406,7 @@ export class Input implements ComponentInterface { /** * Checks if the input is in an invalid state based on Ionic validation classes */ - private checkValidationState(): boolean { + private checkInvalidState(): boolean { const hasIonTouched = this.el.classList.contains('ion-touched'); const hasIonInvalid = this.el.classList.contains('ion-invalid'); @@ -426,7 +426,7 @@ export class Input implements ComponentInterface { // Watch for class changes to update validation state if (Build.isBrowser && typeof MutationObserver !== 'undefined') { this.validationObserver = new MutationObserver(() => { - const newIsInvalid = this.checkValidationState(); + const newIsInvalid = this.checkInvalidState(); if (this.isInvalid !== newIsInvalid) { this.isInvalid = newIsInvalid; // Force a re-render to update aria-describedby immediately @@ -441,7 +441,7 @@ export class Input implements ComponentInterface { } // Always set initial state - this.isInvalid = this.checkValidationState(); + this.isInvalid = this.checkInvalidState(); this.debounceChanged(); if (Build.isBrowser) { diff --git a/core/src/components/input/test/validation/index.html b/core/src/components/input/test/validation/index.html index 556ba15545f..2a6ad89e13f 100644 --- a/core/src/components/input/test/validation/index.html +++ b/core/src/components/input/test/validation/index.html @@ -30,14 +30,6 @@ margin-bottom: 5px; } - .aria-live-region { - position: absolute; - left: -10000px; - width: 1px; - height: 1px; - overflow: hidden; - } - .validation-info { margin: 20px; padding: 10px; diff --git a/core/src/components/textarea/test/validation/index.html b/core/src/components/textarea/test/validation/index.html index 3c832e7979e..6f977a7d915 100644 --- a/core/src/components/textarea/test/validation/index.html +++ b/core/src/components/textarea/test/validation/index.html @@ -30,14 +30,6 @@ margin-bottom: 5px; } - .aria-live-region { - position: absolute; - left: -10000px; - width: 1px; - height: 1px; - overflow: hidden; - } - .validation-info { margin: 20px; padding: 10px; 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 index d3c085b084b..b91f127c189 100644 --- 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 @@ -125,7 +125,7 @@

Optional Field (No Validation)

- Submit Form - Reset Form + Submit Form + Reset Form
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 f7d67754cd9..aee73b0735f 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 @@ -31,9 +31,6 @@ import { ] }) export class InputValidationComponent { - // Track which fields have been touched (using Set like vanilla test) - touchedFields = new Set(); - // Field metadata for labels and error messages fieldMetadata = { email: { @@ -79,47 +76,10 @@ export class InputValidationComponent { constructor(private fb: FormBuilder) {} - // Check if a field is invalid - isInvalid(fieldName: string): boolean { - const control = this.form.get(fieldName); - return !!(control && control.invalid && control.touched); - } - - // Check if a field is valid - isValid(fieldName: string): boolean { - const control = this.form.get(fieldName); - return !!(control && control.valid && control.touched); - } - - - // 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()) { + if (this.form.valid) { 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'); - }); - } } 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 index 8aa8f506b61..d32a9dd8627 100644 --- 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 @@ -127,7 +127,7 @@

Optional Notes

- Submit Form - Reset Form + 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 index 8c0400b3756..6462ef79f69 100644 --- 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 @@ -13,14 +13,6 @@ h2 { margin-bottom: 5px; } -.aria-live-region { - position: absolute; - left: -10000px; - width: 1px; - height: 1px; - overflow: hidden; -} - .validation-info { margin: 20px; padding: 10px; 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 3756ddefcd0..a942bac78df 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 @@ -43,9 +43,6 @@ function addressValidator(control: AbstractControl): ValidationErrors | null { ] }) export class TextareaValidationComponent { - // Track which fields have been touched (using Set like vanilla test) - touchedFields = new Set(); - // Field metadata for labels and error messages fieldMetadata = { description: { @@ -99,46 +96,10 @@ export class TextareaValidationComponent { constructor(private fb: FormBuilder) {} - // Check if a field is invalid - isInvalid(fieldName: string): boolean { - const control = this.form.get(fieldName); - return !!(control && control.invalid && control.touched); - } - - // Check if a field is valid - isValid(fieldName: string): boolean { - const control = this.form.get(fieldName); - return !!(control && control.valid && control.touched); - } - - // 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()) { + if (this.form.valid) { 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'); - }); - } }