mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
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 ```
This commit is contained in:
@@ -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 (
|
||||
<div
|
||||
@@ -729,8 +735,17 @@ export class Input implements ComponentInterface {
|
||||
'label-text-wrapper': true,
|
||||
'label-text-wrapper-hidden': !this.hasLabel,
|
||||
}}
|
||||
// Prevents Android TalkBack from focusing the label separately.
|
||||
// The input remains labelled via aria-labelledby.
|
||||
aria-hidden={this.hasLabel ? 'true' : null}
|
||||
>
|
||||
{label === undefined ? <slot name="label"></slot> : <div class="label-text">{label}</div>}
|
||||
{label === undefined ? (
|
||||
<slot name="label"></slot>
|
||||
) : (
|
||||
<div class="label-text" id={labelTextId}>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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(
|
||||
`
|
||||
<ion-input label="Email" value="test@example.com"></ion-input>
|
||||
`,
|
||||
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(
|
||||
`
|
||||
<ion-input value="test@example.com">
|
||||
<div slot="label">Email</div>
|
||||
</ion-input>
|
||||
`,
|
||||
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(
|
||||
`
|
||||
<ion-input label="Email" value="test@example.com"></ion-input>
|
||||
`,
|
||||
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(
|
||||
`
|
||||
<ion-input value="test@example.com">
|
||||
<div slot="label">Email</div>
|
||||
</ion-input>
|
||||
`,
|
||||
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(
|
||||
`
|
||||
<ion-input aria-label="Custom Label" value="test@example.com"></ion-input>
|
||||
`,
|
||||
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(
|
||||
`
|
||||
<ion-input aria-label="Hidden Label" value="test@example.com"></ion-input>
|
||||
`,
|
||||
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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user