From ae720d0b349226be3efb01391dfa0096cda81e49 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Mon, 29 Dec 2025 10:05:26 -0800 Subject: [PATCH] fix(input): prevent Android TalkBack from focusing label separately --- core/src/components/input/input.tsx | 49 +++++++++- .../components/input/test/a11y/input.e2e.ts | 98 +++++++++++++++++++ 2 files changed, 144 insertions(+), 3 deletions(-) diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index 19c5a9d406f..575a14df9d6 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -48,6 +48,7 @@ export class Input implements ComponentInterface { private inputId = `ion-input-${inputIds++}`; private helperTextId = `${this.inputId}-helper-text`; private errorTextId = `${this.inputId}-error-text`; + private labelTextId = `${this.inputId}-label`; private inheritedAttributes: Attributes = {}; private isComposing = false; private slotMutationController?: SlotMutationController; @@ -406,7 +407,12 @@ export class Input implements ComponentInterface { connectedCallback() { const { el } = this; - this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => forceUpdate(this)); + this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => { + this.setSlottedLabelId(); + forceUpdate(this); + }); + + this.setSlottedLabelId(); this.notchController = createNotchController( el, () => this.notchSpacerEl, @@ -721,7 +727,7 @@ export class Input implements ComponentInterface { } private renderLabel() { - const { label } = this; + const { label, labelTextId } = this; return (
- {label === undefined ? :
{label}
} + {label === undefined ? ( + + ) : ( +
+ {label} +
+ )}
); } @@ -743,6 +758,33 @@ export class Input implements ComponentInterface { return this.el.querySelector('[slot="label"]'); } + /** + * Ensures the slotted label element has an ID for aria-labelledby. + * If no ID exists, we assign one using our generated labelTextId. + */ + private setSlottedLabelId() { + const slottedLabel = this.labelSlot; + if (slottedLabel && !slottedLabel.id) { + slottedLabel.id = this.labelTextId; + } + } + + /** + * Returns the ID to use for aria-labelledby on the native input, + * or undefined if aria-label is explicitly set (to avoid conflicts). + */ + private getLabelledById(): string | undefined { + if (this.inheritedAttributes['aria-label']) { + return undefined; + } + + if (this.label !== undefined) { + return this.labelTextId; + } + + return this.labelSlot?.id || undefined; + } + /** * Returns `true` if label content is provided * either by a prop or a content. If you want @@ -898,6 +940,7 @@ export class Input implements ComponentInterface { onCompositionend={this.onCompositionEnd} aria-describedby={this.getHintTextID()} aria-invalid={this.isInvalid ? 'true' : undefined} + aria-labelledby={this.getLabelledById()} {...this.inheritedAttributes} /> {this.clearInput && !readonly && !disabled && ( diff --git a/core/src/components/input/test/a11y/input.e2e.ts b/core/src/components/input/test/a11y/input.e2e.ts index 6a40385c925..21ce46c52b2 100644 --- a/core/src/components/input/test/a11y/input.e2e.ts +++ b/core/src/components/input/test/a11y/input.e2e.ts @@ -57,6 +57,104 @@ configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title, }); }); +configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, config }) => { + test.describe(title('input: label a11y for Android TalkBack'), () => { + /** + * Android TalkBack treats visible text elements as separate focusable items. + * These tests verify that the label is hidden from a11y tree (aria-hidden) + * while remaining associated with the input via aria-labelledby. + */ + test('label text wrapper should be hidden from accessibility tree when using label prop', async ({ page }) => { + await page.setContent( + ` + + `, + config + ); + + const labelTextWrapper = page.locator('ion-input .label-text-wrapper'); + await expect(labelTextWrapper).toHaveAttribute('aria-hidden', 'true'); + }); + + test('label text wrapper should be hidden from accessibility tree when using label slot', async ({ page }) => { + await page.setContent( + ` + +
Email
+
+ `, + config + ); + + const labelTextWrapper = page.locator('ion-input .label-text-wrapper'); + await expect(labelTextWrapper).toHaveAttribute('aria-hidden', 'true'); + }); + + test('native input should have aria-labelledby pointing to label text when using label prop', async ({ page }) => { + await page.setContent( + ` + + `, + config + ); + + const nativeInput = page.locator('ion-input input'); + const labelText = page.locator('ion-input .label-text'); + + const labelTextId = await labelText.getAttribute('id'); + expect(labelTextId).not.toBeNull(); + await expect(nativeInput).toHaveAttribute('aria-labelledby', labelTextId!); + }); + + test('native input should have aria-labelledby pointing to slotted label when using label slot', async ({ + page, + }) => { + await page.setContent( + ` + +
Email
+
+ `, + config + ); + + const nativeInput = page.locator('ion-input input'); + const slottedLabel = page.locator('ion-input [slot="label"]'); + + const slottedLabelId = await slottedLabel.getAttribute('id'); + expect(slottedLabelId).not.toBeNull(); + await expect(nativeInput).toHaveAttribute('aria-labelledby', slottedLabelId!); + }); + + test('should not add aria-labelledby when aria-label is provided on host', async ({ page }) => { + await page.setContent( + ` + + `, + config + ); + + const nativeInput = page.locator('ion-input input'); + + await expect(nativeInput).toHaveAttribute('aria-label', 'Custom Label'); + await expect(nativeInput).not.toHaveAttribute('aria-labelledby'); + }); + + test('should not add aria-hidden to label wrapper when no label is present', async ({ page }) => { + await page.setContent( + ` + + `, + config + ); + + const labelTextWrapper = page.locator('ion-input .label-text-wrapper'); + + await expect(labelTextWrapper).not.toHaveAttribute('aria-hidden', 'true'); + }); + }); +}); + configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => { test.describe(title('input: font scaling'), () => { test('should scale text on larger font sizes', async ({ page }) => {