From 0d556a71d6bcd5da501724ccbb4ce0be433768df Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Wed, 12 Apr 2023 07:17:18 +0200 Subject: [PATCH] feat: add ElementHandle.scrollIntoView (#10005) --- docs/api/puppeteer.elementhandle.md | 1 + .../puppeteer.elementhandle.scrollintoview.md | 25 +++++++ .../puppeteer-core/src/api/ElementHandle.ts | 31 +++++++++ .../src/common/ElementHandle.ts | 68 ++++++++----------- test/TestExpectations.json | 6 ++ test/src/click.spec.ts | 12 ++++ 6 files changed, 102 insertions(+), 41 deletions(-) create mode 100644 docs/api/puppeteer.elementhandle.scrollintoview.md diff --git a/docs/api/puppeteer.elementhandle.md b/docs/api/puppeteer.elementhandle.md index 1cb28118..5e2ca202 100644 --- a/docs/api/puppeteer.elementhandle.md +++ b/docs/api/puppeteer.elementhandle.md @@ -70,6 +70,7 @@ The constructor for this class is marked as internal. Third-party code should no | [isIntersectingViewport(this, options)](./puppeteer.elementhandle.isintersectingviewport.md) | | Resolves to true if the element is visible in the current viewport. If an element is an SVG, we check if the svg owner element is in the viewport instead. See https://crbug.com/963246. | | [press(key, options)](./puppeteer.elementhandle.press.md) | | Focuses the element, and then uses [Keyboard.down()](./puppeteer.keyboard.down.md) and [Keyboard.up()](./puppeteer.keyboard.up.md). | | [screenshot(this, options)](./puppeteer.elementhandle.screenshot.md) | | This method scrolls element into view if needed, and then uses [Page.screenshot()](./puppeteer.page.screenshot_2.md) to take a screenshot of the element. If the element is detached from DOM, the method throws an error. | +| [scrollIntoView(this)](./puppeteer.elementhandle.scrollintoview.md) | | Scrolls the element into view using either the automation protocol client or by calling element.scrollIntoView. | | [select(values)](./puppeteer.elementhandle.select.md) | | Triggers a change and input event once all the provided options have been selected. If there's no <select> element matching selector, the method throws an error. | | [tap(this)](./puppeteer.elementhandle.tap.md) | | This method scrolls element into view if needed, and then uses [Touchscreen.tap()](./puppeteer.touchscreen.tap.md) to tap in the center of the element. If the element is detached from DOM, the method throws an error. | | [toElement(tagName)](./puppeteer.elementhandle.toelement.md) | | Converts the current handle to the given element type. | diff --git a/docs/api/puppeteer.elementhandle.scrollintoview.md b/docs/api/puppeteer.elementhandle.scrollintoview.md new file mode 100644 index 00000000..9f1f0002 --- /dev/null +++ b/docs/api/puppeteer.elementhandle.scrollintoview.md @@ -0,0 +1,25 @@ +--- +sidebar_label: ElementHandle.scrollIntoView +--- + +# ElementHandle.scrollIntoView() method + +Scrolls the element into view using either the automation protocol client or by calling element.scrollIntoView. + +#### Signature: + +```typescript +class ElementHandle { + scrollIntoView(this: ElementHandle): Promise; +} +``` + +## Parameters + +| Parameter | Type | Description | +| --------- | ------------------------------------------------------------ | ----------- | +| this | [ElementHandle](./puppeteer.elementhandle.md)<Element> | | + +**Returns:** + +Promise<void> diff --git a/packages/puppeteer-core/src/api/ElementHandle.ts b/packages/puppeteer-core/src/api/ElementHandle.ts index 454be0f9..49e5faaa 100644 --- a/packages/puppeteer-core/src/api/ElementHandle.ts +++ b/packages/puppeteer-core/src/api/ElementHandle.ts @@ -811,6 +811,27 @@ export class ElementHandle< throw new Error('Not implemented'); } + /** + * @internal + */ + protected async assertConnectedElement(): Promise { + const error = await this.evaluate( + async (element): Promise => { + if (!element.isConnected) { + return 'Node is detached from document'; + } + if (element.nodeType !== Node.ELEMENT_NODE) { + return 'Node is not of type HTMLElement'; + } + return; + } + ); + + if (error) { + throw new Error(error); + } + } + /** * Resolves to true if the element is visible in the current viewport. If an * element is an SVG, we check if the svg owner element is in the viewport @@ -822,6 +843,8 @@ export class ElementHandle< threshold?: number; } ): Promise { + await this.assertConnectedElement(); + const {threshold = 0} = options ?? {}; const svgHandle = await this.#asSVGElementHandle(this); const intersectionTarget: ElementHandle = svgHandle @@ -846,6 +869,14 @@ export class ElementHandle< } } + /** + * Scrolls the element into view using either the automation protocol client + * or by calling element.scrollIntoView. + */ + async scrollIntoView(this: ElementHandle): Promise { + throw new Error('Not implemented'); + } + /** * Returns true if an element is an SVGElement (included svg, path, rect * etc.). diff --git a/packages/puppeteer-core/src/common/ElementHandle.ts b/packages/puppeteer-core/src/common/ElementHandle.ts index 9407ba0e..2a6f1e1e 100644 --- a/packages/puppeteer-core/src/common/ElementHandle.ts +++ b/packages/puppeteer-core/src/common/ElementHandle.ts @@ -231,58 +231,44 @@ export class CDPElementHandle< return this.#frameManager.frame(nodeInfo.node.frameId); } - async #scrollIntoViewIfNeeded( + override async scrollIntoView( this: CDPElementHandle ): Promise { - const error = await this.evaluate( - async (element): Promise => { - if (!element.isConnected) { - return 'Node is detached from document'; - } - if (element.nodeType !== Node.ELEMENT_NODE) { - return 'Node is not of type HTMLElement'; - } - return; - } - ); - - if (error) { - throw new Error(error); - } + await this.assertConnectedElement(); try { await this.client.send('DOM.scrollIntoViewIfNeeded', { objectId: this.remoteObject().objectId, }); - } catch (_err) { + } catch (error) { + debugError(error); // Fallback to Element.scrollIntoView if DOM.scrollIntoViewIfNeeded is not supported - await this.evaluate( - async (element, pageJavascriptEnabled): Promise => { - const visibleRatio = async () => { - return await new Promise(resolve => { - const observer = new IntersectionObserver(entries => { - resolve(entries[0]!.intersectionRatio); - observer.disconnect(); - }); - observer.observe(element); - }); - }; - if (!pageJavascriptEnabled || (await visibleRatio()) !== 1.0) { - element.scrollIntoView({ - block: 'center', - inline: 'center', - // @ts-expect-error Chrome still supports behavior: instant but - // it's not in the spec so TS shouts We don't want to make this - // breaking change in Puppeteer yet so we'll ignore the line. - behavior: 'instant', - }); - } - }, - this.#page.isJavaScriptEnabled() - ); + await this.evaluate(async (element): Promise => { + element.scrollIntoView({ + block: 'center', + inline: 'center', + // @ts-expect-error Chrome still supports behavior: instant but + // it's not in the spec so TS shouts We don't want to make this + // breaking change in Puppeteer yet so we'll ignore the line. + behavior: 'instant', + }); + }); } } + async #scrollIntoViewIfNeeded( + this: CDPElementHandle + ): Promise { + if ( + await this.isIntersectingViewport({ + threshold: 1, + }) + ) { + return; + } + await this.scrollIntoView(); + } + async #getOOPIFOffsets( frame: Frame ): Promise<{offsetX: number; offsetY: number}> { diff --git a/test/TestExpectations.json b/test/TestExpectations.json index 94b41ae8..84c88784 100644 --- a/test/TestExpectations.json +++ b/test/TestExpectations.json @@ -509,6 +509,12 @@ "parameters": ["cdp", "firefox"], "expectations": ["FAIL"] }, + { + "testIdPattern": "[click.spec] Page.click should scroll and click with disabled javascript", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["cdp", "firefox"], + "expectations": ["FAIL"] + }, { "testIdPattern": "[click.spec] Page.click should select the text by triple clicking", "platforms": ["darwin", "linux", "win32"], diff --git a/test/src/click.spec.ts b/test/src/click.spec.ts index ecc4acda..8c1e1cdd 100644 --- a/test/src/click.spec.ts +++ b/test/src/click.spec.ts @@ -116,6 +116,18 @@ describe('Page.click', function () { await Promise.all([page.click('a'), page.waitForNavigation()]); expect(page.url()).toBe(server.PREFIX + '/wrappedlink.html#clicked'); }); + it('should scroll and click with disabled javascript', async () => { + const {page, server} = getTestState(); + + await page.setJavaScriptEnabled(false); + await page.goto(server.PREFIX + '/wrappedlink.html'); + const body = await page.waitForSelector('body'); + await body!.evaluate(el => { + el.style.paddingTop = '3000px'; + }); + await Promise.all([page.click('a'), page.waitForNavigation()]); + expect(page.url()).toBe(server.PREFIX + '/wrappedlink.html#clicked'); + }); it('should click when one of inline box children is outside of viewport', async () => { const {page} = getTestState();