From 74c2f7c0d255bfa6eb7e54ec6480bcf5afaf5b72 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Wed, 2 Sep 2026 15:48:54 -0700 Subject: [PATCH 1/2] fix(input, select, textarea): emit one click event when slotted content is clicked --- core/src/components/input/input.tsx | 18 +-- .../components/input/test/basic/input.e2e.ts | 67 +++++++++++ core/src/components/select/select.tsx | 112 ++++++++++++------ .../select/test/basic/select.e2e.ts | 91 ++++++++++++++ .../textarea/test/basic/textarea.e2e.ts | 67 +++++++++++ core/src/components/textarea/textarea.tsx | 18 +-- core/src/utils/forms/index.ts | 1 + core/src/utils/forms/slotted-click.ts | 66 +++++++++++ 8 files changed, 392 insertions(+), 48 deletions(-) create mode 100644 core/src/utils/forms/slotted-click.ts diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index dbbff605692..32bcd092ead 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -13,8 +13,13 @@ import { forceUpdate, h, } from '@stencil/core'; -import type { NotchController, StartContainerController } from '@utils/forms'; -import { createNotchController, createStartContainerController, checkInvalidState } from '@utils/forms'; +import type { NotchController, SlottedClickController, StartContainerController } from '@utils/forms'; +import { + createNotchController, + createSlottedClickController, + createStartContainerController, + checkInvalidState, +} from '@utils/forms'; import type { Attributes } from '@utils/helpers'; import { inheritAriaAttributes, debounceEvent, inheritAttributes, componentOnReady } from '@utils/helpers'; import { createSlotMutationController } from '@utils/slot-mutation-controller'; @@ -56,6 +61,7 @@ export class Input implements ComponentInterface { private notchSpacerEl: HTMLElement | undefined; private startContainerController?: StartContainerController; private startContainerEl: HTMLElement | undefined; + private slottedClickController?: SlottedClickController; private originalIonInput?: EventEmitter; @@ -392,11 +398,7 @@ export class Input implements ComponentInterface { */ @Listen('click', { capture: true }) onClickCapture(ev: Event) { - const nativeInput = this.nativeInput; - if (nativeInput && ev.target === nativeInput) { - ev.stopPropagation(); - this.el.click(); - } + this.slottedClickController?.handleClickCapture(ev); } componentWillLoad() { @@ -433,6 +435,8 @@ export class Input implements ComponentInterface { this.startContainerController.calculateStartContainerWidth(); + this.slottedClickController = createSlottedClickController(el, () => this.nativeInput); + // Watch for class changes to update validation state if (Build.isBrowser && typeof MutationObserver !== 'undefined') { this.validationObserver = new MutationObserver(() => { diff --git a/core/src/components/input/test/basic/input.e2e.ts b/core/src/components/input/test/basic/input.e2e.ts index 65a921a0f6f..4ae15f12528 100644 --- a/core/src/components/input/test/basic/input.e2e.ts +++ b/core/src/components/input/test/basic/input.e2e.ts @@ -347,3 +347,70 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { }); }); }); + +/** + * Slotted click behavior does not vary by mode or direction. + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('input: slotted click'), () => { + test.beforeEach(async ({ page }) => { + await page.setContent( + ` + + + + + + + `, + config + ); + }); + + 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'); + }); + + test('should focus the input when a slotted icon is clicked', async ({ page }) => { + await page.locator('#start-icon').click(); + + await expect(page.locator('ion-input input')).toBeFocused(); + }); + + 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 focus the input when a slotted button is clicked', async ({ page }) => { + await page.locator('#end-button').click(); + + await expect(page.locator('ion-input input')).not.toBeFocused(); + }); + + test('should emit one click when the input is clicked after slotted content', async ({ page }) => { + /** + * Clicking a slotted button does not produce a forwarded click for the + * input to ignore, so the following click on the input itself must + * still be emitted. + */ + await page.locator('#end-button').click(); + + const clickEvent = await page.spyOnEvent('click'); + + await page.locator('ion-input input').click(); + + expect(clickEvent).toHaveReceivedEventTimes(1); + }); + }); +}); diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx index 7c3f7f7477f..dc06a02bd5d 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -1,6 +1,20 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { Build, Component, Element, Event, Host, Method, Prop, State, Watch, h, forceUpdate } from '@stencil/core'; +import { + Build, + Component, + Element, + Event, + Host, + Listen, + Method, + Prop, + State, + Watch, + h, + forceUpdate, +} from '@stencil/core'; import { ENABLE_HTML_CONTENT_DEFAULT } from '@utils/config'; +import { focusableQueryString } from '@utils/focus-trap'; import type { NotchController, StartContainerController } from '@utils/forms'; import { compareOptions, @@ -8,8 +22,9 @@ import { createStartContainerController, isOptionSelected, checkInvalidState, + isSlottedClick, } from '@utils/forms'; -import { focusVisibleElement, renderHiddenInput, inheritAttributes } from '@utils/helpers'; +import { focusVisibleElement, renderHiddenInput, inheritAttributes, raf } from '@utils/helpers'; import type { Attributes } from '@utils/helpers'; import { printIonWarning } from '@utils/logging'; import { actionSheetController, alertController, popoverController, modalController } from '@utils/overlays'; @@ -91,6 +106,14 @@ export class Select implements ComponentInterface { private startContainerEl: HTMLElement | undefined; private customHTMLEnabled = config.get('innerHTMLTemplatesEnabled', ENABLE_HTML_CONTENT_DEFAULT); + /** + * `true` if the click currently being dispatched started on content slotted + * into the select. Used to ignore the click the wrapping label forwards to + * the internal button so that a single click is not emitted twice and does + * not open the select. + */ + private hasSlottedClick = false; + @Element() el!: HTMLIonSelectElement; @State() isExpanded = false; @@ -993,42 +1016,54 @@ export class Select implements ComponentInterface { this.ionStyle.emit(style); } - private onClick = (ev: UIEvent) => { - const target = ev.target as HTMLElement; - const closestSlot = target.closest('[slot="start"], [slot="end"]'); + /** + * Clicking slotted content also clicks the