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>
@ -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}
|
||||
/>
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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);
|
||||
|
||||