fix(checkbox, toggle): fire ionFocus and ionBlur (#30733)

Issue number: 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?
<!-- Please describe the current behavior that you are modifying. -->

`ionFocus` and `ionBlur` are not being emitted for checkbox and toggle. 

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

- Moved the `onFocus` and `onBlur` to `Host`
- Added tests for `onFocus`, `onBlur`, and `onChange`.
- Added a workaround on Playwright in order to trigger `ionBlur` since
Playwright browsers aren't acting like native browsers when it comes to
tabbing.

## 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. -->

Dev build: `8.7.7-dev.11761071592.1d1b804d`

---------

Co-authored-by: ionitron <hi@ionicframework.com>
Co-authored-by: Shane <shane@shanessite.net>
This commit is contained in:
Maria Hutt
2025-10-23 10:09:05 -07:00
committed by GitHub
parent ba73988750
commit 54a1c86d6a
21 changed files with 542 additions and 19 deletions

View File

@ -34,7 +34,6 @@ export class Checkbox implements ComponentInterface {
private inputLabelId = `${this.inputId}-lbl`;
private helperTextId = `${this.inputId}-helper-text`;
private errorTextId = `${this.inputId}-error-text`;
private focusEl?: HTMLElement;
private inheritedAttributes: Attributes = {};
@Element() el!: HTMLIonCheckboxElement;
@ -147,9 +146,7 @@ export class Checkbox implements ComponentInterface {
/** @internal */
@Method()
async setFocus() {
if (this.focusEl) {
this.focusEl.focus();
}
this.el.focus();
}
/**
@ -169,7 +166,6 @@ export class Checkbox implements ComponentInterface {
private toggleChecked = (ev: Event) => {
ev.preventDefault();
this.setFocus();
this.setChecked(!this.checked);
this.indeterminate = false;
};
@ -285,6 +281,9 @@ export class Checkbox implements ComponentInterface {
aria-disabled={disabled ? 'true' : null}
tabindex={disabled ? undefined : 0}
onKeyDown={this.onKeyDown}
onFocus={this.onFocus}
onBlur={this.onBlur}
onClick={this.onClick}
class={createColorClasses(color, {
[mode]: true,
'in-item': hostContext('ion-item', el),
@ -296,7 +295,6 @@ export class Checkbox implements ComponentInterface {
[`checkbox-alignment-${alignment}`]: alignment !== undefined,
[`checkbox-label-placement-${labelPlacement}`]: true,
})}
onClick={this.onClick}
>
<label class="checkbox-wrapper" htmlFor={inputId}>
{/*
@ -309,9 +307,6 @@ export class Checkbox implements ComponentInterface {
disabled={disabled}
id={inputId}
onChange={this.toggleChecked}
onFocus={() => this.onFocus()}
onBlur={() => this.onBlur()}
ref={(focusEl) => (this.focusEl = focusEl)}
required={required}
{...inheritedAttributes}
/>

View File

@ -44,7 +44,10 @@ configs().forEach(({ title, screenshot, config }) => {
});
});
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
/**
* This behavior does not vary across modes/directions
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('checkbox: ionChange'), () => {
test('should fire ionChange when interacting with checkbox', async ({ page }) => {
await page.setContent(
@ -133,4 +136,195 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
expect(clickCount).toBe(1);
});
});
test.describe(title('checkbox: ionFocus'), () => {
test('should not have visual regressions', async ({ page, pageUtils }) => {
await page.setContent(
`
<style>
#container {
display: inline-block;
padding: 10px;
}
</style>
<div id="container">
<ion-checkbox>Unchecked</ion-checkbox>
</div>
`,
config
);
await pageUtils.pressKeys('Tab');
const container = page.locator('#container');
await expect(container).toHaveScreenshot(screenshot(`checkbox-focus`));
});
test('should not have visual regressions when interacting with checkbox in item', async ({ page, pageUtils }) => {
await page.setContent(
`
<ion-item class="ion-focused">
<ion-checkbox>Unchecked</ion-checkbox>
</ion-item>
`,
config
);
// Test focus with keyboard navigation
await pageUtils.pressKeys('Tab');
const item = page.locator('ion-item');
await expect(item).toHaveScreenshot(screenshot(`checkbox-in-item-focus`));
});
test('should fire ionFocus when checkbox is focused', async ({ page, pageUtils }) => {
await page.setContent(
`
<ion-checkbox aria-label="checkbox" value="my-checkbox"></ion-checkbox>
`,
config
);
const ionFocus = await page.spyOnEvent('ionFocus');
// Test focus with keyboard navigation
await pageUtils.pressKeys('Tab');
expect(ionFocus).toHaveReceivedEventTimes(1);
// Reset focus
const checkbox = page.locator('ion-checkbox');
const checkboxBoundingBox = (await checkbox.boundingBox())!;
await page.mouse.click(0, checkboxBoundingBox.height + 1);
// Test focus with click
await checkbox.click();
expect(ionFocus).toHaveReceivedEventTimes(2);
});
test('should fire ionFocus when interacting with checkbox in item', async ({ page, pageUtils }) => {
await page.setContent(
`
<ion-item>
<ion-checkbox aria-label="checkbox" value="my-checkbox"></ion-checkbox>
</ion-item>
`,
config
);
const ionFocus = await page.spyOnEvent('ionFocus');
// Test focus with keyboard navigation
await pageUtils.pressKeys('Tab');
expect(ionFocus).toHaveReceivedEventTimes(1);
// Verify that the event target is the checkbox and not the item
const eventByKeyboard = ionFocus.events[0];
expect((eventByKeyboard.target as HTMLElement).tagName.toLowerCase()).toBe('ion-checkbox');
// Reset focus
const checkbox = page.locator('ion-checkbox');
const checkboxBoundingBox = (await checkbox.boundingBox())!;
await page.mouse.click(0, checkboxBoundingBox.height + 1);
// Test focus with click
const item = page.locator('ion-item');
await item.click();
expect(ionFocus).toHaveReceivedEventTimes(2);
// Verify that the event target is the checkbox and not the item
const eventByClick = ionFocus.events[0];
expect((eventByClick.target as HTMLElement).tagName.toLowerCase()).toBe('ion-checkbox');
});
test('should not fire when programmatically setting a value', async ({ page }) => {
await page.setContent(
`
<ion-checkbox aria-label="checkbox" value="my-checkbox"></ion-checkbox>
`,
config
);
const ionFocus = await page.spyOnEvent('ionFocus');
const checkbox = page.locator('ion-checkbox');
await checkbox.evaluate((el: HTMLIonCheckboxElement) => (el.checked = true));
expect(ionFocus).not.toHaveReceivedEvent();
});
});
test.describe(title('checkbox: ionBlur'), () => {
test('should fire ionBlur when checkbox is blurred', async ({ page, pageUtils }) => {
await page.setContent(
`
<ion-checkbox aria-label="checkbox" value="my-checkbox"></ion-checkbox>
`,
config
);
const ionBlur = await page.spyOnEvent('ionBlur');
// Test blur with keyboard navigation
// Focus the checkbox
await pageUtils.pressKeys('Tab');
// Blur the checkbox
await pageUtils.pressKeys('Tab');
expect(ionBlur).toHaveReceivedEventTimes(1);
// Test blur with click
const checkbox = page.locator('ion-checkbox');
// Focus the checkbox
await checkbox.click();
// Blur the checkbox by clicking outside of it
const checkboxBoundingBox = (await checkbox.boundingBox())!;
await page.mouse.click(0, checkboxBoundingBox.height + 1);
expect(ionBlur).toHaveReceivedEventTimes(2);
});
test('should fire ionBlur after interacting with checkbox in item', async ({ page, pageUtils }) => {
await page.setContent(
`
<ion-item>
<ion-checkbox aria-label="checkbox" value="my-checkbox"></ion-checkbox>
</ion-item>
`,
config
);
const ionBlur = await page.spyOnEvent('ionBlur');
// Test blur with keyboard navigation
// Focus the checkbox
await pageUtils.pressKeys('Tab');
// Blur the checkbox
await pageUtils.pressKeys('Tab');
expect(ionBlur).toHaveReceivedEventTimes(1);
// Verify that the event target is the checkbox and not the item
const event = ionBlur.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-checkbox');
// Test blur with click
const item = page.locator('ion-item');
await item.click();
// Blur the checkbox by clicking outside of it
const itemBoundingBox = (await item.boundingBox())!;
await page.mouse.click(0, itemBoundingBox.height + 1);
expect(ionBlur).toHaveReceivedEventTimes(2);
// Verify that the event target is the checkbox and not the item
const eventByClick = ionBlur.events[0];
expect((eventByClick.target as HTMLElement).tagName.toLowerCase()).toBe('ion-checkbox');
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -50,6 +50,20 @@
<ion-checkbox checked style="width: 200px">Specified width</ion-checkbox><br />
<ion-checkbox checked style="width: 100%">Full-width</ion-checkbox><br />
</ion-content>
<script>
document.addEventListener('ionBlur', (ev) => {
console.log('ionBlur', ev);
});
document.addEventListener('ionChange', (ev) => {
console.log('ionChange', ev);
});
document.addEventListener('ionFocus', (ev) => {
console.log('ionFocus', ev);
});
</script>
</ion-app>
</body>
</html>

View File

@ -246,6 +246,20 @@
</div>
</div>
</ion-content>
<script>
document.addEventListener('ionBlur', (ev) => {
console.log('ionBlur', ev);
});
document.addEventListener('ionChange', (ev) => {
console.log('ionChange', ev);
});
document.addEventListener('ionFocus', (ev) => {
console.log('ionFocus', ev);
});
</script>
</ion-app>
</body>
</html>

View File

@ -45,6 +45,20 @@
<ion-toggle style="width: 100%"> Full-width </ion-toggle><br />
<ion-toggle> Long Label Long Label Long Label Long Label Long Label Long Label </ion-toggle><br />
</ion-content>
<script>
document.addEventListener('ionBlur', (ev) => {
console.log('ionBlur', ev);
});
document.addEventListener('ionChange', (ev) => {
console.log('ionChange', ev);
});
document.addEventListener('ionFocus', (ev) => {
console.log('ionFocus', ev);
});
</script>
</ion-app>
</body>
</html>

View File

@ -1,7 +1,65 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
/**
* This behavior does not vary across modes/directions
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('toggle: ionChange'), () => {
test('should fire ionChange when interacting with toggle', async ({ page }) => {
await page.setContent(
`
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
`,
config
);
const ionChange = await page.spyOnEvent('ionChange');
const toggle = page.locator('ion-toggle');
await toggle.click();
expect(ionChange).toHaveReceivedEventDetail({ value: 'my-toggle', checked: true });
await toggle.click();
expect(ionChange).toHaveReceivedEventDetail({ value: 'my-toggle', checked: false });
});
test('should fire ionChange when interacting with toggle in item', async ({ page }) => {
await page.setContent(
`
<ion-item>
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
</ion-item>
`,
config
);
const ionChange = await page.spyOnEvent('ionChange');
const item = page.locator('ion-item');
await item.click();
expect(ionChange).toHaveReceivedEventDetail({ value: 'my-toggle', checked: true });
await item.click();
expect(ionChange).toHaveReceivedEventDetail({ value: 'my-toggle', checked: false });
});
test('should not fire when programmatically setting a value', async ({ page }) => {
await page.setContent(
`
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
`,
config
);
const ionChange = await page.spyOnEvent('ionChange');
const toggle = page.locator('ion-toggle');
await toggle.evaluate((el: HTMLIonToggleElement) => (el.checked = true));
expect(ionChange).not.toHaveReceivedEvent();
});
});
test.describe(title('toggle: click'), () => {
test('should trigger onclick only once when clicking the label', async ({ page }, testInfo) => {
testInfo.annotations.push({
@ -35,4 +93,195 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
expect(clickCount).toBe(1);
});
});
test.describe(title('toggle: ionFocus'), () => {
test('should not have visual regressions', async ({ page, pageUtils }) => {
await page.setContent(
`
<style>
#container {
display: inline-block;
padding: 10px;
}
</style>
<div id="container">
<ion-toggle>Unchecked</ion-toggle>
</div>
`,
config
);
await pageUtils.pressKeys('Tab');
const container = page.locator('#container');
await expect(container).toHaveScreenshot(screenshot(`toggle-focus`));
});
test('should not have visual regressions when interacting with toggle in item', async ({ page, pageUtils }) => {
await page.setContent(
`
<ion-item class="ion-focused">
<ion-toggle>Unchecked</ion-toggle>
</ion-item>
`,
config
);
// Test focus with keyboard navigation
await pageUtils.pressKeys('Tab');
const item = page.locator('ion-item');
await expect(item).toHaveScreenshot(screenshot(`toggle-in-item-focus`));
});
test('should fire ionFocus when toggle is focused', async ({ page, pageUtils }) => {
await page.setContent(
`
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
`,
config
);
const ionFocus = await page.spyOnEvent('ionFocus');
// Test focus with keyboard navigation
await pageUtils.pressKeys('Tab');
expect(ionFocus).toHaveReceivedEventTimes(1);
// Reset focus
const toggle = page.locator('ion-toggle');
const toggleBoundingBox = (await toggle.boundingBox())!;
await page.mouse.click(0, toggleBoundingBox.height + 1);
// Test focus with click
await toggle.click();
expect(ionFocus).toHaveReceivedEventTimes(2);
});
test('should fire ionFocus when interacting with toggle in item', async ({ page, pageUtils }) => {
await page.setContent(
`
<ion-item>
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
</ion-item>
`,
config
);
const ionFocus = await page.spyOnEvent('ionFocus');
// Test focus with keyboard navigation
await pageUtils.pressKeys('Tab');
expect(ionFocus).toHaveReceivedEventTimes(1);
// Verify that the event target is the toggle and not the item
const eventByKeyboard = ionFocus.events[0];
expect((eventByKeyboard.target as HTMLElement).tagName.toLowerCase()).toBe('ion-toggle');
// Reset focus
const toggle = page.locator('ion-toggle');
const toggleBoundingBox = (await toggle.boundingBox())!;
await page.mouse.click(0, toggleBoundingBox.height + 1);
// Test focus with click
const item = page.locator('ion-item');
await item.click();
expect(ionFocus).toHaveReceivedEventTimes(2);
// Verify that the event target is the toggle and not the item
const eventByClick = ionFocus.events[0];
expect((eventByClick.target as HTMLElement).tagName.toLowerCase()).toBe('ion-toggle');
});
test('should not fire when programmatically setting a value', async ({ page }) => {
await page.setContent(
`
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
`,
config
);
const ionFocus = await page.spyOnEvent('ionFocus');
const toggle = page.locator('ion-toggle');
await toggle.evaluate((el: HTMLIonToggleElement) => (el.checked = true));
expect(ionFocus).not.toHaveReceivedEvent();
});
});
test.describe(title('toggle: ionBlur'), () => {
test('should fire ionBlur when toggle is blurred', async ({ page, pageUtils }) => {
await page.setContent(
`
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
`,
config
);
const ionBlur = await page.spyOnEvent('ionBlur');
// Test blur with keyboard navigation
// Focus the toggle
await pageUtils.pressKeys('Tab');
// Blur the toggle
await pageUtils.pressKeys('Tab');
expect(ionBlur).toHaveReceivedEventTimes(1);
// Test blur with click
const toggle = page.locator('ion-toggle');
// Focus the toggle
await toggle.click();
// Blur the toggle by clicking outside of it
const toggleBoundingBox = (await toggle.boundingBox())!;
await page.mouse.click(0, toggleBoundingBox.height + 1);
expect(ionBlur).toHaveReceivedEventTimes(2);
});
test('should fire ionBlur after interacting with toggle in item', async ({ page, pageUtils }) => {
await page.setContent(
`
<ion-item>
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
</ion-item>
`,
config
);
const ionBlur = await page.spyOnEvent('ionBlur');
// Test blur with keyboard navigation
// Focus the toggle
await pageUtils.pressKeys('Tab');
// Blur the toggle
await pageUtils.pressKeys('Tab');
expect(ionBlur).toHaveReceivedEventTimes(1);
// Verify that the event target is the toggle and not the item
const event = ionBlur.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-toggle');
// Test blur with click
const item = page.locator('ion-item');
await item.click();
// Blur the toggle by clicking outside of it
const itemBoundingBox = (await item.boundingBox())!;
await page.mouse.click(0, itemBoundingBox.height + 1);
expect(ionBlur).toHaveReceivedEventTimes(2);
// Verify that the event target is the toggle and not the item
const eventByClick = ionBlur.events[0];
expect((eventByClick.target as HTMLElement).tagName.toLowerCase()).toBe('ion-toggle');
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -223,6 +223,20 @@
</div>
</div>
</ion-content>
<script>
document.addEventListener('ionBlur', (ev) => {
console.log('ionBlur', ev);
});
document.addEventListener('ionChange', (ev) => {
console.log('ionChange', ev);
});
document.addEventListener('ionFocus', (ev) => {
console.log('ionFocus', ev);
});
</script>
</ion-app>
</body>
</html>

View File

@ -40,7 +40,6 @@ export class Toggle implements ComponentInterface {
private helperTextId = `${this.inputId}-helper-text`;
private errorTextId = `${this.inputId}-error-text`;
private gesture?: Gesture;
private focusEl?: HTMLElement;
private lastDrag = 0;
private inheritedAttributes: Attributes = {};
private toggleTrack?: HTMLElement;
@ -162,7 +161,6 @@ export class Toggle implements ComponentInterface {
const isNowChecked = !checked;
this.checked = isNowChecked;
this.setFocus();
this.ionChange.emit({
checked: isNowChecked,
value,
@ -243,9 +241,7 @@ export class Toggle implements ComponentInterface {
}
private setFocus() {
if (this.focusEl) {
this.focusEl.focus();
}
this.el.focus();
}
private onKeyDown = (ev: KeyboardEvent) => {
@ -417,6 +413,8 @@ export class Toggle implements ComponentInterface {
aria-disabled={disabled ? 'true' : null}
tabindex={disabled ? undefined : 0}
onKeyDown={this.onKeyDown}
onFocus={this.onFocus}
onBlur={this.onBlur}
class={createColorClasses(color, {
[mode]: true,
'in-item': hostContext('ion-item', el),
@ -441,9 +439,6 @@ export class Toggle implements ComponentInterface {
checked={checked}
disabled={disabled}
id={inputId}
onFocus={() => this.onFocus()}
onBlur={() => this.onBlur()}
ref={(focusEl) => (this.focusEl = focusEl)}
required={required}
{...inheritedAttributes}
/>

View File

@ -2,6 +2,40 @@ import type { E2EPage } from '../../playwright-declarations';
import { addE2EListener, EventSpy } from '../event-spy';
export const spyOnEvent = async (page: E2EPage, eventName: string): Promise<EventSpy> => {
/**
* Tabbing out of the page boundary can lead to unreliable `ionBlur events,
* particularly in Firefox.
*
* This occurs because Playwright may incorrectly maintain focus state on the
* last element, even after a Tab press attempts to shift focus outside the
* viewport. To reliably trigger the necessary blur event, add a visually
* hidden, focusable element at the end of the page to receive focus instead of
* the browser.
*
* Playwright issue reference:
* https://github.com/microsoft/playwright/issues/32269
*/
if (eventName === 'ionBlur') {
const hiddenInput = await page.$('#hidden-input-for-ion-blur');
if (!hiddenInput) {
await page.evaluate(() => {
const input = document.createElement('input');
input.id = 'hidden-input-for-ion-blur';
input.style.position = 'absolute';
input.style.opacity = '0';
input.style.height = '0';
input.style.width = '0';
input.style.pointerEvents = 'none';
document.body.appendChild(input);
// Clean up the element when the page is unloaded.
window.addEventListener('unload', () => {
input.remove();
});
});
}
}
const spy = new EventSpy(eventName);
const handle = await page.evaluateHandle(() => window);