Skip to content

fix(input, select, textarea): emit one click event when slotted content is clicked - #31423

Open
thetaPC wants to merge 2 commits into
mainfrom
FW-7677
Open

fix(input, select, textarea): emit one click event when slotted content is clicked#31423
thetaPC wants to merge 2 commits into
mainfrom
FW-7677

Conversation

@thetaPC

@thetaPC thetaPC commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Issue number: resolves internal


What is the current behavior?

Clicking content in the start or end slot behaves differently in each form control. ion-input and ion-textarea emit the click event twice, while ion-select emits once but does not respond to the click at all.

The duplicate comes from the browser rather than from Ionic emitting twice. The wrapping <label> forwards the click to the native control, and that forwarded click is re-emitted from the host. Browsers skip this forwarding when the click lands on interactive content, which is why slotted buttons were never affected.

ion-select separately cancels the default action on every slotted click. A slotted link does not navigate and a slotted checkbox does not toggle, while the same markup works in ion-input.

What is the new behavior?

  • Clicking slotted content emits a single click event from all three components.
  • Decorative slotted content now activates the control: ion-input and ion-textarea focus, ion-select opens.
  • Interactive slotted content, such as a button or a link, keeps its own behavior and does not activate the control.
  • Slotted links and form controls work inside ion-select.

Does this introduce a breaking change?

  • Yes
  • No

Other information

Previews:

@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
ionic-framework Ready Ready Preview Sep 3, 2026 2:28am UTC

Request Review

@github-actions github-actions Bot added the package: core @ionic/core package label Sep 2, 2026
@thetaPC
thetaPC marked this pull request as ready for review September 2, 2026 23:17
@thetaPC
thetaPC requested a review from a team as a code owner September 2, 2026 23:17
@thetaPC
thetaPC requested a review from OS-jacobbell September 2, 2026 23:17

@brandyscarney brandyscarney left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will leave more info on the ticket!

await expect(page.locator('ion-select')).not.toHaveClass(/has-focus/);
});

test('should activate slotted form controls', async ({ page }) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should verify that activating a form control doesn't open the select.

Comment on lines +1364 to +1386
test('should emit one click when a slotted icon is clicked', async ({ page }) => {
const clickEvent = await page.spyOnEvent('click');

await page.locator('#start-icon').click();

expect(clickEvent).toHaveReceivedEventTimes(1);

const event = clickEvent.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-icon');
});

/**
* Decorative slotted content behaves the same as clicking the select
* itself, so it opens the overlay.
*/
test('should open when a slotted icon is clicked', async ({ page }) => {
const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent');

await page.locator('#start-icon').click();
await ionAlertDidPresent.next();

await expect(page.locator('ion-alert')).toBeVisible();
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we combine these?

Suggested change
test('should emit one click when a slotted icon is clicked', async ({ page }) => {
const clickEvent = await page.spyOnEvent('click');
await page.locator('#start-icon').click();
expect(clickEvent).toHaveReceivedEventTimes(1);
const event = clickEvent.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-icon');
});
/**
* Decorative slotted content behaves the same as clicking the select
* itself, so it opens the overlay.
*/
test('should open when a slotted icon is clicked', async ({ page }) => {
const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent');
await page.locator('#start-icon').click();
await ionAlertDidPresent.next();
await expect(page.locator('ion-alert')).toBeVisible();
});
/**
* Decorative slotted content behaves the same as clicking the select
* itself, so it opens the overlay.
*/
test('should emit one click and open the select when a slotted icon is clicked', async ({ page }) => {
const clickEvent = await page.spyOnEvent('click');
await page.locator('#start-icon').click();
expect(clickEvent).toHaveReceivedEventTimes(1);
const event = clickEvent.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-icon');
await ionAlertDidPresent.next();
await expect(page.locator('ion-alert')).toBeVisible();
});

Comment on lines +1388 to +1406
test('should emit one click when a slotted button is clicked', async ({ page }) => {
const clickEvent = await page.spyOnEvent('click');

await page.locator('#end-button').click();

expect(clickEvent).toHaveReceivedEventTimes(1);
});

test('should not open when a slotted button is clicked', async ({ page }) => {
await page.locator('#end-button').click();

await expect(page.locator('ion-alert')).toHaveCount(0);
});

test('should not focus the select when a slotted button is clicked', async ({ page }) => {
await page.locator('#end-button').click();

await expect(page.locator('ion-select')).not.toHaveClass(/has-focus/);
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we combine these?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the best name for this? It doesn't only handle slotted clicks, it handles all clicks including the native input. What about renaming the file to click-controller (or similar) to match the other files in this directory:

  • notch-controller.ts
  • start-container-controller.ts

Comment on lines +7 to +20
/**
* Whether a click started on content slotted into a form control's start or
* end slot.
*
* The slotted element is compared against the host in case the form control
* itself is slotted into, for example, an item. Without that check a control
* carrying slot="start"/"end" would treat every click on itself as a slotted
* click.
*/
export const isSlottedClick = (ev: Event, el: HTMLElement): boolean => {
const slotted = (ev.target as HTMLElement).closest('[slot="start"], [slot="end"]');

return slotted !== null && slotted !== el && el.contains(slotted);
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you split this to have getSlottedClickContent, select can be updated to call it (see select.tsx comment)

Suggested change
/**
* Whether a click started on content slotted into a form control's start or
* end slot.
*
* The slotted element is compared against the host in case the form control
* itself is slotted into, for example, an item. Without that check a control
* carrying slot="start"/"end" would treat every click on itself as a slotted
* click.
*/
export const isSlottedClick = (ev: Event, el: HTMLElement): boolean => {
const slotted = (ev.target as HTMLElement).closest('[slot="start"], [slot="end"]');
return slotted !== null && slotted !== el && el.contains(slotted);
};
/**
* The content slotted into a form control's start or end slot that a click
* started on, or `null` when the click did not start on slotted content.
*
* The slotted element is compared against the host in case the form control
* itself is slotted into, for example, an item. Without that check a control
* carrying slot="start"/"end" would treat every click on itself as a slotted
* click.
*/
export const getSlottedClickContent = (ev: Event, el: HTMLElement): HTMLElement | null => {
const slotted = (ev.target as HTMLElement).closest<HTMLElement>('[slot="start"], [slot="end"]');
return slotted !== null && slotted !== el && el.contains(slotted) ? slotted : null;
};
/**
* Whether a click started on content slotted into a form control's start or
* end slot.
*/
export const isSlottedClick = (ev: Event, el: HTMLElement): boolean => getSlottedClickContent(ev, el) !== null;

Comment on lines +1060 to +1063
const deepTarget = ev.composedPath()[0] as HTMLElement;
if (isSlottedClick(ev, this.el) && deepTarget.closest(INTERACTIVE_SLOTTED_CONTENT) !== null) {
return;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing to the following fixes the issue where the select opens from a slotted checkbox/radio/toggle:

Suggested change
const deepTarget = ev.composedPath()[0] as HTMLElement;
if (isSlottedClick(ev, this.el) && deepTarget.closest(INTERACTIVE_SLOTTED_CONTENT) !== null) {
return;
}
const slotted = getSlottedClickContent(ev, this.el);
if (slotted !== null) {
const interactive = (ev.target as HTMLElement).closest(INTERACTIVE_SLOTTED_CONTENT);
if (interactive !== null && slotted.contains(interactive)) {
return;
}
}

Comment on lines +1048 to +1051
if (this.hasSlottedClick) {
ev.stopPropagation();
this.hasSlottedClick = false;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lot of this is duplicated from the slotted click controller - can this not be combined with that?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

package: core @ionic/core package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants