Skip to content

fix(item): emit click event once when clicking padded space on item and emit correct element #30373

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Apr 30, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 0 additions & 23 deletions core/src/components/checkbox/test/basic/checkbox.e2e.ts
Original file line number Diff line number Diff line change
@@ -98,28 +98,5 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
await checkbox.evaluate((el: HTMLIonCheckboxElement) => (el.checked = true));
expect(ionChange).not.toHaveReceivedEvent();
});

test('clicking padded space within item should click the checkbox', async ({ page }) => {
await page.setContent(
`
<ion-item>
<ion-checkbox>Size</ion-checkbox>
</ion-item>
`,
config
);
const itemNative = page.locator('.item-native');
const ionChange = await page.spyOnEvent('ionChange');

// Clicks the padded space within the item
await itemNative.click({
position: {
x: 5,
y: 5,
},
});

expect(ionChange).toHaveReceivedEvent();
});
});
});
67 changes: 67 additions & 0 deletions core/src/components/checkbox/test/item/checkbox.e2e.ts
Original file line number Diff line number Diff line change
@@ -127,3 +127,70 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
});
});
});

configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('checkbox: item functionality'), () => {
test('clicking padded space within item should click the checkbox', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/27169',
});

await page.setContent(
`
<ion-item>
<ion-checkbox>Size</ion-checkbox>
</ion-item>
`,
config
);
const item = page.locator('ion-item');
const ionChange = await page.spyOnEvent('ionChange');

// Clicks the padded space within the item
await item.click({
position: {
x: 5,
y: 5,
},
});

expect(ionChange).toHaveReceivedEvent();
});

test('clicking padded space within item should fire one click event', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/29758',
});

await page.setContent(
`
<ion-item>
<ion-checkbox>
Checkbox
</ion-checkbox>
</ion-item>
`,
config
);

const item = page.locator('ion-item');
const onClick = await page.spyOnEvent('click');

// Click the padding area (5px from left edge)
await item.click({
position: {
x: 5,
y: 5,
},
});

expect(onClick).toHaveReceivedEventTimes(1);

// Verify that the event target is the checkbox and not the item
const event = onClick.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-checkbox');
});
});
});
4 changes: 4 additions & 0 deletions core/src/components/input/input.scss
Original file line number Diff line number Diff line change
@@ -107,6 +107,10 @@

width: 100%;
max-width: 100%;

// Ensure the input fills the full height of the native wrapper.
// This prevents the wrapper from being the click event target.
height: 100%;
max-height: 100%;

border: 0;
28 changes: 27 additions & 1 deletion core/src/components/input/input.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Build, Component, Element, Event, Host, Method, Prop, State, Watch, forceUpdate, h } from '@stencil/core';
import {
Build,
Component,
Element,
Event,
Host,
Listen,
Method,
Prop,
State,
Watch,
forceUpdate,
h,
} from '@stencil/core';
import type { NotchController } from '@utils/forms';
import { createNotchController } from '@utils/forms';
import type { Attributes } from '@utils/helpers';
@@ -363,6 +376,19 @@ export class Input implements ComponentInterface {
forceUpdate(this);
}

/**
* This prevents the native input from emitting the click event.
* Instead, the click event from the ion-input is emitted.
*/
@Listen('click', { capture: true })
onClickCapture(ev: Event) {
const nativeInput = this.nativeInput;
if (nativeInput && ev.target === nativeInput) {
ev.stopPropagation();
this.el.click();
}
}

componentWillLoad() {
this.inheritedAttributes = {
...inheritAriaAttributes(this.el),
91 changes: 89 additions & 2 deletions core/src/components/input/test/item/input.e2e.ts
Original file line number Diff line number Diff line change
@@ -49,6 +49,11 @@ configs().forEach(({ title, screenshot, config }) => {
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('input: item functionality'), () => {
test('clicking padded space within item should focus the input', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/21982',
});

await page.setContent(
`
<ion-item>
@@ -57,11 +62,12 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
`,
config
);
const itemNative = page.locator('.item-native');

const item = page.locator('ion-item');
const input = page.locator('ion-input input');

// Clicks the padded space within the item
await itemNative.click({
await item.click({
position: {
x: 5,
y: 5,
@@ -70,5 +76,86 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>

await expect(input).toBeFocused();
});

test('clicking padded space within item should fire one click event', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/29761',
});

await page.setContent(
`
<ion-item>
<ion-input label="Input"></ion-input>
</ion-item>
`,
config
);

const item = page.locator('ion-item');
const onClick = await page.spyOnEvent('click');

// Click the padding area (5px from left edge)
await item.click({
position: {
x: 5,
y: 5,
},
});

expect(onClick).toHaveReceivedEventTimes(1);

// Verify that the event target is the input and not the item
const event = onClick.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-input');
});

test('clicking native wrapper should fire one click event', async ({ page }) => {
await page.setContent(
`
<ion-item>
<ion-input label="Input"></ion-input>
</ion-item>
`,
config
);

const nativeWrapper = page.locator('.native-wrapper');
const onClick = await page.spyOnEvent('click');

await nativeWrapper.click({
position: {
x: 5,
y: 5,
},
});

expect(onClick).toHaveReceivedEventTimes(1);

// Verify that the event target is the input and not the native wrapper
const event = onClick.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-input');
});

test('clicking native input within item should fire click event with target as ion-input', async ({ page }) => {
await page.setContent(
`
<ion-item>
<ion-input label="Input"></ion-input>
</ion-item>
`,
config
);

const nativeInput = page.locator('.native-input');
const onClick = await page.spyOnEvent('click');

await nativeInput.click();
expect(onClick).toHaveReceivedEventTimes(1);

// Verify that the event target is the ion-input and not the native input
const event = onClick.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-input');
});
});
});
10 changes: 8 additions & 2 deletions core/src/components/item/item.tsx
Original file line number Diff line number Diff line change
@@ -286,6 +286,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
if (firstInteractive !== undefined && !multipleInputs) {
const path = ev.composedPath();
const target = path[0] as HTMLElement;

if (ev.isTrusted) {
/**
* Dispatches a click event to the first interactive element,
@@ -304,9 +305,14 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
*/
if (firstInteractive.tagName === 'ION-INPUT' || firstInteractive.tagName === 'ION-TEXTAREA') {
(firstInteractive as HTMLIonInputElement | HTMLIonTextareaElement).setFocus();
} else {
firstInteractive.click();
}
firstInteractive.click();
/**
* Stop the item event from being triggered
* as the firstInteractive click event will also
* trigger the item click event.
*/
ev.stopImmediatePropagation();
}
}
}
48 changes: 45 additions & 3 deletions core/src/components/radio/test/item/radio.e2e.ts
Original file line number Diff line number Diff line change
@@ -78,9 +78,16 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, screenshot, co
await expect(list).toHaveScreenshot(screenshot(`radio-stacked-label-in-item`));
});
});
});

test.describe(title('radio: ionChange'), () => {
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('radio: item functionality'), () => {
test('clicking padded space within item should click the radio', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/27169',
});

await page.setContent(
`
<ion-radio-group>
@@ -93,11 +100,11 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, screenshot, co
`,
config
);
const itemNative = page.locator('.item-native');
const item = page.locator('ion-item');
const ionChange = await page.spyOnEvent('ionChange');

// Clicks the padded space within the item
await itemNative.click({
await item.click({
position: {
x: 5,
y: 5,
@@ -106,5 +113,40 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, screenshot, co

expect(ionChange).toHaveReceivedEvent();
});

test('clicking padded space within item should fire one click event', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/29758',
});

await page.setContent(
`
<ion-item>
<ion-radio>
Radio
</ion-radio>
</ion-item>
`,
config
);

const item = page.locator('ion-item');
const onClick = await page.spyOnEvent('click');

// Click the padding area (5px from left edge)
await item.click({
position: {
x: 5,
y: 5,
},
});

expect(onClick).toHaveReceivedEventTimes(1);

// Verify that the event target is the radio and not the item
const event = onClick.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-radio');
});
});
});
28 changes: 0 additions & 28 deletions core/src/components/select/test/basic/select.e2e.ts
Original file line number Diff line number Diff line change
@@ -294,34 +294,6 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
await select.evaluate((el: HTMLIonSelectElement) => (el.value = 'banana'));
await expect(ionChange).not.toHaveReceivedEvent();
});

test('clicking padded space within item should click the select', async ({ page }) => {
await page.setContent(
`
<ion-item>
<ion-select label="Fruit" interface="action-sheet">
<ion-select-option value="apple">Apple</ion-select-option>
<ion-select-option value="banana">Banana</ion-select-option>
</ion-select>
</ion-item>
`,
config
);
const itemNative = page.locator('.item-native');
const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent');

// Clicks the padded space within the item
await itemNative.click({
position: {
x: 5,
y: 5,
},
});

await ionActionSheetDidPresent.next();

expect(ionActionSheetDidPresent).toHaveReceivedEvent();
});
});
});

75 changes: 75 additions & 0 deletions core/src/components/select/test/item/select.e2e.ts
Original file line number Diff line number Diff line change
@@ -61,3 +61,78 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
});
});
});

configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('select: item functionality'), () => {
test('clicking padded space within item should click the select', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/27169',
});

await page.setContent(
`
<ion-item>
<ion-select label="Fruit" interface="action-sheet">
<ion-select-option value="apple">Apple</ion-select-option>
<ion-select-option value="banana">Banana</ion-select-option>
</ion-select>
</ion-item>
`,
config
);
const item = page.locator('ion-item');
const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent');

// Clicks the padded space within the item
await item.click({
position: {
x: 5,
y: 5,
},
});

await ionActionSheetDidPresent.next();

expect(ionActionSheetDidPresent).toHaveReceivedEvent();
});

test('clicking padded space within item should fire one click event', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/29758',
});

await page.setContent(
`
<ion-item>
<ion-select
label="Fruit"
value="apple"
>
<ion-select-option value="apple">Apple</ion-select-option>
</ion-select>
</ion-item>
`,
config
);

const item = page.locator('ion-item');
const onClick = await page.spyOnEvent('click');

// Click the padding area (5px from left edge)
await item.click({
position: {
x: 5,
y: 5,
},
});

expect(onClick).toHaveReceivedEventTimes(1);

// Verify that the event target is the select and not the item
const event = onClick.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-select');
});
});
});
65 changes: 63 additions & 2 deletions core/src/components/textarea/test/item/textarea.e2e.ts
Original file line number Diff line number Diff line change
@@ -49,6 +49,11 @@ configs().forEach(({ title, screenshot, config }) => {
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('textarea: item functionality'), () => {
test('clicking padded space within item should focus the textarea', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/21982',
});

await page.setContent(
`
<ion-item>
@@ -57,11 +62,11 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
`,
config
);
const itemNative = page.locator('.item-native');
const item = page.locator('ion-item');
const textarea = page.locator('ion-textarea textarea');

// Clicks the padded space within the item
await itemNative.click({
await item.click({
position: {
x: 5,
y: 5,
@@ -70,5 +75,61 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>

await expect(textarea).toBeFocused();
});

test('clicking padded space within item should fire one click event', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/29761',
});

await page.setContent(
`
<ion-item>
<ion-textarea label="Textarea"></ion-textarea>
</ion-item>
`,
config
);

const item = page.locator('ion-item');
const onClick = await page.spyOnEvent('click');

// Click the padding area (5px from left edge)
await item.click({
position: {
x: 5,
y: 5,
},
});

expect(onClick).toHaveReceivedEventTimes(1);

// Verify that the event target is the input and not the item
const event = onClick.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-textarea');
});

test('clicking native textarea within item should fire click event with target as ion-textarea', async ({
page,
}) => {
await page.setContent(
`
<ion-item>
<ion-textarea label="Textarea"></ion-textarea>
</ion-item>
`,
config
);

const nativeTextarea = page.locator('.native-textarea');
const onClick = await page.spyOnEvent('click');

await nativeTextarea.click();
expect(onClick).toHaveReceivedEventTimes(1);

// Verify that the event target is the ion-textarea and not the native textarea
const event = onClick.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-textarea');
});
});
});
14 changes: 14 additions & 0 deletions core/src/components/textarea/textarea.tsx
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@ import {
Element,
Event,
Host,
Listen,
Method,
Prop,
State,
@@ -314,6 +315,19 @@ export class Textarea implements ComponentInterface {
*/
@Event() ionFocus!: EventEmitter<FocusEvent>;

/**
* This prevents the native input from emitting the click event.
* Instead, the click event from the ion-textarea is emitted.
*/
@Listen('click', { capture: true })
onClickCapture(ev: Event) {
const nativeInput = this.nativeInput;
if (nativeInput && ev.target === nativeInput) {
ev.stopPropagation();
this.el.click();
}
}

connectedCallback() {
const { el } = this;
this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => forceUpdate(this));
48 changes: 45 additions & 3 deletions core/src/components/toggle/test/item/toggle.e2e.ts
Original file line number Diff line number Diff line change
@@ -108,9 +108,16 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, screenshot, co
await expect(list).toHaveScreenshot(screenshot(`toggle-stacked-label-in-item`));
});
});
});

test.describe(title('toggle: ionChange'), () => {
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('toggle: item functionality'), () => {
test('clicking padded space within item should click the toggle', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/27169',
});

await page.setContent(
`
<ion-item>
@@ -119,7 +126,7 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, screenshot, co
`,
config
);
const itemNative = page.locator('.item-native');
const item = page.locator('ion-item');
const ionChange = await page.spyOnEvent('ionChange');

/**
@@ -132,7 +139,7 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, screenshot, co
* 2. iOS is inconsistent in their implementation and other controls can be activated by clicking the label.
* 3. MD is consistent in their implementation and activates controls by clicking the label.
*/
await itemNative.click({
await item.click({
position: {
x: 5,
y: 5,
@@ -141,5 +148,40 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, screenshot, co

expect(ionChange).toHaveReceivedEvent();
});

test('clicking padded space within item should fire one click event', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/29758',
});

await page.setContent(
`
<ion-item>
<ion-toggle>
Toggle
</ion-toggle>
</ion-item>
`,
config
);

const item = page.locator('ion-item');
const onClick = await page.spyOnEvent('click');

// Click the padding area (5px from left edge)
await item.click({
position: {
x: 5,
y: 5,
},
});

expect(onClick).toHaveReceivedEventTimes(1);

// Verify that the event target is the toggle and not the item
const event = onClick.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-toggle');
});
});
});