Skip to content

Commit 56190b2

Browse files
committed
Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-6830
2 parents 8ee1069 + 95b8702 commit 56190b2

File tree

9 files changed

+229
-14
lines changed

9 files changed

+229
-14
lines changed

.github/ionic-issue-bot.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ comment:
4040
4141
4242
If the requested feature is something you would find useful for your applications, please react to the original post with 👍 (`+1`). If you would like to provide an additional use case for the feature, please post a comment.
43-
43+
4444
4545
The team will review this feedback and make a final decision. Any decision will be posted on this thread, but please note that we may ultimately decide not to pursue this feature.
4646
@@ -83,6 +83,7 @@ stale:
8383
exemptLabels:
8484
- "good first issue"
8585
- "triage"
86+
- "bug: external"
8687
- "type: bug"
8788
- "type: feature request"
8889
- "needs: investigation"

core/src/components/footer/footer.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export class Footer implements ComponentInterface {
2222
private scrollEl?: HTMLElement;
2323
private contentScrollCallback?: () => void;
2424
private keyboardCtrl: KeyboardController | null = null;
25+
private keyboardCtrlPromise: Promise<KeyboardController> | null = null;
2526

2627
@State() private keyboardVisible = false;
2728

@@ -52,7 +53,7 @@ export class Footer implements ComponentInterface {
5253
}
5354

5455
async connectedCallback() {
55-
this.keyboardCtrl = await createKeyboardController(async (keyboardOpen, waitForResize) => {
56+
const promise = createKeyboardController(async (keyboardOpen, waitForResize) => {
5657
/**
5758
* If the keyboard is hiding, then we need to wait
5859
* for the webview to resize. Otherwise, the footer
@@ -64,11 +65,32 @@ export class Footer implements ComponentInterface {
6465

6566
this.keyboardVisible = keyboardOpen; // trigger re-render by updating state
6667
});
68+
this.keyboardCtrlPromise = promise;
69+
70+
const keyboardCtrl = await promise;
71+
72+
/**
73+
* Only assign if this is still the current promise.
74+
* Otherwise, a new connectedCallback has started or
75+
* disconnectedCallback was called, so destroy this instance.
76+
*/
77+
if (this.keyboardCtrlPromise === promise) {
78+
this.keyboardCtrl = keyboardCtrl;
79+
this.keyboardCtrlPromise = null;
80+
} else {
81+
keyboardCtrl.destroy();
82+
}
6783
}
6884

6985
disconnectedCallback() {
86+
if (this.keyboardCtrlPromise) {
87+
this.keyboardCtrlPromise.then((ctrl) => ctrl.destroy());
88+
this.keyboardCtrlPromise = null;
89+
}
90+
7091
if (this.keyboardCtrl) {
7192
this.keyboardCtrl.destroy();
93+
this.keyboardCtrl = null;
7294
}
7395
}
7496

core/src/components/input/input.scss

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,13 @@
165165
// otherwise the .input-cover will not be rendered at all
166166
// The input cover is not clickable when the input is disabled
167167
.cloned-input {
168-
@include position(0, null, 0, 0);
169-
170168
position: absolute;
169+
top: 0;
170+
bottom: 0;
171+
172+
// Reset height since absolute positioning with top/bottom handles sizing
173+
height: auto;
174+
max-height: none;
171175

172176
pointer-events: none;
173177
}

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

core/src/components/tab-bar/tab-bar.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type { TabBarChangedEventDetail } from './tab-bar-interface';
2222
})
2323
export class TabBar implements ComponentInterface {
2424
private keyboardCtrl: KeyboardController | null = null;
25+
private keyboardCtrlPromise: Promise<KeyboardController> | null = null;
2526
private didLoad = false;
2627

2728
@Element() el!: HTMLElement;
@@ -88,7 +89,7 @@ export class TabBar implements ComponentInterface {
8889
}
8990

9091
async connectedCallback() {
91-
this.keyboardCtrl = await createKeyboardController(async (keyboardOpen, waitForResize) => {
92+
const promise = createKeyboardController(async (keyboardOpen, waitForResize) => {
9293
/**
9394
* If the keyboard is hiding, then we need to wait
9495
* for the webview to resize. Otherwise, the tab bar
@@ -100,11 +101,32 @@ export class TabBar implements ComponentInterface {
100101

101102
this.keyboardVisible = keyboardOpen; // trigger re-render by updating state
102103
});
104+
this.keyboardCtrlPromise = promise;
105+
106+
const keyboardCtrl = await promise;
107+
108+
/**
109+
* Only assign if this is still the current promise.
110+
* Otherwise, a new connectedCallback has started or
111+
* disconnectedCallback was called, so destroy this instance.
112+
*/
113+
if (this.keyboardCtrlPromise === promise) {
114+
this.keyboardCtrl = keyboardCtrl;
115+
this.keyboardCtrlPromise = null;
116+
} else {
117+
keyboardCtrl.destroy();
118+
}
103119
}
104120

105121
disconnectedCallback() {
122+
if (this.keyboardCtrlPromise) {
123+
this.keyboardCtrlPromise.then((ctrl) => ctrl.destroy());
124+
this.keyboardCtrlPromise = null;
125+
}
126+
106127
if (this.keyboardCtrl) {
107128
this.keyboardCtrl.destroy();
129+
this.keyboardCtrl = null;
108130
}
109131
}
110132

core/src/components/textarea/textarea.scss

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,9 +205,13 @@
205205
// otherwise the .input-cover will not be rendered at all
206206
// The input cover is not clickable when the input is disabled
207207
.cloned-input {
208-
@include position(0, null, 0, 0);
209-
210208
position: absolute;
209+
top: 0;
210+
bottom: 0;
211+
212+
// Reset height since absolute positioning with top/bottom handles sizing
213+
height: auto;
214+
max-height: none;
211215

212216
pointer-events: none;
213217
}

core/src/utils/input-shims/hacks/common.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,26 @@ const addClone = (
6868
if (disabledClonedInput) {
6969
clonedEl.disabled = true;
7070
}
71+
72+
/**
73+
* Position the clone at the same horizontal offset as the native input
74+
* to prevent the placeholder from overlapping start slot content (e.g., icons).
75+
*/
76+
const doc = componentEl.ownerDocument!;
77+
const isRTL = doc.dir === 'rtl';
78+
79+
if (isRTL) {
80+
const parentWidth = (parentEl as HTMLElement).offsetWidth;
81+
const startOffset = parentWidth - inputEl.offsetLeft - inputEl.offsetWidth;
82+
clonedEl.style.insetInlineStart = `${startOffset}px`;
83+
} else {
84+
clonedEl.style.insetInlineStart = `${inputEl.offsetLeft}px`;
85+
}
86+
7187
parentEl.appendChild(clonedEl);
7288
cloneMap.set(componentEl, clonedEl);
7389

74-
const doc = componentEl.ownerDocument!;
75-
const tx = doc.dir === 'rtl' ? 9999 : -9999;
90+
const tx = isRTL ? 9999 : -9999;
7691
componentEl.style.pointerEvents = 'none';
7792
inputEl.style.transform = `translate3d(${tx}px,${inputRelativeY}px,0) scale(0)`;
7893
};

core/src/utils/input-shims/hacks/scroll-assist.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,8 +291,14 @@ const jsSetFocus = async (
291291
// give the native text input focus
292292
relocateInput(componentEl, inputEl, false, scrollData.inputSafeY);
293293

294-
// ensure this is the focused input
295-
setManualFocus(inputEl);
294+
/**
295+
* If focus has moved to another element while scroll assist was running,
296+
* don't steal focus back. This prevents focus jumping when users
297+
* quickly switch between inputs or tap other elements.
298+
*/
299+
if (document.activeElement === inputEl) {
300+
setManualFocus(inputEl);
301+
}
296302

297303
/**
298304
* When the input is about to be blurred

0 commit comments

Comments
 (0)