Skip to content

Commit ab733b7

Browse files
authored
fix(input): prevent Android TalkBack from focusing label separately (#30895)
Issue number: resolves internal --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? When using `ion-input` with a label on Android, TalkBack treats the visual label text as a separate focusable element. This causes the initial focus to land on the label instead of the input field, creating a confusing experience for screen reader users. ## What is the new behavior? The label text wrapper is now hidden from the accessibility tree via `aria-hidden="true"`, while the native input maintains proper labeling through `aria-labelledby`. This ensures Android TalkBack focuses directly on the input field while still announcing the label correctly. ## Does this introduce a breaking change? - [ ] Yes - [X] No <!-- If this introduces a breaking change: 1. Describe the impact and migration path for existing applications below. 2. Update the BREAKING.md file with the breaking change. 3. Add "BREAKING CHANGE: [...]" to the commit description when merging. See https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer for more information. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> Current dev build: ``` 8.7.16-dev.11767032989.1ae720d0 ```
1 parent f99d000 commit ab733b7

File tree

2 files changed

+144
-3
lines changed

2 files changed

+144
-3
lines changed

core/src/components/input/input.tsx

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export class Input implements ComponentInterface {
4848
private inputId = `ion-input-${inputIds++}`;
4949
private helperTextId = `${this.inputId}-helper-text`;
5050
private errorTextId = `${this.inputId}-error-text`;
51+
private labelTextId = `${this.inputId}-label`;
5152
private inheritedAttributes: Attributes = {};
5253
private isComposing = false;
5354
private slotMutationController?: SlotMutationController;
@@ -406,7 +407,12 @@ export class Input implements ComponentInterface {
406407
connectedCallback() {
407408
const { el } = this;
408409

409-
this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => forceUpdate(this));
410+
this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => {
411+
this.setSlottedLabelId();
412+
forceUpdate(this);
413+
});
414+
415+
this.setSlottedLabelId();
410416
this.notchController = createNotchController(
411417
el,
412418
() => this.notchSpacerEl,
@@ -721,16 +727,25 @@ export class Input implements ComponentInterface {
721727
}
722728

723729
private renderLabel() {
724-
const { label } = this;
730+
const { label, labelTextId } = this;
725731

726732
return (
727733
<div
728734
class={{
729735
'label-text-wrapper': true,
730736
'label-text-wrapper-hidden': !this.hasLabel,
731737
}}
738+
// Prevents Android TalkBack from focusing the label separately.
739+
// The input remains labelled via aria-labelledby.
740+
aria-hidden={this.hasLabel ? 'true' : null}
732741
>
733-
{label === undefined ? <slot name="label"></slot> : <div class="label-text">{label}</div>}
742+
{label === undefined ? (
743+
<slot name="label"></slot>
744+
) : (
745+
<div class="label-text" id={labelTextId}>
746+
{label}
747+
</div>
748+
)}
734749
</div>
735750
);
736751
}
@@ -743,6 +758,33 @@ export class Input implements ComponentInterface {
743758
return this.el.querySelector('[slot="label"]');
744759
}
745760

761+
/**
762+
* Ensures the slotted label element has an ID for aria-labelledby.
763+
* If no ID exists, we assign one using our generated labelTextId.
764+
*/
765+
private setSlottedLabelId() {
766+
const slottedLabel = this.labelSlot;
767+
if (slottedLabel && !slottedLabel.id) {
768+
slottedLabel.id = this.labelTextId;
769+
}
770+
}
771+
772+
/**
773+
* Returns the ID to use for aria-labelledby on the native input,
774+
* or undefined if aria-label is explicitly set (to avoid conflicts).
775+
*/
776+
private getLabelledById(): string | undefined {
777+
if (this.inheritedAttributes['aria-label']) {
778+
return undefined;
779+
}
780+
781+
if (this.label !== undefined) {
782+
return this.labelTextId;
783+
}
784+
785+
return this.labelSlot?.id || undefined;
786+
}
787+
746788
/**
747789
* Returns `true` if label content is provided
748790
* either by a prop or a content. If you want
@@ -898,6 +940,7 @@ export class Input implements ComponentInterface {
898940
onCompositionend={this.onCompositionEnd}
899941
aria-describedby={this.getHintTextID()}
900942
aria-invalid={this.isInvalid ? 'true' : undefined}
943+
aria-labelledby={this.getLabelledById()}
901944
{...this.inheritedAttributes}
902945
/>
903946
{this.clearInput && !readonly && !disabled && (

core/src/components/input/test/a11y/input.e2e.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,104 @@ configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title,
5757
});
5858
});
5959

60+
configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, config }) => {
61+
test.describe(title('input: label a11y for Android TalkBack'), () => {
62+
/**
63+
* Android TalkBack treats visible text elements as separate focusable items.
64+
* These tests verify that the label is hidden from a11y tree (aria-hidden)
65+
* while remaining associated with the input via aria-labelledby.
66+
*/
67+
test('label text wrapper should be hidden from accessibility tree when using label prop', async ({ page }) => {
68+
await page.setContent(
69+
`
70+
<ion-input label="Email" value="test@example.com"></ion-input>
71+
`,
72+
config
73+
);
74+
75+
const labelTextWrapper = page.locator('ion-input .label-text-wrapper');
76+
await expect(labelTextWrapper).toHaveAttribute('aria-hidden', 'true');
77+
});
78+
79+
test('label text wrapper should be hidden from accessibility tree when using label slot', async ({ page }) => {
80+
await page.setContent(
81+
`
82+
<ion-input value="test@example.com">
83+
<div slot="label">Email</div>
84+
</ion-input>
85+
`,
86+
config
87+
);
88+
89+
const labelTextWrapper = page.locator('ion-input .label-text-wrapper');
90+
await expect(labelTextWrapper).toHaveAttribute('aria-hidden', 'true');
91+
});
92+
93+
test('native input should have aria-labelledby pointing to label text when using label prop', async ({ page }) => {
94+
await page.setContent(
95+
`
96+
<ion-input label="Email" value="test@example.com"></ion-input>
97+
`,
98+
config
99+
);
100+
101+
const nativeInput = page.locator('ion-input input');
102+
const labelText = page.locator('ion-input .label-text');
103+
104+
const labelTextId = await labelText.getAttribute('id');
105+
expect(labelTextId).not.toBeNull();
106+
await expect(nativeInput).toHaveAttribute('aria-labelledby', labelTextId!);
107+
});
108+
109+
test('native input should have aria-labelledby pointing to slotted label when using label slot', async ({
110+
page,
111+
}) => {
112+
await page.setContent(
113+
`
114+
<ion-input value="test@example.com">
115+
<div slot="label">Email</div>
116+
</ion-input>
117+
`,
118+
config
119+
);
120+
121+
const nativeInput = page.locator('ion-input input');
122+
const slottedLabel = page.locator('ion-input [slot="label"]');
123+
124+
const slottedLabelId = await slottedLabel.getAttribute('id');
125+
expect(slottedLabelId).not.toBeNull();
126+
await expect(nativeInput).toHaveAttribute('aria-labelledby', slottedLabelId!);
127+
});
128+
129+
test('should not add aria-labelledby when aria-label is provided on host', async ({ page }) => {
130+
await page.setContent(
131+
`
132+
<ion-input aria-label="Custom Label" value="test@example.com"></ion-input>
133+
`,
134+
config
135+
);
136+
137+
const nativeInput = page.locator('ion-input input');
138+
139+
await expect(nativeInput).toHaveAttribute('aria-label', 'Custom Label');
140+
await expect(nativeInput).not.toHaveAttribute('aria-labelledby');
141+
});
142+
143+
test('should not add aria-hidden to label wrapper when no label is present', async ({ page }) => {
144+
await page.setContent(
145+
`
146+
<ion-input aria-label="Hidden Label" value="test@example.com"></ion-input>
147+
`,
148+
config
149+
);
150+
151+
const labelTextWrapper = page.locator('ion-input .label-text-wrapper');
152+
153+
await expect(labelTextWrapper).not.toHaveAttribute('aria-hidden', 'true');
154+
});
155+
});
156+
});
157+
60158
configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => {
61159
test.describe(title('input: font scaling'), () => {
62160
test('should scale text on larger font sizes', async ({ page }) => {

0 commit comments

Comments
 (0)