feat(scrollIntoView): expose scrollIntoViewIfNeeded in api (#382)

This also replaces isIntersectingViewport with visibleRatio for more flexibility.
This commit is contained in:
Dmitry Gozman 2020-01-06 13:16:56 -08:00 committed by Pavel Feldman
parent 58b8e66df8
commit 491eeeef7e
5 changed files with 62 additions and 19 deletions

View file

@ -58,15 +58,16 @@
* [elementHandle.fill(value)](#elementhandlefillvalue) * [elementHandle.fill(value)](#elementhandlefillvalue)
* [elementHandle.focus()](#elementhandlefocus) * [elementHandle.focus()](#elementhandlefocus)
* [elementHandle.hover([options])](#elementhandlehoveroptions) * [elementHandle.hover([options])](#elementhandlehoveroptions)
* [elementHandle.isIntersectingViewport()](#elementhandleisintersectingviewport)
* [elementHandle.ownerFrame()](#elementhandleownerframe) * [elementHandle.ownerFrame()](#elementhandleownerframe)
* [elementHandle.press(key[, options])](#elementhandlepresskey-options) * [elementHandle.press(key[, options])](#elementhandlepresskey-options)
* [elementHandle.screenshot([options])](#elementhandlescreenshotoptions) * [elementHandle.screenshot([options])](#elementhandlescreenshotoptions)
* [elementHandle.scrollIntoViewIfNeeded()](#elementhandlescrollintoviewifneeded)
* [elementHandle.select(...values)](#elementhandleselectvalues) * [elementHandle.select(...values)](#elementhandleselectvalues)
* [elementHandle.setInputFiles(...files)](#elementhandlesetinputfilesfiles) * [elementHandle.setInputFiles(...files)](#elementhandlesetinputfilesfiles)
* [elementHandle.toString()](#elementhandletostring) * [elementHandle.toString()](#elementhandletostring)
* [elementHandle.tripleclick([options])](#elementhandletripleclickoptions) * [elementHandle.tripleclick([options])](#elementhandletripleclickoptions)
* [elementHandle.type(text[, options])](#elementhandletypetext-options) * [elementHandle.type(text[, options])](#elementhandletypetext-options)
* [elementHandle.visibleRatio()](#elementhandlevisibleratio)
- [class: Frame](#class-frame) - [class: Frame](#class-frame)
* [frame.$(selector)](#frameselector) * [frame.$(selector)](#frameselector)
* [frame.$$(selector)](#frameselector-1) * [frame.$$(selector)](#frameselector-1)
@ -786,9 +787,6 @@ Calls [focus](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus
This method scrolls element into view if needed, and then uses [page.mouse](#pagemouse) to hover over the center of the element. This method scrolls element into view if needed, and then uses [page.mouse](#pagemouse) to hover over the center of the element.
If the element is detached from DOM, the method throws an error. If the element is detached from DOM, the method throws an error.
#### elementHandle.isIntersectingViewport()
- returns: <[Promise]<[boolean]>> Resolves to true if the element is visible in the current viewport.
#### elementHandle.ownerFrame() #### elementHandle.ownerFrame()
- returns: <[Promise]<[Frame]>> Returns the frame containing the given element. - returns: <[Promise]<[Frame]>> Returns the frame containing the given element.
@ -816,6 +814,15 @@ If `key` is a single character and no modifier keys besides `Shift` are being he
This method scrolls element into view if needed, and then uses [page.screenshot](#pagescreenshotoptions) to take a screenshot of the element. This method scrolls element into view if needed, and then uses [page.screenshot](#pagescreenshotoptions) to take a screenshot of the element.
If the element is detached from DOM, the method throws an error. If the element is detached from DOM, the method throws an error.
#### elementHandle.scrollIntoViewIfNeeded()
- returns: <[Promise]> Resolves after the element has been scrolled into view.
This method tries to scroll element into view, unless it is completely visible as defined by [IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)'s ```ratio```. See also [elementHandle.visibleRatio()](#elementhandlevisibleratio).
Throws when ```elementHandle``` does not point to an element [connected](https://developer.mozilla.org/en-US/docs/Web/API/Node/isConnected) to a Document or a ShadowRoot.
> **NOTE** If javascript is disabled, element is scrolled into view even when already completely visible.
#### elementHandle.select(...values) #### elementHandle.select(...values)
- `...values` <...[string]|[ElementHandle]|[Object]> Options to select. If the `<select>` has the `multiple` attribute, all matching options are selected, otherwise only the first option matching one of the passed options is selected. String values are equivalent to `{value:'string'}`. Option is considered matching if all specified properties match. - `...values` <...[string]|[ElementHandle]|[Object]> Options to select. If the `<select>` has the `multiple` attribute, all matching options are selected, otherwise only the first option matching one of the passed options is selected. String values are equivalent to `{value:'string'}`. Option is considered matching if all specified properties match.
- `value` <[string]> Matches by `option.value`. - `value` <[string]> Matches by `option.value`.
@ -891,6 +898,11 @@ await elementHandle.type('some text');
await elementHandle.press('Enter'); await elementHandle.press('Enter');
``` ```
#### elementHandle.visibleRatio()
- returns: <[Promise]<[number]>> Returns the visible ratio as defined by [IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API).
Positive ratio means that some part of the element is visible in the current viewport. Ratio equal to one means that element is completely visible.
### class: Frame ### class: Frame
At every point of time, page exposes its current frame tree via the [page.mainFrame()](#pagemainframe) and [frame.childFrames()](#framechildframes) methods. At every point of time, page exposes its current frame tree via the [page.mainFrame()](#pagemainframe) and [frame.childFrames()](#framechildframes) methods.
@ -1954,8 +1966,7 @@ Get the browser context that the page belongs to.
- `relativePoint` <[Object]> A point to click relative to the top-left corner of element padding box. If not specified, clicks to some visible point of the element. - `relativePoint` <[Object]> A point to click relative to the top-left corner of element padding box. If not specified, clicks to some visible point of the element.
- x <[number]> - x <[number]>
- y <[number]> - y <[number]>
- `modifiers` <[Array]<"Alt"|"Control"|"Meta"|"Shift">> Modifier keys to press. Ensures that only these modifiers are pressed during the click, and then restores current modifiers back. If not specified, - `modifiers` <[Array]<"Alt"|"Control"|"Meta"|"Shift">> Modifier keys to press. Ensures that only these modifiers are pressed during the click, and then restores current modifiers back. If not specified, currently pressed modifiers are used.
currently pressed modifiers are used.
- `waitFor` <"visible"|"hidden"|"any"|"nowait"> Wait for element to become visible (`visible`), hidden (`hidden`), present in dom (`any`) or do not wait at all (`nowait`). Defaults to `visible`. - `waitFor` <"visible"|"hidden"|"any"|"nowait"> Wait for element to become visible (`visible`), hidden (`hidden`), present in dom (`any`) or do not wait at all (`nowait`). Defaults to `visible`.
- `timeout` <[number]> Maximum navigation time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be changed by using the [page.setDefaultTimeout(timeout)](#pagesetdefaulttimeouttimeout) method. - `timeout` <[number]> Maximum navigation time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be changed by using the [page.setDefaultTimeout(timeout)](#pagesetdefaulttimeouttimeout) method.
- returns: <[Promise]> Promise which resolves when the element matching `selector` is successfully clicked. The Promise will be rejected if there is no element matching `selector`. - returns: <[Promise]> Promise which resolves when the element matching `selector` is successfully clicked. The Promise will be rejected if there is no element matching `selector`.

View file

@ -134,7 +134,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return this._page._delegate.getContentFrame(this); return this._page._delegate.getContentFrame(this);
} }
async _scrollIntoViewIfNeeded() { async scrollIntoViewIfNeeded() {
const error = await this._evaluateInUtility(async (node: Node, pageJavascriptEnabled: boolean) => { const error = await this._evaluateInUtility(async (node: Node, pageJavascriptEnabled: boolean) => {
if (!node.isConnected) if (!node.isConnected)
return 'Node is detached from document'; return 'Node is detached from document';
@ -168,7 +168,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} }
private async _ensurePointerActionPoint(relativePoint?: types.Point): Promise<types.Point> { private async _ensurePointerActionPoint(relativePoint?: types.Point): Promise<types.Point> {
await this._scrollIntoViewIfNeeded(); await this.scrollIntoViewIfNeeded();
if (!relativePoint) if (!relativePoint)
return this._clickablePoint(); return this._clickablePoint();
let r = await this._viewportPointAndScroll(relativePoint); let r = await this._viewportPointAndScroll(relativePoint);
@ -464,12 +464,12 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return this._context._$$('xpath=' + expression, this); return this._context._$$('xpath=' + expression, this);
} }
isIntersectingViewport(): Promise<boolean> { visibleRatio(): Promise<number> {
return this._evaluateInUtility(async (node: Node) => { return this._evaluateInUtility(async (node: Node) => {
if (node.nodeType !== Node.ELEMENT_NODE) if (node.nodeType !== Node.ELEMENT_NODE)
throw new Error('Node is not of type HTMLElement'); throw new Error('Node is not of type HTMLElement');
const element = node as Element; const element = node as Element;
const visibleRatio = await new Promise(resolve => { const visibleRatio = await new Promise<number>(resolve => {
const observer = new IntersectionObserver(entries => { const observer = new IntersectionObserver(entries => {
resolve(entries[0].intersectionRatio); resolve(entries[0].intersectionRatio);
observer.disconnect(); observer.disconnect();
@ -479,7 +479,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
// there are rafs. // there are rafs.
requestAnimationFrame(() => {}); requestAnimationFrame(() => {});
}); });
return visibleRatio > 0; return visibleRatio;
}); });
} }
} }

View file

@ -105,7 +105,7 @@ export class Screenshotter {
await this._page.setViewport(overridenViewport); await this._page.setViewport(overridenViewport);
} }
await handle._scrollIntoViewIfNeeded(); await handle.scrollIntoViewIfNeeded();
boundingBox = enclosingIntRect(await this._page._delegate.getBoundingBoxForScreenshot(handle)); boundingBox = enclosingIntRect(await this._page._delegate.getBoundingBoxForScreenshot(handle));
} }

View file

@ -3,6 +3,23 @@
position: absolute; position: absolute;
width: 100px; width: 100px;
height: 20px; height: 20px;
margin: 0;
}
body, html {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
position: relative;
}
div {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
} }
#btn0 { right: 0px; top: 0; } #btn0 { right: 0px; top: 0; }
@ -17,6 +34,7 @@
#btn9 { right: -90px; top: 225px; } #btn9 { right: -90px; top: 225px; }
#btn10 { right: -100px; top: 250px; } #btn10 { right: -100px; top: 250px; }
</style> </style>
<div>
<button id=btn0>0</button> <button id=btn0>0</button>
<button id=btn1>1</button> <button id=btn1>1</button>
<button id=btn2>2</button> <button id=btn2>2</button>
@ -28,6 +46,7 @@
<button id=btn8>8</button> <button id=btn8>8</button>
<button id=btn9>9</button> <button id=btn9>9</button>
<button id=btn10>10</button> <button id=btn10>10</button>
</div>
<script> <script>
window.addEventListener('DOMContentLoaded', () => { window.addEventListener('DOMContentLoaded', () => {
for (const button of Array.from(document.querySelectorAll('button'))) for (const button of Array.from(document.querySelectorAll('button')))

View file

@ -248,14 +248,13 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) {
}); });
}); });
describe('ElementHandle.isIntersectingViewport', function() { describe('ElementHandle.visibleRatio', function() {
it('should work', async({page, server}) => { it('should work', async({page, server}) => {
await page.goto(server.PREFIX + '/offscreenbuttons.html'); await page.goto(server.PREFIX + '/offscreenbuttons.html');
for (let i = 0; i < 11; ++i) { for (let i = 0; i < 11; ++i) {
const button = await page.$('#btn' + i); const button = await page.$('#btn' + i);
// All but last button are visible. const ratio = await button.visibleRatio();
const visible = i < 10; expect(Math.round(ratio * 10)).toBe(10 - i);
expect(await button.isIntersectingViewport()).toBe(visible);
} }
}); });
it.skip(FFOX)('should work when Node is removed', async({page, server}) => { it.skip(FFOX)('should work when Node is removed', async({page, server}) => {
@ -263,9 +262,23 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) {
await page.evaluate(() => delete window['Node']); await page.evaluate(() => delete window['Node']);
for (let i = 0; i < 11; ++i) { for (let i = 0; i < 11; ++i) {
const button = await page.$('#btn' + i); const button = await page.$('#btn' + i);
// All but last button are visible. const ratio = await button.visibleRatio();
const visible = i < 10; expect(Math.round(ratio * 10)).toBe(10 - i);
expect(await button.isIntersectingViewport()).toBe(visible); }
});
});
describe('ElementHandle.scrollIntoViewIfNeeded', function() {
it('should work', async({page, server}) => {
await page.goto(server.PREFIX + '/offscreenbuttons.html');
for (let i = 0; i < 11; ++i) {
const button = await page.$('#btn' + i);
const before = await button.visibleRatio();
expect(Math.round(before * 10)).toBe(10 - i);
await button.scrollIntoViewIfNeeded();
const after = await button.visibleRatio();
expect(Math.round(after * 10)).toBe(10);
await page.evaluate(() => window.scrollTo(0, 0));
} }
}); });
}); });