2023-05-05 09:11:16 +00:00
|
|
|
/**
|
|
|
|
* Copyright 2023 Google Inc. All rights reserved.
|
|
|
|
*
|
|
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
* you may not use this file except in compliance with the License.
|
|
|
|
* You may obtain a copy of the License at
|
|
|
|
*
|
|
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
*
|
|
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
* See the License for the specific language governing permissions and
|
|
|
|
* limitations under the License.
|
|
|
|
*/
|
|
|
|
|
|
|
|
import {TimeoutError} from '../common/Errors.js';
|
|
|
|
import {EventEmitter} from '../common/EventEmitter.js';
|
|
|
|
import {debugError} from '../common/util.js';
|
|
|
|
import {isErrorLike} from '../util/ErrorLike.js';
|
|
|
|
|
|
|
|
import {ElementHandle, BoundingBox, ClickOptions} from './ElementHandle.js';
|
|
|
|
import type {Page} from './Page.js';
|
|
|
|
|
2023-05-30 13:20:00 +00:00
|
|
|
type VisibilityOption = 'hidden' | 'visible' | null;
|
|
|
|
|
2023-05-05 09:11:16 +00:00
|
|
|
/**
|
|
|
|
* @internal
|
|
|
|
*/
|
|
|
|
export interface LocatorOptions {
|
|
|
|
/**
|
2023-05-30 13:20:00 +00:00
|
|
|
* Whether to wait for the element to be `visible` or `hidden`. `null` to
|
|
|
|
* disable visibility checks.
|
2023-05-05 09:11:16 +00:00
|
|
|
*/
|
2023-05-30 13:20:00 +00:00
|
|
|
visibility: VisibilityOption;
|
2023-05-05 09:11:16 +00:00
|
|
|
/**
|
|
|
|
* Total timeout for the entire locator operation.
|
2023-05-30 13:20:00 +00:00
|
|
|
*
|
|
|
|
* Pass `0` to disable timeout.
|
|
|
|
*
|
|
|
|
* @defaultValue `Page.getDefaultTimeout()`
|
2023-05-05 09:11:16 +00:00
|
|
|
*/
|
|
|
|
timeout: number;
|
2023-05-30 13:20:00 +00:00
|
|
|
/**
|
|
|
|
* Whether to scroll the element into viewport if not in the viewprot already.
|
|
|
|
* @defaultValue `true`
|
|
|
|
*/
|
|
|
|
ensureElementIsInTheViewport: boolean;
|
|
|
|
/**
|
|
|
|
* Whether to wait for input elements to become enabled before the action.
|
|
|
|
* Applicable to `click` and `fill` actions.
|
|
|
|
* @defaultValue `true`
|
|
|
|
*/
|
|
|
|
waitForEnabled: boolean;
|
|
|
|
/**
|
|
|
|
* Whether to wait for the element's bounding box to be same between two
|
|
|
|
* animation frames.
|
|
|
|
* @defaultValue `true`
|
|
|
|
*/
|
|
|
|
waitForStableBoundingBox: boolean;
|
2023-05-05 09:11:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Timeout for individual operations inside the locator. On errors the
|
|
|
|
* operation is retried as long as {@link LocatorOptions.timeout} is not
|
|
|
|
* exceeded. This timeout should be generally much lower as locating an
|
|
|
|
* element means multiple asynchronious operations.
|
|
|
|
*/
|
|
|
|
const CONDITION_TIMEOUT = 1_000;
|
|
|
|
const WAIT_FOR_FUNCTION_DELAY = 100;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @internal
|
|
|
|
*/
|
|
|
|
type ActionCondition = (
|
|
|
|
element: ElementHandle,
|
|
|
|
signal: AbortSignal
|
|
|
|
) => Promise<void>;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @internal
|
|
|
|
*/
|
|
|
|
export interface ActionOptions {
|
|
|
|
signal?: AbortSignal;
|
|
|
|
conditions: ActionCondition[];
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* All the events that a locator instance may emit.
|
|
|
|
*
|
|
|
|
* @internal
|
|
|
|
*/
|
|
|
|
export enum LocatorEmittedEvents {
|
|
|
|
/**
|
|
|
|
* Emitted every time before the locator performs an action on the located element(s).
|
|
|
|
*/
|
|
|
|
Action = 'action',
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @internal
|
|
|
|
*/
|
|
|
|
export interface LocatorEventObject {
|
|
|
|
[LocatorEmittedEvents.Action]: never;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Locators describe a strategy of locating elements and performing an action on
|
|
|
|
* them. If the action fails because the element are not ready for the action,
|
|
|
|
* the whole operation is retried.
|
|
|
|
*
|
|
|
|
* @internal
|
|
|
|
*/
|
|
|
|
export class Locator extends EventEmitter {
|
|
|
|
#page: Page;
|
|
|
|
#selector: string;
|
|
|
|
#options: LocatorOptions;
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
page: Page,
|
|
|
|
selector: string,
|
|
|
|
options: LocatorOptions = {
|
|
|
|
visibility: 'visible',
|
|
|
|
timeout: page.getDefaultTimeout(),
|
2023-05-30 13:20:00 +00:00
|
|
|
ensureElementIsInTheViewport: true,
|
|
|
|
waitForEnabled: true,
|
|
|
|
waitForStableBoundingBox: true,
|
2023-05-05 09:11:16 +00:00
|
|
|
}
|
|
|
|
) {
|
|
|
|
super();
|
|
|
|
this.#page = page;
|
|
|
|
this.#selector = selector;
|
|
|
|
this.#options = options;
|
|
|
|
}
|
|
|
|
|
|
|
|
override on<K extends keyof LocatorEventObject>(
|
|
|
|
eventName: K,
|
|
|
|
handler: (event: LocatorEventObject[K]) => void
|
2023-05-24 15:57:56 +00:00
|
|
|
): this {
|
|
|
|
return super.on(eventName, handler);
|
2023-05-05 09:11:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
override once<K extends keyof LocatorEventObject>(
|
|
|
|
eventName: K,
|
|
|
|
handler: (event: LocatorEventObject[K]) => void
|
2023-05-24 15:57:56 +00:00
|
|
|
): this {
|
|
|
|
return super.once(eventName, handler);
|
2023-05-05 09:11:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
override off<K extends keyof LocatorEventObject>(
|
|
|
|
eventName: K,
|
|
|
|
handler: (event: LocatorEventObject[K]) => void
|
2023-05-24 15:57:56 +00:00
|
|
|
): this {
|
|
|
|
return super.off(eventName, handler);
|
2023-05-05 09:11:16 +00:00
|
|
|
}
|
|
|
|
|
2023-05-30 13:20:00 +00:00
|
|
|
setVisibility(visibility: VisibilityOption): this {
|
|
|
|
this.#options.visibility = visibility;
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
setTimeout(timeout: number): this {
|
|
|
|
this.#options.timeout = timeout;
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
setEnsureElementIsInTheViewport(value: boolean): this {
|
|
|
|
this.#options.ensureElementIsInTheViewport = value;
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
setWaitForEnabled(value: boolean): this {
|
|
|
|
this.#options.waitForEnabled = value;
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
setWaitForStableBoundingBox(value: boolean): this {
|
|
|
|
this.#options.waitForStableBoundingBox = value;
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
2023-05-05 09:11:16 +00:00
|
|
|
/**
|
|
|
|
* Retries the `fn` until a truthy result is returned.
|
|
|
|
*/
|
|
|
|
async #waitForFunction(
|
|
|
|
fn: (signal: AbortSignal) => unknown,
|
|
|
|
signal?: AbortSignal,
|
|
|
|
timeout = CONDITION_TIMEOUT
|
|
|
|
): Promise<void> {
|
|
|
|
let isActive = true;
|
|
|
|
let controller: AbortController;
|
|
|
|
// If the loop times out, we abort only the last iteration's controller.
|
2023-05-30 13:20:00 +00:00
|
|
|
const timeoutId = timeout
|
|
|
|
? setTimeout(() => {
|
|
|
|
isActive = false;
|
|
|
|
controller?.abort();
|
|
|
|
}, timeout)
|
|
|
|
: 0;
|
2023-05-05 09:11:16 +00:00
|
|
|
// If the user's signal aborts, we abort the last iteration and the loop.
|
|
|
|
signal?.addEventListener(
|
|
|
|
'abort',
|
|
|
|
() => {
|
|
|
|
controller?.abort();
|
|
|
|
isActive = false;
|
|
|
|
},
|
|
|
|
{once: true}
|
|
|
|
);
|
|
|
|
while (isActive) {
|
|
|
|
controller = new AbortController();
|
|
|
|
try {
|
|
|
|
const result = await fn(controller.signal);
|
|
|
|
if (result) {
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
} catch (err) {
|
|
|
|
if (isErrorLike(err)) {
|
|
|
|
debugError(err);
|
|
|
|
// Retry on all timeouts.
|
|
|
|
if (err instanceof TimeoutError) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
// Abort error are ignored as they only affect one iteration.
|
|
|
|
if (err.name === 'AbortError') {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
throw err;
|
|
|
|
} finally {
|
|
|
|
// We abort any operations that might have been started by `fn`, because
|
|
|
|
// the iteration is now over.
|
|
|
|
controller.abort();
|
|
|
|
}
|
|
|
|
await new Promise(resolve => {
|
|
|
|
return setTimeout(resolve, WAIT_FOR_FUNCTION_DELAY);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
signal?.throwIfAborted();
|
|
|
|
throw new TimeoutError(
|
|
|
|
`waitForFunction timed out. The timeout is ${timeout}ms.`
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Checks if the element is in the viewport and auto-scrolls it if it is not.
|
|
|
|
*/
|
|
|
|
#ensureElementIsInTheViewport = async (
|
|
|
|
element: ElementHandle,
|
|
|
|
signal?: AbortSignal
|
|
|
|
): Promise<void> => {
|
2023-05-30 13:20:00 +00:00
|
|
|
if (!this.#options.ensureElementIsInTheViewport) {
|
|
|
|
return;
|
|
|
|
}
|
2023-05-05 09:11:16 +00:00
|
|
|
// Side-effect: this also checks if it is connected.
|
|
|
|
const isIntersectingViewport = await element.isIntersectingViewport({
|
|
|
|
threshold: 0,
|
|
|
|
});
|
|
|
|
signal?.throwIfAborted();
|
|
|
|
if (!isIntersectingViewport) {
|
|
|
|
await element.scrollIntoView();
|
|
|
|
signal?.throwIfAborted();
|
|
|
|
await this.#waitForFunction(async () => {
|
|
|
|
return await element.isIntersectingViewport({
|
|
|
|
threshold: 0,
|
|
|
|
});
|
|
|
|
}, signal);
|
|
|
|
signal?.throwIfAborted();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Waits for the element to become visible or hidden. visibility === 'visible'
|
|
|
|
* means that the element has a computed style, the visibility property other
|
|
|
|
* than 'hidden' or 'collapse' and non-empty bounding box. visibility ===
|
|
|
|
* 'hidden' means the opposite of that.
|
|
|
|
*/
|
|
|
|
#waitForVisibility = async (
|
|
|
|
element: ElementHandle,
|
|
|
|
signal?: AbortSignal
|
|
|
|
): Promise<void> => {
|
2023-05-30 13:20:00 +00:00
|
|
|
if (this.#options.visibility === null) {
|
|
|
|
return;
|
|
|
|
}
|
2023-05-05 09:11:16 +00:00
|
|
|
if (this.#options.visibility === 'hidden') {
|
|
|
|
await this.#waitForFunction(async () => {
|
|
|
|
return element.isHidden();
|
|
|
|
}, signal);
|
|
|
|
}
|
|
|
|
await this.#waitForFunction(async () => {
|
|
|
|
return element.isVisible();
|
|
|
|
}, signal);
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* If the element is a button, textarea, input or select, wait till the
|
|
|
|
* element becomes enabled.
|
|
|
|
*/
|
|
|
|
#waitForEnabled = async (
|
|
|
|
element: ElementHandle,
|
|
|
|
signal?: AbortSignal
|
|
|
|
): Promise<void> => {
|
2023-05-30 13:20:00 +00:00
|
|
|
if (!this.#options.waitForEnabled) {
|
|
|
|
return;
|
|
|
|
}
|
2023-05-05 09:11:16 +00:00
|
|
|
await this.#page.waitForFunction(
|
|
|
|
el => {
|
|
|
|
if (['button', 'textarea', 'input', 'select'].includes(el.tagName)) {
|
|
|
|
return !(el as HTMLInputElement).disabled;
|
|
|
|
}
|
|
|
|
return true;
|
|
|
|
},
|
|
|
|
{
|
|
|
|
timeout: CONDITION_TIMEOUT,
|
|
|
|
signal,
|
|
|
|
},
|
|
|
|
element
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Compares the bounding box of the element for two consecutive animation
|
|
|
|
* frames and waits till they are the same.
|
|
|
|
*/
|
|
|
|
#waitForStableBoundingBox = async (
|
|
|
|
element: ElementHandle,
|
|
|
|
signal?: AbortSignal
|
|
|
|
): Promise<void> => {
|
2023-05-30 13:20:00 +00:00
|
|
|
if (!this.#options.waitForStableBoundingBox) {
|
|
|
|
return;
|
|
|
|
}
|
2023-05-05 09:11:16 +00:00
|
|
|
function getClientRect() {
|
|
|
|
return element.evaluate(el => {
|
|
|
|
return new Promise<[BoundingBox, BoundingBox]>(resolve => {
|
|
|
|
window.requestAnimationFrame(() => {
|
|
|
|
const rect1 = el.getBoundingClientRect();
|
|
|
|
window.requestAnimationFrame(() => {
|
|
|
|
const rect2 = el.getBoundingClientRect();
|
|
|
|
resolve([
|
|
|
|
{
|
|
|
|
x: rect1.x,
|
|
|
|
y: rect1.y,
|
|
|
|
width: rect1.width,
|
|
|
|
height: rect1.height,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
x: rect2.x,
|
|
|
|
y: rect2.y,
|
|
|
|
width: rect2.width,
|
|
|
|
height: rect2.height,
|
|
|
|
},
|
|
|
|
]);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
await this.#waitForFunction(async () => {
|
|
|
|
const [rect1, rect2] = await getClientRect();
|
|
|
|
return (
|
|
|
|
rect1.x === rect2.x &&
|
|
|
|
rect1.y === rect2.y &&
|
|
|
|
rect1.width === rect2.width &&
|
|
|
|
rect1.height === rect2.height
|
|
|
|
);
|
|
|
|
}, signal);
|
|
|
|
};
|
|
|
|
|
|
|
|
async #run(
|
|
|
|
action: (el: ElementHandle) => Promise<void>,
|
|
|
|
options?: ActionOptions
|
|
|
|
) {
|
|
|
|
await this.#waitForFunction(
|
|
|
|
async signal => {
|
|
|
|
// 1. Select the element without visibility checks.
|
|
|
|
const element = await this.#page.waitForSelector(this.#selector, {
|
|
|
|
visible: false,
|
|
|
|
timeout: this.#options.timeout,
|
|
|
|
signal,
|
|
|
|
});
|
|
|
|
// Retry if no element is found.
|
|
|
|
if (!element) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
try {
|
|
|
|
signal?.throwIfAborted();
|
|
|
|
// 2. Perform action specific checks.
|
|
|
|
await Promise.all(
|
|
|
|
options?.conditions.map(check => {
|
|
|
|
return check(element, signal);
|
|
|
|
}) || []
|
|
|
|
);
|
|
|
|
signal?.throwIfAborted();
|
|
|
|
// 3. Perform the action
|
|
|
|
this.emit(LocatorEmittedEvents.Action);
|
|
|
|
await action(element);
|
|
|
|
return true;
|
|
|
|
} finally {
|
|
|
|
void element.dispose().catch(debugError);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
options?.signal,
|
|
|
|
this.#options.timeout
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
async click(
|
|
|
|
clickOptions?: ClickOptions & {
|
|
|
|
signal?: AbortSignal;
|
|
|
|
}
|
|
|
|
): Promise<void> {
|
2023-05-22 09:57:51 +00:00
|
|
|
return await this.#run(
|
2023-05-05 09:11:16 +00:00
|
|
|
async element => {
|
|
|
|
await element.click(clickOptions);
|
|
|
|
},
|
|
|
|
{
|
|
|
|
signal: clickOptions?.signal,
|
|
|
|
conditions: [
|
|
|
|
this.#ensureElementIsInTheViewport,
|
|
|
|
this.#waitForVisibility,
|
|
|
|
this.#waitForEnabled,
|
|
|
|
this.#waitForStableBoundingBox,
|
|
|
|
],
|
|
|
|
}
|
|
|
|
);
|
|
|
|
}
|
2023-05-22 09:57:51 +00:00
|
|
|
|
2023-05-23 11:55:12 +00:00
|
|
|
/**
|
|
|
|
* Fills out the input identified by the locator using the provided value. The
|
|
|
|
* type of the input is determined at runtime and the appropriate fill-out
|
|
|
|
* method is chosen based on the type. contenteditable, selector, inputs are
|
|
|
|
* supported.
|
|
|
|
*/
|
|
|
|
async fill(
|
|
|
|
value: string,
|
|
|
|
fillOptions?: {signal?: AbortSignal}
|
|
|
|
): Promise<void> {
|
|
|
|
return await this.#run(
|
|
|
|
async element => {
|
|
|
|
const input = element as ElementHandle<HTMLElement>;
|
|
|
|
const inputType = await input.evaluate(el => {
|
|
|
|
if (el instanceof HTMLSelectElement) {
|
|
|
|
return 'select';
|
|
|
|
}
|
|
|
|
if (el instanceof HTMLInputElement) {
|
|
|
|
if (
|
|
|
|
new Set([
|
|
|
|
'textarea',
|
|
|
|
'text',
|
|
|
|
'url',
|
|
|
|
'tel',
|
|
|
|
'search',
|
|
|
|
'password',
|
|
|
|
'number',
|
|
|
|
'email',
|
|
|
|
]).has(el.type)
|
|
|
|
) {
|
|
|
|
return 'typeable-input';
|
|
|
|
} else {
|
|
|
|
return 'other-input';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (el.isContentEditable) {
|
|
|
|
return 'contenteditable';
|
|
|
|
}
|
|
|
|
|
|
|
|
return 'unknown';
|
|
|
|
});
|
|
|
|
|
|
|
|
switch (inputType) {
|
|
|
|
case 'select':
|
|
|
|
await input.select(value);
|
|
|
|
break;
|
|
|
|
case 'contenteditable':
|
|
|
|
case 'typeable-input':
|
|
|
|
const textToType = await (
|
|
|
|
input as ElementHandle<HTMLInputElement>
|
|
|
|
).evaluate((input, newValue) => {
|
|
|
|
const currentValue = input.isContentEditable
|
|
|
|
? input.innerText
|
|
|
|
: input.value;
|
|
|
|
|
|
|
|
// Clear the input if the current value does not match the filled
|
|
|
|
// out value.
|
|
|
|
if (
|
|
|
|
newValue.length <= currentValue.length ||
|
|
|
|
!newValue.startsWith(input.value)
|
|
|
|
) {
|
|
|
|
if (input.isContentEditable) {
|
|
|
|
input.innerText = '';
|
|
|
|
} else {
|
|
|
|
input.value = '';
|
|
|
|
}
|
|
|
|
return newValue;
|
|
|
|
}
|
|
|
|
const originalValue = input.isContentEditable
|
|
|
|
? input.innerText
|
|
|
|
: input.value;
|
|
|
|
|
|
|
|
// If the value is partially filled out, only type the rest. Move
|
|
|
|
// cursor to the end of the common prefix.
|
|
|
|
if (input.isContentEditable) {
|
|
|
|
input.innerText = '';
|
|
|
|
input.innerText = originalValue;
|
|
|
|
} else {
|
|
|
|
input.value = '';
|
|
|
|
input.value = originalValue;
|
|
|
|
}
|
|
|
|
return newValue.substring(originalValue.length);
|
|
|
|
}, value);
|
|
|
|
await input.type(textToType);
|
|
|
|
break;
|
|
|
|
case 'other-input':
|
|
|
|
await input.focus();
|
|
|
|
await input.evaluate((input, value) => {
|
|
|
|
(input as HTMLInputElement).value = value;
|
|
|
|
input.dispatchEvent(new Event('input', {bubbles: true}));
|
|
|
|
input.dispatchEvent(new Event('change', {bubbles: true}));
|
|
|
|
}, value);
|
|
|
|
break;
|
|
|
|
case 'unknown':
|
|
|
|
throw new Error(`Element cannot be filled out.`);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
{
|
|
|
|
signal: fillOptions?.signal,
|
|
|
|
conditions: [
|
|
|
|
this.#ensureElementIsInTheViewport,
|
|
|
|
this.#waitForVisibility,
|
|
|
|
this.#waitForEnabled,
|
|
|
|
this.#waitForStableBoundingBox,
|
|
|
|
],
|
|
|
|
}
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-05-22 09:57:51 +00:00
|
|
|
async hover(hoverOptions?: {signal?: AbortSignal}): Promise<void> {
|
|
|
|
return await this.#run(
|
|
|
|
async element => {
|
|
|
|
await element.hover();
|
|
|
|
},
|
|
|
|
{
|
|
|
|
signal: hoverOptions?.signal,
|
|
|
|
conditions: [
|
|
|
|
this.#ensureElementIsInTheViewport,
|
|
|
|
this.#waitForVisibility,
|
|
|
|
this.#waitForStableBoundingBox,
|
|
|
|
],
|
|
|
|
}
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
async scroll(scrollOptions?: {
|
|
|
|
scrollTop?: number;
|
|
|
|
scrollLeft?: number;
|
|
|
|
signal?: AbortSignal;
|
|
|
|
}): Promise<void> {
|
|
|
|
return await this.#run(
|
|
|
|
async element => {
|
|
|
|
await element.evaluate(
|
|
|
|
(el, scrollTop, scrollLeft) => {
|
|
|
|
if (scrollTop !== undefined) {
|
|
|
|
el.scrollTop = scrollTop;
|
|
|
|
}
|
|
|
|
if (scrollLeft !== undefined) {
|
|
|
|
el.scrollLeft = scrollLeft;
|
|
|
|
}
|
|
|
|
},
|
|
|
|
scrollOptions?.scrollTop,
|
|
|
|
scrollOptions?.scrollLeft
|
|
|
|
);
|
|
|
|
},
|
|
|
|
{
|
|
|
|
signal: scrollOptions?.signal,
|
|
|
|
conditions: [
|
|
|
|
this.#ensureElementIsInTheViewport,
|
|
|
|
this.#waitForVisibility,
|
|
|
|
this.#waitForStableBoundingBox,
|
|
|
|
],
|
|
|
|
}
|
|
|
|
);
|
|
|
|
}
|
2023-05-05 09:11:16 +00:00
|
|
|
}
|