diff --git a/core/src/components/input/test/item/scroll-assist-double-click.e2e.ts b/core/src/components/input/test/item/scroll-assist-double-click.e2e.ts new file mode 100644 index 00000000000..be590aca7e5 --- /dev/null +++ b/core/src/components/input/test/item/scroll-assist-double-click.e2e.ts @@ -0,0 +1,124 @@ +import { expect } from '@playwright/test'; +import type { E2EPage } from '@utils/test/playwright'; +import { configs, test } from '@utils/test/playwright'; + +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('input: scroll assist click events'), () => { + const setup = async (page: E2EPage) => { + const longText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(40); + await page.setContent( + ` + + +

${longText}

+ + + +

${longText}

+
+
+ `, + config + ); + }; + + // Position the target near the bottom of the viewport so scroll assist's + // `scrollAmount` calculation passes its 4px threshold and the relocate path runs. + const positionTargetForScrollAssist = async (page: E2EPage) => { + await page.evaluate(async () => { + const content = document.querySelector('ion-content') as HTMLIonContentElement; + const item = document.querySelector('#target')!; + const scrollEl = await content.getScrollElement(); + const itemRect = item.getBoundingClientRect(); + const desiredTop = window.innerHeight - itemRect.height - 20; + scrollEl.scrollTop += itemRect.top - desiredTop; + await new Promise((r) => requestAnimationFrame(() => r(null))); + }); + }; + + // Two rAF cycles drain any rAF-scheduled work, including the legacy recovery + // click that scroll assist used to fire after relocating the input. + const flushAnimationFrames = (page: E2EPage) => + page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))) + ); + + test('clicking ion-item padding when scroll is needed should fire one click event', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30412', + }); + await setup(page); + await positionTargetForScrollAssist(page); + + const item = page.locator('#target'); + const onClick = await page.spyOnEvent('click'); + + // `force: true` skips Playwright's auto-scroll-into-view, so the click + // lands while the item still requires scroll assist to relocate. + await item.click({ position: { x: 5, y: 5 }, force: true }); + await flushAnimationFrames(page); + + expect(onClick).toHaveReceivedEventTimes(1); + }); + + test('clicking ion-input directly when scroll is needed should fire one click event', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30412', + }); + await setup(page); + await positionTargetForScrollAssist(page); + + const onClick = await page.spyOnEvent('click'); + const nativeInput = page.locator('#target ion-input input'); + await nativeInput.click({ force: true }); + await flushAnimationFrames(page); + + expect(onClick).toHaveReceivedEventTimes(1); + }); + + test('programmatic setFocus when scroll is needed should not fire a click event', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30412', + }); + await setup(page); + await positionTargetForScrollAssist(page); + + const onClick = await page.spyOnEvent('click'); + await page.evaluate(async () => { + const input = document.querySelector('#target ion-input') as HTMLIonInputElement; + await input.setFocus(); + }); + await flushAnimationFrames(page); + + expect(onClick).toHaveReceivedEventTimes(0); + }); + + test('keyboard focus into input when scroll is needed should not fire a click event', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30412', + }); + await setup(page); + await positionTargetForScrollAssist(page); + + const onClick = await page.spyOnEvent('click'); + + // Focus the native input directly without dispatching a click; mimics + // tab navigation landing on an offscreen input. + await page.evaluate(() => { + const native = document.querySelector('#target ion-input input') as HTMLInputElement; + native.focus(); + }); + await flushAnimationFrames(page); + + expect(onClick).toHaveReceivedEventTimes(0); + }); + }); +}); diff --git a/core/src/utils/input-shims/hacks/scroll-assist.ts b/core/src/utils/input-shims/hacks/scroll-assist.ts index 9d4685cb8da..a5f3780741b 100644 --- a/core/src/utils/input-shims/hacks/scroll-assist.ts +++ b/core/src/utils/input-shims/hacks/scroll-assist.ts @@ -2,7 +2,6 @@ import type { KeyboardResizeOptions } from '@capacitor/keyboard'; import { win } from '@utils/browser'; import { getScrollElement, scrollByPoint } from '../../content'; -import { raf } from '../../helpers'; import { KeyboardResize } from '../../native/keyboard'; import { relocateInput, SCROLL_AMOUNT_PADDING } from './common'; @@ -253,13 +252,6 @@ const jsSetFocus = async ( relocateInput(componentEl, inputEl, true, scrollData.inputSafeY, disableClonedInput); setManualFocus(inputEl); - /** - * Relocating/Focusing input causes the - * click event to be cancelled, so - * manually fire one here. - */ - raf(() => componentEl.click()); - /** * If enabled, we can add scroll padding to * the bottom of the content so that scroll assist