diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index f2430eabf32..2a9b9ec34e7 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -33,6 +33,8 @@ import { getCounterText } from './input.utils'; export class Input implements ComponentInterface { private nativeInput?: HTMLInputElement; private inputId = `ion-input-${inputIds++}`; + private helperTextId = `${this.inputId}-helper-text`; + private errorTextId = `${this.inputId}-error-text`; private inheritedAttributes: Attributes = {}; private isComposing = false; private slotMutationController?: SlotMutationController; @@ -573,9 +575,30 @@ export class Input implements ComponentInterface { * Renders the helper text or error text values */ private renderHintText() { - const { helperText, errorText } = this; + const { helperText, errorText, helperTextId, errorTextId } = this; + + return [ +
+ {helperText} +
, +
+ {errorText} +
, + ]; + } + + private getHintTextID(): string | undefined { + const { el, helperText, errorText, helperTextId, errorTextId } = this; + + if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) { + return errorTextId; + } + + if (helperText) { + return helperTextId; + } - return [
{helperText}
,
{errorText}
]; + return undefined; } private renderCounter() { @@ -777,6 +800,8 @@ export class Input implements ComponentInterface { onKeyDown={this.onKeydown} onCompositionstart={this.onCompositionStart} onCompositionend={this.onCompositionEnd} + aria-describedby={this.getHintTextID()} + aria-invalid={this.getHintTextID() === this.errorTextId} {...this.inheritedAttributes} /> {this.clearInput && !readonly && !disabled && ( diff --git a/core/src/components/input/test/bottom-content/input.e2e.ts b/core/src/components/input/test/bottom-content/input.e2e.ts index 9f09ffd78a4..5de0ca79e19 100644 --- a/core/src/components/input/test/bottom-content/input.e2e.ts +++ b/core/src/components/input/test/bottom-content/input.e2e.ts @@ -68,6 +68,19 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co await expect(helperText).toHaveText('my helper'); await expect(errorText).toBeHidden(); }); + test('input should have an aria-describedby attribute when helper text is present', async ({ page }) => { + await page.setContent( + ``, + config + ); + + const input = page.locator('ion-input input'); + const helperText = page.locator('ion-input .helper-text'); + const helperTextId = await helperText.getAttribute('id'); + const ariaDescribedBy = await input.getAttribute('aria-describedby'); + + expect(ariaDescribedBy).toBe(helperTextId); + }); test('error text should be visible when input is invalid', async ({ page }) => { await page.setContent( ``, @@ -96,6 +109,48 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co const errorText = page.locator('ion-input .error-text'); await expect(errorText).toHaveScreenshot(screenshot(`input-error-custom-color`)); }); + test('input should have an aria-describedby attribute when error text is present', async ({ page }) => { + await page.setContent( + ``, + config + ); + + const input = page.locator('ion-input input'); + const errorText = page.locator('ion-input .error-text'); + const errorTextId = await errorText.getAttribute('id'); + const ariaDescribedBy = await input.getAttribute('aria-describedby'); + + expect(ariaDescribedBy).toBe(errorTextId); + }); + test('input should have aria-invalid attribute when input is invalid', async ({ page }) => { + await page.setContent( + ``, + config + ); + + const input = page.locator('ion-input input'); + + await expect(input).toHaveAttribute('aria-invalid'); + }); + test('input should not have aria-invalid attribute when input is valid', async ({ page }) => { + await page.setContent( + ``, + config + ); + + const input = page.locator('ion-input input'); + + await expect(input).not.toHaveAttribute('aria-invalid'); + }); + test('input should not have aria-describedby attribute when no hint or error text is present', async ({ + page, + }) => { + await page.setContent(``, config); + + const input = page.locator('ion-input input'); + + await expect(input).not.toHaveAttribute('aria-describedby'); + }); }); test.describe('input: hint text rendering', () => { test.describe('regular inputs', () => { diff --git a/core/src/components/textarea/test/bottom-content/textarea.e2e.ts b/core/src/components/textarea/test/bottom-content/textarea.e2e.ts index e6c60e1ce0d..79fcfc4cfac 100644 --- a/core/src/components/textarea/test/bottom-content/textarea.e2e.ts +++ b/core/src/components/textarea/test/bottom-content/textarea.e2e.ts @@ -27,6 +27,19 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co await expect(helperText).toHaveText('my helper'); await expect(errorText).toBeHidden(); }); + test('textarea should have an aria-describedby attribute when helper text is present', async ({ page }) => { + await page.setContent( + ``, + config + ); + + const textarea = page.locator('ion-textarea textarea'); + const helperText = page.locator('ion-textarea .helper-text'); + const helperTextId = await helperText.getAttribute('id'); + const ariaDescribedBy = await textarea.getAttribute('aria-describedby'); + + expect(ariaDescribedBy).toBe(helperTextId); + }); test('error text should be visible when textarea is invalid', async ({ page }) => { await page.setContent( ``, @@ -55,6 +68,48 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co const errorText = page.locator('ion-textarea .error-text'); await expect(errorText).toHaveScreenshot(screenshot(`textarea-error-custom-color`)); }); + test('textarea should have an aria-describedby attribute when error text is present', async ({ page }) => { + await page.setContent( + ``, + config + ); + + const textarea = page.locator('ion-textarea textarea'); + const errorText = page.locator('ion-textarea .error-text'); + const errorTextId = await errorText.getAttribute('id'); + const ariaDescribedBy = await textarea.getAttribute('aria-describedby'); + + expect(ariaDescribedBy).toBe(errorTextId); + }); + test('textarea should have aria-invalid attribute when input is invalid', async ({ page }) => { + await page.setContent( + ``, + config + ); + + const textarea = page.locator('ion-textarea textarea'); + + await expect(textarea).toHaveAttribute('aria-invalid'); + }); + test('textarea should not have aria-invalid attribute when input is valid', async ({ page }) => { + await page.setContent( + ``, + config + ); + + const textarea = page.locator('ion-textarea textarea'); + + await expect(textarea).not.toHaveAttribute('aria-invalid'); + }); + test('textarea should not have aria-describedby attribute when no hint or error text is present', async ({ + page, + }) => { + await page.setContent(``, config); + + const textarea = page.locator('ion-textarea textarea'); + + await expect(textarea).not.toHaveAttribute('aria-describedby'); + }); }); test.describe('textarea: hint text rendering', () => { test.describe('regular textareas', () => { diff --git a/core/src/components/textarea/textarea.tsx b/core/src/components/textarea/textarea.tsx index 09d66928cfb..3349f0c1a85 100644 --- a/core/src/components/textarea/textarea.tsx +++ b/core/src/components/textarea/textarea.tsx @@ -45,6 +45,8 @@ import type { TextareaChangeEventDetail, TextareaInputEventDetail } from './text export class Textarea implements ComponentInterface { private nativeInput?: HTMLTextAreaElement; private inputId = `ion-textarea-${textareaIds++}`; + private helperTextId = `${this.inputId}-helper-text`; + private errorTextId = `${this.inputId}-error-text`; /** * `true` if the textarea was cleared as a result of the user typing * with `clearOnEdit` enabled. @@ -576,9 +578,30 @@ export class Textarea implements ComponentInterface { * Renders the helper text or error text values */ private renderHintText() { - const { helperText, errorText } = this; + const { helperText, errorText, helperTextId, errorTextId } = this; + + return [ +
+ {helperText} +
, +
+ {errorText} +
, + ]; + } + + private getHintTextID(): string | undefined { + const { el, helperText, errorText, helperTextId, errorTextId } = this; + + if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) { + return errorTextId; + } + + if (helperText) { + return helperTextId; + } - return [
{helperText}
,
{errorText}
]; + return undefined; } private renderCounter() { @@ -703,6 +726,8 @@ export class Textarea implements ComponentInterface { onBlur={this.onBlur} onFocus={this.onFocus} onKeyDown={this.onKeyDown} + aria-describedby={this.getHintTextID()} + aria-invalid={this.getHintTextID() === this.errorTextId} {...this.inheritedAttributes} > {value}