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 inputLabelId = `${this.inputId}-lbl`;
|
||||||
private helperTextId = `${this.inputId}-helper-text`;
|
private helperTextId = `${this.inputId}-helper-text`;
|
||||||
private errorTextId = `${this.inputId}-error-text`;
|
private errorTextId = `${this.inputId}-error-text`;
|
||||||
private focusEl?: HTMLElement;
|
|
||||||
private inheritedAttributes: Attributes = {};
|
private inheritedAttributes: Attributes = {};
|
||||||
|
|
||||||
@Element() el!: HTMLIonCheckboxElement;
|
@Element() el!: HTMLIonCheckboxElement;
|
||||||
@ -147,9 +146,7 @@ export class Checkbox implements ComponentInterface {
|
|||||||
/** @internal */
|
/** @internal */
|
||||||
@Method()
|
@Method()
|
||||||
async setFocus() {
|
async setFocus() {
|
||||||
if (this.focusEl) {
|
this.el.focus();
|
||||||
this.focusEl.focus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -169,7 +166,6 @@ export class Checkbox implements ComponentInterface {
|
|||||||
private toggleChecked = (ev: Event) => {
|
private toggleChecked = (ev: Event) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
|
||||||
this.setFocus();
|
|
||||||
this.setChecked(!this.checked);
|
this.setChecked(!this.checked);
|
||||||
this.indeterminate = false;
|
this.indeterminate = false;
|
||||||
};
|
};
|
||||||
@ -285,6 +281,9 @@ export class Checkbox implements ComponentInterface {
|
|||||||
aria-disabled={disabled ? 'true' : null}
|
aria-disabled={disabled ? 'true' : null}
|
||||||
tabindex={disabled ? undefined : 0}
|
tabindex={disabled ? undefined : 0}
|
||||||
onKeyDown={this.onKeyDown}
|
onKeyDown={this.onKeyDown}
|
||||||
|
onFocus={this.onFocus}
|
||||||
|
onBlur={this.onBlur}
|
||||||
|
onClick={this.onClick}
|
||||||
class={createColorClasses(color, {
|
class={createColorClasses(color, {
|
||||||
[mode]: true,
|
[mode]: true,
|
||||||
'in-item': hostContext('ion-item', el),
|
'in-item': hostContext('ion-item', el),
|
||||||
@ -296,7 +295,6 @@ export class Checkbox implements ComponentInterface {
|
|||||||
[`checkbox-alignment-${alignment}`]: alignment !== undefined,
|
[`checkbox-alignment-${alignment}`]: alignment !== undefined,
|
||||||
[`checkbox-label-placement-${labelPlacement}`]: true,
|
[`checkbox-label-placement-${labelPlacement}`]: true,
|
||||||
})}
|
})}
|
||||||
onClick={this.onClick}
|
|
||||||
>
|
>
|
||||||
<label class="checkbox-wrapper" htmlFor={inputId}>
|
<label class="checkbox-wrapper" htmlFor={inputId}>
|
||||||
{/*
|
{/*
|
||||||
@ -309,9 +307,6 @@ export class Checkbox implements ComponentInterface {
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
id={inputId}
|
id={inputId}
|
||||||
onChange={this.toggleChecked}
|
onChange={this.toggleChecked}
|
||||||
onFocus={() => this.onFocus()}
|
|
||||||
onBlur={() => this.onBlur()}
|
|
||||||
ref={(focusEl) => (this.focusEl = focusEl)}
|
|
||||||
required={required}
|
required={required}
|
||||||
{...inheritedAttributes}
|
{...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.describe(title('checkbox: ionChange'), () => {
|
||||||
test('should fire ionChange when interacting with checkbox', async ({ page }) => {
|
test('should fire ionChange when interacting with checkbox', async ({ page }) => {
|
||||||
await page.setContent(
|
await page.setContent(
|
||||||
@ -133,4 +136,195 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
|||||||
expect(clickCount).toBe(1);
|
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: 200px">Specified width</ion-checkbox><br />
|
||||||
<ion-checkbox checked style="width: 100%">Full-width</ion-checkbox><br />
|
<ion-checkbox checked style="width: 100%">Full-width</ion-checkbox><br />
|
||||||
</ion-content>
|
</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>
|
</ion-app>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -246,6 +246,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ion-content>
|
</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>
|
</ion-app>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -45,6 +45,20 @@
|
|||||||
<ion-toggle style="width: 100%"> Full-width </ion-toggle><br />
|
<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-toggle> Long Label Long Label Long Label Long Label Long Label Long Label </ion-toggle><br />
|
||||||
</ion-content>
|
</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>
|
</ion-app>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,7 +1,65 @@
|
|||||||
import { expect } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
import { configs, test } from '@utils/test/playwright';
|
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.describe(title('toggle: click'), () => {
|
||||||
test('should trigger onclick only once when clicking the label', async ({ page }, testInfo) => {
|
test('should trigger onclick only once when clicking the label', async ({ page }, testInfo) => {
|
||||||
testInfo.annotations.push({
|
testInfo.annotations.push({
|
||||||
@ -35,4 +93,195 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
|||||||
expect(clickCount).toBe(1);
|
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>
|
||||||
</div>
|
</div>
|
||||||
</ion-content>
|
</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>
|
</ion-app>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -40,7 +40,6 @@ export class Toggle implements ComponentInterface {
|
|||||||
private helperTextId = `${this.inputId}-helper-text`;
|
private helperTextId = `${this.inputId}-helper-text`;
|
||||||
private errorTextId = `${this.inputId}-error-text`;
|
private errorTextId = `${this.inputId}-error-text`;
|
||||||
private gesture?: Gesture;
|
private gesture?: Gesture;
|
||||||
private focusEl?: HTMLElement;
|
|
||||||
private lastDrag = 0;
|
private lastDrag = 0;
|
||||||
private inheritedAttributes: Attributes = {};
|
private inheritedAttributes: Attributes = {};
|
||||||
private toggleTrack?: HTMLElement;
|
private toggleTrack?: HTMLElement;
|
||||||
@ -162,7 +161,6 @@ export class Toggle implements ComponentInterface {
|
|||||||
const isNowChecked = !checked;
|
const isNowChecked = !checked;
|
||||||
this.checked = isNowChecked;
|
this.checked = isNowChecked;
|
||||||
|
|
||||||
this.setFocus();
|
|
||||||
this.ionChange.emit({
|
this.ionChange.emit({
|
||||||
checked: isNowChecked,
|
checked: isNowChecked,
|
||||||
value,
|
value,
|
||||||
@ -243,9 +241,7 @@ export class Toggle implements ComponentInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setFocus() {
|
private setFocus() {
|
||||||
if (this.focusEl) {
|
this.el.focus();
|
||||||
this.focusEl.focus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private onKeyDown = (ev: KeyboardEvent) => {
|
private onKeyDown = (ev: KeyboardEvent) => {
|
||||||
@ -417,6 +413,8 @@ export class Toggle implements ComponentInterface {
|
|||||||
aria-disabled={disabled ? 'true' : null}
|
aria-disabled={disabled ? 'true' : null}
|
||||||
tabindex={disabled ? undefined : 0}
|
tabindex={disabled ? undefined : 0}
|
||||||
onKeyDown={this.onKeyDown}
|
onKeyDown={this.onKeyDown}
|
||||||
|
onFocus={this.onFocus}
|
||||||
|
onBlur={this.onBlur}
|
||||||
class={createColorClasses(color, {
|
class={createColorClasses(color, {
|
||||||
[mode]: true,
|
[mode]: true,
|
||||||
'in-item': hostContext('ion-item', el),
|
'in-item': hostContext('ion-item', el),
|
||||||
@ -441,9 +439,6 @@ export class Toggle implements ComponentInterface {
|
|||||||
checked={checked}
|
checked={checked}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
id={inputId}
|
id={inputId}
|
||||||
onFocus={() => this.onFocus()}
|
|
||||||
onBlur={() => this.onBlur()}
|
|
||||||
ref={(focusEl) => (this.focusEl = focusEl)}
|
|
||||||
required={required}
|
required={required}
|
||||||
{...inheritedAttributes}
|
{...inheritedAttributes}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -2,6 +2,40 @@ import type { E2EPage } from '../../playwright-declarations';
|
|||||||
import { addE2EListener, EventSpy } from '../event-spy';
|
import { addE2EListener, EventSpy } from '../event-spy';
|
||||||
|
|
||||||
export const spyOnEvent = async (page: E2EPage, eventName: string): Promise<EventSpy> => {
|
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 spy = new EventSpy(eventName);
|
||||||
|
|
||||||
const handle = await page.evaluateHandle(() => window);
|
const handle = await page.evaluateHandle(() => window);
|
||||||
|
|||||||