import logger from '@wdio/logger' import { ELEMENT_KEY } from 'webdriver' import { getBrowserObject } from '@wdio/utils' import type { ChainablePromiseElement, CustomScrollIntoViewOptions, MobileScrollIntoViewOptions } from '../../types.js' import { MobileScrollDirection } from '../../types.js' const log = logger('webdriverio') /** * * Scroll element into viewport for Desktop/Mobile Web AND Mobile Native Apps. * * :::info * * Scrolling for Mobile Native Apps is done based on the mobile `swipe` command. * * ::: * * :desktop.mobile.web.scrollIntoView.js it('should demonstrate the desktop/mobile web scrollIntoView command', async () => { const elem = await $('#myElement'); // scroll to specific element await elem.scrollIntoView(); // center element within the viewport await elem.scrollIntoView({ block: 'center', inline: 'center' }); }); * * * :mobile.native.app.scrollIntoView.js it('should demonstrate the mobile native app scrollIntoView command', async () => { const elem = await $('#myElement'); // scroll to a specific element in the default scrollable element for Android or iOS for a maximum of 10 scrolls await elem.scrollIntoView(); // Scroll to the left in the scrollable element called '#scrollable' for a maximum of 5 scrolls await elem.scrollIntoView({ direction: 'left', maxScrolls: 5, scrollableElement: $('#scrollable') }); }); * * * @alias element.scrollIntoView * @param {object|boolean=} options options for `Element.scrollIntoView()`. Default for desktop/mobile web:
`{ block: 'start', inline: 'nearest' }`
Default for Mobile Native App
`{ maxScrolls: 10, scrollDirection: 'down' }` * @rowInfo Desktop/Mobile Web Only * @param {string=} options.behavior See [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView).
WEB-ONLY (Desktop/Mobile) * @param {string=} options.block See [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView).
WEB-ONLY (Desktop/Mobile) * @param {string=} options.inline See [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView).
WEB-ONLY (Desktop/Mobile) * @rowInfo Mobile Native App Only * @param {string=} options.direction Can be one of `down`, `up`, `left` or `right`, default is `up`.
MOBILE-NATIVE-APP-ONLY * @param {number=} options.maxScrolls The max amount of scrolls until it will stop searching for the element, default is `10`.
MOBILE-NATIVE-APP-ONLY * @param {number=} options.duration The duration in milliseconds for the swipe. Default is `1500` ms. The lower the value, the faster the swipe.
MOBILE-NATIVE-APP-ONLY * @param {Element=} options.scrollableElement Element that is used to scroll within. If no element is provided it will use the following selector for iOS `-ios predicate string:type == "XCUIElementTypeApplication"` and the following for Android `//android.widget.ScrollView'`. If more elements match the default selector, then by default it will pick the first matching element.
MOBILE-NATIVE-APP-ONLY * @param {number=} options.percent The percentage of the (default) scrollable element to swipe. This is a value between 0 and 1. Default is `0.95`.
NEVER swipe from the exact top|bottom|left|right of the screen, you might trigger for example the notification bar or other OS/App features which can lead to unexpected results.
MOBILE-NATIVE-APP-ONLY * @uses protocol/execute * @type utility * */ export async function scrollIntoView ( this: WebdriverIO.Element, options: CustomScrollIntoViewOptions | boolean = { block: 'start', inline: 'nearest' } ): Promise { const browser = getBrowserObject(this) /** * Appium does not support the "wheel" action */ if (browser.isMobile) { if (await browser.isNativeContext) { return nativeMobileScrollIntoView({ browser, element: this, options: (options as CustomScrollIntoViewOptions) || {} }) } return scrollIntoViewWeb.call(this, options) } try { /** * by default the WebDriver action scrolls the element just into the * viewport. In order to stay complaint with `Element.scrollIntoView()` * we need to adjust the values a bit. */ const elemRect = await browser.getElementRect(this.elementId) const viewport = await browser.getWindowSize() let [scrollX, scrollY] = await browser.execute(() => [ window.scrollX, window.scrollY ]) // handle elements outside of the viewport scrollX = elemRect.x <= viewport.width ? elemRect.x : viewport.width / 2 scrollY = elemRect.y <= viewport.height ? elemRect.y : viewport.height / 2 const deltaByOption = { start: { y: elemRect.y - elemRect.height, x: elemRect.x - elemRect.width }, center: { y: elemRect.y - Math.round((viewport.height - elemRect.height) / 2), x: elemRect.x - Math.round((viewport.width - elemRect.width) / 2) }, end: { y: elemRect.y - (viewport.height - elemRect.height), x: elemRect.x - (viewport.width - elemRect.width) } } let [deltaX, deltaY] = [deltaByOption.start.x, deltaByOption.start.y] if (options === true) { options = { block: 'start', inline: 'nearest' } } if (options === false) { options = { block: 'end', inline: 'nearest' } } if (options && typeof options === 'object') { const { block, inline } = options if (block === 'nearest') { const nearestYDistance = Math.min(...Object.values(deltaByOption).map(delta => delta.y)) deltaY = Object.values(deltaByOption).find(delta => delta.y === nearestYDistance)!.y } else if (block) { deltaY = deltaByOption[block].y } if (inline === 'nearest') { const nearestXDistance = Math.min(...Object.values(deltaByOption).map(delta => delta.x)) deltaX = Object.values(deltaByOption).find(delta => delta.x === nearestXDistance)!.x } else if (inline) { deltaX = deltaByOption[inline].x } } // take into account the current scroll position deltaX = Math.round(deltaX - scrollX) deltaY = Math.round(deltaY - scrollY) await browser.action('wheel') .scroll({ duration: 0, x: deltaX, y: deltaY, origin: this }) .perform() } catch (err) { log.warn( `Failed to execute "scrollIntoView" using WebDriver Actions API: ${(err as Error).message}!\n` + 'Re-attempting using `Element.scrollIntoView` via Web API.' ) await scrollIntoViewWeb.call(this, options) } } type MobileScrollUntilVisibleOptions = { browser: WebdriverIO.Browser; element: WebdriverIO.Element; maxScrolls: number; direction: `${MobileScrollDirection}`; scrollableElement?: WebdriverIO.Element | ChainablePromiseElement | null; duration?: number; percent?: number; } async function mobileScrollUntilVisible({ browser, direction, duration, element, maxScrolls, percent, scrollableElement, }: MobileScrollUntilVisibleOptions): Promise<{ hasScrolled: boolean; isVisible: boolean; }> { let isVisible = false let hasScrolled = false let scrolls = 0 while (!isVisible && scrolls < maxScrolls) { try { isVisible = await element.isDisplayed() } catch { isVisible = false } if (isVisible) {break} await browser.swipe({ direction, ...(duration ? { duration } : {}), ...(percent ? { percent } : {}), ...(scrollableElement ? { scrollableElement } : {}), }) hasScrolled = true scrolls++ } return { hasScrolled, isVisible } } async function nativeMobileScrollIntoView({ browser, element, options }: { browser: WebdriverIO.Browser, element: WebdriverIO.Element, options: MobileScrollIntoViewOptions }) { const defaultOptions = { maxScrolls: 10, direction: MobileScrollDirection.Up, } const mobileOptions = { ...defaultOptions, ...(options || {}), } const { hasScrolled, isVisible } = await mobileScrollUntilVisible({ browser, element, maxScrolls: mobileOptions.maxScrolls, direction: mobileOptions.direction, ...(mobileOptions?.duration ? { duration: mobileOptions.duration } : {}), ...(mobileOptions?.percent ? { percent: mobileOptions.percent } : {}), ...(mobileOptions?.scrollableElement ? { scrollableElement: mobileOptions.scrollableElement } : {}), }) if (hasScrolled && isVisible) { // Pause for stabilization // eslint-disable-next-line wdio/no-pause return browser.pause(1000) } else if (isVisible) { // Element is already visible return } throw new Error(`Element not found within scroll limit of ${mobileOptions.maxScrolls} scrolls by scrolling "${mobileOptions.direction}". ` + `Are you sure the element is within the scrollable element or the direction is correct? You can change the scrollable element or direction like this: await elem.scrollIntoView({ direction: 'left' // possible options are: 'up|down|left|right' scrollableElement: $('#scrollable'), }); `) } function scrollIntoViewWeb ( this: WebdriverIO.Element, options: ScrollIntoViewOptions | boolean = { block: 'start', inline: 'nearest' } ) { const browser = getBrowserObject(this) return browser.execute( (elem: HTMLElement, options: ScrollIntoViewOptions | boolean) => elem.scrollIntoView(options), { [ELEMENT_KEY]: this.elementId, // w3c compatible ELEMENT: this.elementId, // jsonwp compatible } as unknown as HTMLElement, options, ) }