diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index dbbff605692..2dc919e41b1 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 { ClickController, NotchController, StartContainerController } from '@utils/forms'; +import { + createClickController, + createNotchController, + 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 clickController?: ClickController; 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.clickController?.handleClickCapture(ev); } componentWillLoad() { @@ -433,6 +435,8 @@ export class Input implements ComponentInterface { this.startContainerController.calculateStartContainerWidth(); + this.clickController = createClickController(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..7ab510d191a 100644 --- a/core/src/components/input/test/basic/input.e2e.ts +++ b/core/src/components/input/test/basic/input.e2e.ts @@ -347,3 +347,90 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { }); }); }); + +/** + * This behavior does not vary across directions/modes + */ +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 and focus the input when a slotted icon is clicked', async ({ page }) => { + const clickEvent = await page.spyOnEvent('click'); + + await page.locator('ion-icon[slot="start"]').click(); + + expect(clickEvent).toHaveReceivedEventTimes(1); + + const event = clickEvent.events[0]; + expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-icon'); + + await expect(page.locator('ion-input input.native-input')).toBeFocused(); + }); + + test('should emit one click without focusing the input when a slotted button is clicked', async ({ page }) => { + const clickEvent = await page.spyOnEvent('click'); + + await page.locator('ion-button[slot="end"]').click(); + + expect(clickEvent).toHaveReceivedEventTimes(1); + + await expect(page.locator('ion-input input.native-input')).not.toBeFocused(); + }); + + /** + * Browsers skip the label forwarding when a click lands on interactive + * content, so activating a slotted control leaves the input alone. A radio + * outside a radio group cannot be checked, so only the input is asserted + * for it. + */ + [ + { tag: 'ion-checkbox', checkable: true }, + { tag: 'ion-radio', checkable: false }, + { tag: 'ion-toggle', checkable: true }, + ].forEach(({ tag, checkable }) => { + test(`should not focus the input when a slotted ${tag} is clicked`, async ({ page }) => { + const control = page.locator(tag); + + await control.click(); + await page.waitForChanges(); + + if (checkable) { + await expect(control).toHaveJSProperty('checked', true); + } + + await expect(page.locator('ion-input input.native-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('ion-button[slot="end"]').click(); + + const clickEvent = await page.spyOnEvent('click'); + + await page.locator('ion-input input.native-input').click(); + + expect(clickEvent).toHaveReceivedEventTimes(1); + }); + }); +}); diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx index 7c3f7f7477f..a8fd05996af 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -1,11 +1,26 @@ 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 type { NotchController, StartContainerController } from '@utils/forms'; +import type { ClickController, NotchController, StartContainerController } from '@utils/forms'; import { compareOptions, + createClickController, createNotchController, createStartContainerController, + getSlottedClickContent, isOptionSelected, checkInvalidState, } from '@utils/forms'; @@ -91,6 +106,8 @@ export class Select implements ComponentInterface { private startContainerEl: HTMLElement | undefined; private customHTMLEnabled = config.get('innerHTMLTemplatesEnabled', ENABLE_HTML_CONTENT_DEFAULT); + private clickController?: ClickController; + @Element() el!: HTMLIonSelectElement; @State() isExpanded = false; @@ -364,6 +381,8 @@ export class Select implements ComponentInterface { this.startContainerController.calculateStartContainerWidth(); + this.clickController = createClickController(el); + this.updateOverlayOptions(); this.emitStyle(); @@ -993,42 +1012,39 @@ export class Select implements ComponentInterface { this.ionStyle.emit(style); } + /** + * The label wrapping the slots has no `for` attribute, so the browser + * forwards a click on slotted content to the label's first labelable + * descendant, the internal button. That forwarded click bubbles back out of + * the shadow root targeting the host, where it would be emitted a second + * time and open the select. The controller swallows it during the capture + * phase, leaving the click on the slotted content itself alone so slotted + * links, checkboxes and buttons keep their default behavior. + */ + @Listen('click', { capture: true }) + onClickCapture(ev: Event) { + this.clickController?.handleClickCapture(ev); + } + private onClick = (ev: UIEvent) => { - const target = ev.target as HTMLElement; - const closestSlot = target.closest('[slot="start"], [slot="end"]'); + const slotted = getSlottedClickContent(ev, this.el); - if (target === this.el || closestSlot === null) { - this.setFocus(); - this.open(ev); - } else { - /** - * Prevent clicks to the start/end slots from opening the select. - * We ensure the target isn't this element in case the select is slotted - * in, for example, an item. This would prevent the select from ever - * being opened since the element itself has slot="start"/"end". - * - * Clicking a slotted element also causes a click - * on the