Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 46 additions & 3 deletions core/src/components/input/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -721,16 +727,25 @@ export class Input implements ComponentInterface {
}

private renderLabel() {
const { label } = this;
const { label, labelTextId } = this;

return (
<div
class={{
'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>
);
}
Expand All @@ -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
Expand Down Expand Up @@ -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 && (
Expand Down
98 changes: 98 additions & 0 deletions core/src/components/input/test/a11y/input.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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="[email protected]"></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="[email protected]">
<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="[email protected]"></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="[email protected]">
<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="[email protected]"></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="[email protected]"></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 }) => {
Expand Down
Loading