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 }) => {