Skip to content

Commit a5a628e

Browse files
committed
fix(checkbox): fire ionFocus and ionBlur
1 parent 820fa28 commit a5a628e

File tree

5 files changed

+243
-9
lines changed

5 files changed

+243
-9
lines changed

core/src/components/checkbox/checkbox.tsx

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,7 @@ export class Checkbox implements ComponentInterface {
147147
/** @internal */
148148
@Method()
149149
async setFocus() {
150-
if (this.focusEl) {
151-
this.focusEl.focus();
152-
}
150+
this.el.focus();
153151
}
154152

155153
/**
@@ -169,7 +167,6 @@ export class Checkbox implements ComponentInterface {
169167
private toggleChecked = (ev: Event) => {
170168
ev.preventDefault();
171169

172-
this.setFocus();
173170
this.setChecked(!this.checked);
174171
this.indeterminate = false;
175172
};
@@ -285,6 +282,9 @@ export class Checkbox implements ComponentInterface {
285282
aria-disabled={disabled ? 'true' : null}
286283
tabindex={disabled ? undefined : 0}
287284
onKeyDown={this.onKeyDown}
285+
onFocus={() => this.onFocus()}
286+
onBlur={() => this.onBlur()}
287+
onClick={this.onClick}
288288
class={createColorClasses(color, {
289289
[mode]: true,
290290
'in-item': hostContext('ion-item', el),
@@ -296,7 +296,6 @@ export class Checkbox implements ComponentInterface {
296296
[`checkbox-alignment-${alignment}`]: alignment !== undefined,
297297
[`checkbox-label-placement-${labelPlacement}`]: true,
298298
})}
299-
onClick={this.onClick}
300299
>
301300
<label class="checkbox-wrapper" htmlFor={inputId}>
302301
{/*
@@ -309,9 +308,6 @@ export class Checkbox implements ComponentInterface {
309308
disabled={disabled}
310309
id={inputId}
311310
onChange={this.toggleChecked}
312-
onFocus={() => this.onFocus()}
313-
onBlur={() => this.onBlur()}
314-
ref={(focusEl) => (this.focusEl = focusEl)}
315311
required={required}
316312
{...inheritedAttributes}
317313
/>

core/src/components/checkbox/test/basic/checkbox.e2e.ts

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ configs().forEach(({ title, screenshot, config }) => {
4444
});
4545
});
4646

47-
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
47+
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
4848
test.describe(title('checkbox: ionChange'), () => {
4949
test('should fire ionChange when interacting with checkbox', async ({ page }) => {
5050
await page.setContent(
@@ -133,4 +133,177 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
133133
expect(clickCount).toBe(1);
134134
});
135135
});
136+
137+
test.describe(title('checkbox: ionFocus'), () => {
138+
test('should fire ionFocus when checkbox is focused', async ({ page, pageUtils }) => {
139+
await page.setContent(
140+
`
141+
<ion-checkbox aria-label="checkbox" value="my-checkbox"></ion-checkbox>
142+
`,
143+
config
144+
);
145+
146+
const ionFocus = await page.spyOnEvent('ionFocus');
147+
148+
// Test focus with keyboard navigation.
149+
await pageUtils.pressKeys('Tab');
150+
151+
expect(ionFocus).toHaveReceivedEventTimes(1);
152+
153+
// Reset focus.
154+
const checkbox = page.locator('ion-checkbox');
155+
const checkboxBoundingBox = (await checkbox.boundingBox())!;
156+
await page.mouse.click(0, checkboxBoundingBox.height + 1);
157+
158+
// Test focus with click.
159+
await checkbox.click();
160+
161+
expect(ionFocus).toHaveReceivedEventTimes(2);
162+
});
163+
164+
test('should fire ionFocus when interacting with checkbox in item', async ({ page, pageUtils }) => {
165+
await page.setContent(
166+
`
167+
<ion-item>
168+
<ion-checkbox aria-label="checkbox" value="my-checkbox"></ion-checkbox>
169+
</ion-item>
170+
`,
171+
config
172+
);
173+
174+
const ionFocus = await page.spyOnEvent('ionFocus');
175+
176+
// Test focus with keyboard navigation.
177+
await pageUtils.pressKeys('Tab');
178+
179+
expect(ionFocus).toHaveReceivedEventTimes(1);
180+
181+
// Verify that the event target is the checkbox and not the item.
182+
const eventByKeyboard = ionFocus.events[0];
183+
expect((eventByKeyboard.target as HTMLElement).tagName.toLowerCase()).toBe('ion-checkbox');
184+
185+
// Reset focus.
186+
const checkbox = page.locator('ion-checkbox');
187+
const checkboxBoundingBox = (await checkbox.boundingBox())!;
188+
await page.mouse.click(0, checkboxBoundingBox.height + 1);
189+
190+
// Test focus with click.
191+
const item = page.locator('ion-item');
192+
await item.click();
193+
194+
expect(ionFocus).toHaveReceivedEventTimes(2);
195+
196+
// Verify that the event target is the checkbox and not the item.
197+
const eventByClick = ionFocus.events[0];
198+
expect((eventByClick.target as HTMLElement).tagName.toLowerCase()).toBe('ion-checkbox');
199+
});
200+
201+
test('should not fire when programmatically setting a value', async ({ page }) => {
202+
await page.setContent(
203+
`
204+
<ion-checkbox aria-label="checkbox" value="my-checkbox"></ion-checkbox>
205+
`,
206+
config
207+
);
208+
209+
const ionFocus = await page.spyOnEvent('ionFocus');
210+
const checkbox = page.locator('ion-checkbox');
211+
212+
await checkbox.evaluate((el: HTMLIonCheckboxElement) => (el.checked = true));
213+
expect(ionFocus).not.toHaveReceivedEvent();
214+
});
215+
216+
test('should not have visual regressions', async ({ page, pageUtils }) => {
217+
await page.setContent(
218+
`
219+
<style>
220+
#container {
221+
display: inline-block;
222+
padding: 10px;
223+
}
224+
</style>
225+
226+
<div id="container">
227+
<ion-checkbox>Unchecked</ion-checkbox>
228+
</div>
229+
`,
230+
config
231+
);
232+
233+
await pageUtils.pressKeys('Tab');
234+
235+
const container = page.locator('#container');
236+
237+
await expect(container).toHaveScreenshot(screenshot(`checkbox-focus`));
238+
});
239+
});
240+
241+
test.describe(title('checkbox: ionBlur'), () => {
242+
test('should fire ionBlur when checkbox is blurred', async ({ page, pageUtils }) => {
243+
await page.setContent(
244+
`
245+
<ion-checkbox aria-label="checkbox" value="my-checkbox"></ion-checkbox>
246+
`,
247+
config
248+
);
249+
250+
const ionBlur = await page.spyOnEvent('ionBlur');
251+
252+
// Test blur with keyboard navigation.
253+
// Focus the checkbox.
254+
await pageUtils.pressKeys('Tab');
255+
// Blur the checkbox.
256+
await pageUtils.pressKeys('Tab');
257+
258+
expect(ionBlur).toHaveReceivedEventTimes(1);
259+
260+
// Test blur with click.
261+
const checkbox = page.locator('ion-checkbox');
262+
// Focus the checkbox.
263+
await checkbox.click();
264+
// Blur the checkbox by clicking outside of it.
265+
const checkboxBoundingBox = (await checkbox.boundingBox())!;
266+
await page.mouse.click(0, checkboxBoundingBox.height + 1);
267+
268+
expect(ionBlur).toHaveReceivedEventTimes(2);
269+
});
270+
271+
test('should fire ionBlur after interacting with checkbox in item', async ({ page, pageUtils }) => {
272+
await page.setContent(
273+
`
274+
<ion-item>
275+
<ion-checkbox aria-label="checkbox" value="my-checkbox"></ion-checkbox>
276+
</ion-item>
277+
`,
278+
config
279+
);
280+
281+
const ionBlur = await page.spyOnEvent('ionBlur');
282+
283+
// Test blur with keyboard navigation.
284+
// Focus the checkbox.
285+
await pageUtils.pressKeys('Tab');
286+
// Blur the checkbox.
287+
await pageUtils.pressKeys('Tab');
288+
289+
expect(ionBlur).toHaveReceivedEventTimes(1);
290+
291+
// Verify that the event target is the checkbox and not the item.
292+
const event = ionBlur.events[0];
293+
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-checkbox');
294+
295+
// Test blur with click.
296+
const item = page.locator('ion-item');
297+
await item.click();
298+
// Blur the checkbox by clicking outside of it.
299+
const itemBoundingBox = (await item.boundingBox())!;
300+
await page.mouse.click(0, itemBoundingBox.height + 1);
301+
302+
expect(ionBlur).toHaveReceivedEventTimes(2);
303+
304+
// Verify that the event target is the checkbox and not the item.
305+
const eventByClick = ionBlur.events[0];
306+
expect((eventByClick.target as HTMLElement).tagName.toLowerCase()).toBe('ion-checkbox');
307+
});
308+
});
136309
});

core/src/components/checkbox/test/basic/index.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,20 @@
5050
<ion-checkbox checked style="width: 200px">Specified width</ion-checkbox><br />
5151
<ion-checkbox checked style="width: 100%">Full-width</ion-checkbox><br />
5252
</ion-content>
53+
54+
<script>
55+
document.addEventListener('ionBlur', (ev) => {
56+
console.log('ionBlur', ev);
57+
});
58+
59+
document.addEventListener('ionChange', (ev) => {
60+
console.log('ionChange', ev);
61+
});
62+
63+
document.addEventListener('ionFocus', (ev) => {
64+
console.log('ionFocus', ev);
65+
});
66+
</script>
5367
</ion-app>
5468
</body>
5569
</html>

core/src/components/checkbox/test/item/index.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,20 @@ <h1>Multiline Label</h1>
246246
</div>
247247
</div>
248248
</ion-content>
249+
250+
<script>
251+
document.addEventListener('ionBlur', (ev) => {
252+
console.log('ionBlur', ev);
253+
});
254+
255+
document.addEventListener('ionChange', (ev) => {
256+
console.log('ionChange', ev);
257+
});
258+
259+
document.addEventListener('ionFocus', (ev) => {
260+
console.log('ionFocus', ev);
261+
});
262+
</script>
249263
</ion-app>
250264
</body>
251265
</html>

core/src/utils/test/playwright/page/utils/spy-on-event.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,43 @@ import type { E2EPage } from '../../playwright-declarations';
22
import { addE2EListener, EventSpy } from '../event-spy';
33

44
export const spyOnEvent = async (page: E2EPage, eventName: string): Promise<EventSpy> => {
5+
/**
6+
* Tabbing out of the page boundary can lead to unreliable `ionBlur events,
7+
* particularly in Firefox.
8+
*
9+
* This occurs because Playwright may incorrectly maintain focus state on the
10+
* last element, even after a Tab press attempts to shift focus outside the
11+
* viewport. To reliably trigger the necessary blur event, add a visually
12+
* hidden, focusable element at the end of the page to receive focus instead of
13+
* the browser.
14+
*
15+
* Playwright issue reference:
16+
* https://github.com/microsoft/playwright/issues/32269
17+
*/
18+
if (eventName === 'ionBlur') {
19+
const hiddenInput = await page.$('#hidden-input-for-ion-blur');
20+
if (!hiddenInput) {
21+
await page.evaluate(() => {
22+
const input = document.createElement('input');
23+
input.id = 'hidden-input-for-ion-blur';
24+
input.style.position = 'absolute';
25+
input.style.opacity = '0';
26+
input.style.height = '0';
27+
input.style.width = '0';
28+
input.style.pointerEvents = 'none';
29+
document.body.appendChild(input);
30+
31+
// Add console warning to indicate presence of hidden input.
32+
console.warn('[Ionic Warning]: Hidden input for ionBlur added to the page for Playwright testing.');
33+
34+
// Clean up the element when the page is unloaded.
35+
window.addEventListener('unload', () => {
36+
input.remove();
37+
});
38+
});
39+
}
40+
}
41+
542
const spy = new EventSpy(eventName);
643

744
const handle = await page.evaluateHandle(() => window);

0 commit comments

Comments
 (0)