diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 3f8d3b536d..5e709e1759 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -2991,7 +2991,7 @@ export namespace Components { */ "checkEnd": () => Promise; /** - * This method marks a subset of items as dirty, so they can be re-rendered. Items should be marked as dirty any time the content or their style changes. The subset of items to be updated can are specifing by an offset and a length. + * This method marks a subset of items as dirty, so they can be re-rendered. Items should be marked as dirty any time the content or their style changes. The subset of items to be updated can are specifying by an offset and a length. */ "checkRange": (offset: number, len?: number) => Promise; "domRender"?: DomRenderFn; diff --git a/core/src/components/footer/footer.tsx b/core/src/components/footer/footer.tsx index 7719bd4718..5366e0762b 100644 --- a/core/src/components/footer/footer.tsx +++ b/core/src/components/footer/footer.tsx @@ -1,7 +1,7 @@ import { Component, ComponentInterface, Element, Host, Prop, h } from '@stencil/core'; +import { findIonContent, getScrollElement, printIonContentErrorMsg } from '@utils/content'; import { getIonMode } from '../../global/ionic-global'; -import { componentOnReady } from '../../utils/helpers'; import { handleFooterFade } from './footer.utils'; @@ -56,17 +56,19 @@ export class Footer implements ComponentInterface { if (hasFade) { const pageEl = this.el.closest('ion-app,ion-page,.ion-page,page-inner'); - const contentEl = (pageEl) ? pageEl.querySelector('ion-content') : null; + const contentEl = (pageEl) ? findIonContent(pageEl) : null; + + if (!contentEl) { + printIonContentErrorMsg(this.el); + return; + } this.setupFadeFooter(contentEl); } } - private setupFadeFooter = async (contentEl: HTMLIonContentElement | null) => { - if (!contentEl) { console.error('ion-footer requires a content to collapse. Make sure there is an ion-content.'); return; } - - await new Promise(resolve => componentOnReady(contentEl, resolve)); - const scrollEl = this.scrollEl = await contentEl.getScrollElement(); + private setupFadeFooter = async (contentEl: HTMLElement) => { + const scrollEl = this.scrollEl = await getScrollElement(contentEl); /** * Handle fading of toolbars on scroll @@ -102,7 +104,7 @@ export class Footer implements ComponentInterface { [`footer-collapse-${collapse}`]: collapse !== undefined, }} > - { mode === 'ios' && translucent && + {mode === 'ios' && translucent && } diff --git a/core/src/components/footer/readme.md b/core/src/components/footer/readme.md index 3525b110c5..5e1864d336 100644 --- a/core/src/components/footer/readme.md +++ b/core/src/components/footer/readme.md @@ -7,6 +7,23 @@ Footer can be a wrapper for ion-toolbar to make sure the content area is sized c The `collapse` property can be set to `'fade'` on a page's `ion-footer` to have the background color of the toolbars fade in as users scroll. This provides the same fade effect that is found in many native iOS applications. +### Usage with Virtual Scroll + +Fade footer requires a scroll container to function. When using a virtual scrolling solution, you will need to disable scrolling on the `ion-content` and indicate which element container is responsible for the scroll container with the `.ion-content-scroll-host` class target. + +```html + + + + + + + + Footer + + +``` + diff --git a/core/src/components/footer/test/scroll-target/e2e.ts b/core/src/components/footer/test/scroll-target/e2e.ts new file mode 100644 index 0000000000..27b17a28b3 --- /dev/null +++ b/core/src/components/footer/test/scroll-target/e2e.ts @@ -0,0 +1,35 @@ +import type { E2EPage } from '@stencil/core/testing'; +import { newE2EPage } from '@stencil/core/testing'; + +import { scrollToBottom } from '@utils/test'; + +/** + * This test suite verifies that the fade effect for iOS is working correctly + * when the `ion-footer` is using a custom scroll target with the `.ion-content-scroll-host` + * selector. + */ +describe('footer: fade with custom scroll target: iOS', () => { + + let page: E2EPage; + + beforeEach(async () => { + page = await newE2EPage({ + url: '/src/components/footer/test/scroll-target?ionic:_testing=true&ionic:mode=ios' + }); + }); + + it('should match existing visual screenshots', async () => { + const compares = []; + + compares.push(await page.compareScreenshot('footer: blurred')); + + await scrollToBottom(page, '#scroll-target'); + + compares.push(await page.compareScreenshot('footer: not blurred')); + + for (const compare of compares) { + expect(compare).toMatchScreenshot(); + } + }); + +}); diff --git a/core/src/components/footer/test/scroll-target/index.html b/core/src/components/footer/test/scroll-target/index.html new file mode 100644 index 0000000000..9331f31852 --- /dev/null +++ b/core/src/components/footer/test/scroll-target/index.html @@ -0,0 +1,102 @@ + + + + + + Footer - Fade (custom scroll host) + + + + + + + + + + + +
+ + + Mailboxes + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+ + + Updated Just Now + + +
+
+ + + diff --git a/core/src/components/header/header.tsx b/core/src/components/header/header.tsx index 9f09803f9d..f0b0fea2e4 100644 --- a/core/src/components/header/header.tsx +++ b/core/src/components/header/header.tsx @@ -1,7 +1,8 @@ import { Component, ComponentInterface, Element, Host, Prop, h, writeTask } from '@stencil/core'; +import { findIonContent, getScrollElement, printIonContentErrorMsg } from '@utils/content'; import { getIonMode } from '../../global/ionic-global'; -import { Attributes, componentOnReady, inheritAttributes } from '../../utils/helpers'; +import { Attributes, inheritAttributes } from '../../utils/helpers'; import { hostContext } from '../../utils/theme'; import { cloneElement, createHeaderIndex, handleContentScroll, handleHeaderFade, handleToolbarIntersection, setHeaderActive, setToolbarBackgroundOpacity } from './header.utils'; @@ -72,7 +73,8 @@ export class Header implements ComponentInterface { if (hasCondense) { const pageEl = this.el.closest('ion-app,ion-page,.ion-page,page-inner'); - const contentEl = (pageEl) ? pageEl.querySelector('ion-content') : null; + + const contentEl = (pageEl) ? findIonContent(pageEl) : null; // Cloned elements are always needed in iOS transition writeTask(() => { @@ -84,22 +86,25 @@ export class Header implements ComponentInterface { await this.setupCondenseHeader(contentEl, pageEl); } else if (hasFade) { const pageEl = this.el.closest('ion-app,ion-page,.ion-page,page-inner'); - const contentEl = (pageEl) ? pageEl.querySelector('ion-content') : null; - const condenseHeader = (contentEl) ? contentEl.querySelector('ion-header[collapse="condense"]') as HTMLElement | null : null; + const contentEl = (pageEl) ? findIonContent(pageEl) : null; + + if (!contentEl) { + printIonContentErrorMsg(this.el); + return; + } + + const condenseHeader = contentEl.querySelector('ion-header[collapse="condense"]') as HTMLElement | null; + await this.setupFadeHeader(contentEl, condenseHeader); } } - private setupFadeHeader = async (contentEl: HTMLIonContentElement | null, condenseHeader: HTMLElement | null) => { - if (!contentEl) { console.error('ion-header requires a content to collapse. Make sure there is an ion-content.'); return; } - - await new Promise(resolve => componentOnReady(contentEl, resolve)); - const scrollEl = this.scrollEl = await contentEl.getScrollElement(); + private setupFadeHeader = async (contentEl: HTMLElement, condenseHeader: HTMLElement | null) => { + const scrollEl = this.scrollEl = await getScrollElement(contentEl); /** * Handle fading of toolbars on scroll */ - this.contentScrollCallback = () => { handleHeaderFade(this.scrollEl!, this.el, condenseHeader); }; scrollEl!.addEventListener('scroll', this.contentScrollCallback); @@ -123,12 +128,14 @@ export class Header implements ComponentInterface { } } - private async setupCondenseHeader(contentEl: HTMLIonContentElement | null, pageEl: Element | null) { - if (!contentEl || !pageEl) { console.error('ion-header requires a content to collapse, make sure there is an ion-content.'); return; } + private async setupCondenseHeader(contentEl: HTMLElement | null, pageEl: Element | null) { + if (!contentEl || !pageEl) { + printIonContentErrorMsg(this.el); + return; + } if (typeof (IntersectionObserver as any) === 'undefined') { return; } - await new Promise(resolve => componentOnReady(contentEl, resolve)); - this.scrollEl = await contentEl.getScrollElement(); + this.scrollEl = await getScrollElement(contentEl); const headers = pageEl.querySelectorAll('ion-header'); this.collapsibleMainHeader = Array.from(headers).find((header: any) => header.collapse !== 'condense') as HTMLElement | undefined; @@ -192,7 +199,7 @@ export class Header implements ComponentInterface { }} {...inheritedAttributes} > - { mode === 'ios' && translucent && + {mode === 'ios' && translucent &&
} diff --git a/core/src/components/header/readme.md b/core/src/components/header/readme.md index e436e312e4..23fcaa8bec 100644 --- a/core/src/components/header/readme.md +++ b/core/src/components/header/readme.md @@ -9,6 +9,27 @@ The `collapse` property can be set to `'fade'` on a page's main `ion-header` to This functionality can be combined with [Collapsible Large Titles](https://ionicframework.com/docs/api/title#collapsible-large-titles) as well. The `collapse="condense"` value should be set on the `ion-header` inside of your `ion-content`. The `collapse="fade"` value should be set on the `ion-header` outside of your `ion-content`. +### Usage with Virtual Scroll + +Fade and collapsible large titles require a scroll container to function. When using a virtual scrolling solution, you will need to disable scrolling on the `ion-content` and indicate which element container is responsible for the scroll container with the `.ion-content-scroll-host` class target. + +```html + + + Header + + + + + + Header + + + + + + +``` diff --git a/core/src/components/header/test/fade/index.html b/core/src/components/header/test/fade/index.html index b8264d4c29..e11149b273 100644 --- a/core/src/components/header/test/fade/index.html +++ b/core/src/components/header/test/fade/index.html @@ -1,89 +1,93 @@ - - - Header - Fade - - - - - - - - - - -
- + + + + +
+ + + Mailboxes + + + + - Mailboxes + Mailboxes - - - - Mailboxes - - -
-
-
-
-
-
-
-
-
-
-
- - - Updated Just Now - - -
-
- +
+
+
+
+
+
+
+
+
+
+ + + + Updated Just Now + + +
+
+ + diff --git a/core/src/components/header/test/scroll-target/e2e.ts b/core/src/components/header/test/scroll-target/e2e.ts new file mode 100644 index 0000000000..95bd5050db --- /dev/null +++ b/core/src/components/header/test/scroll-target/e2e.ts @@ -0,0 +1,59 @@ +import { newE2EPage } from '@stencil/core/testing'; +import type { E2EPage } from '@stencil/core/testing'; + +import { scrollToBottom } from '@utils/test'; + +describe('ion-header: custom scroll target', () => { + + let page: E2EPage; + + beforeEach(async () => { + page = await newE2EPage({ + url: '/src/components/header/test/scroll-target?ionic:_testing=true&ionic:mode=ios' + }); + }); + + it('should match existing visual screenshots', async () => { + const compare = await page.compareScreenshot(); + expect(compare).toMatchScreenshot(); + }); + + describe('large title', () => { + + it('should display the large title initially', async () => { + const largeHeader = await page.find('ion-header[collapse="condense"]'); + const collapseHeader = await page.find('ion-header[collapse="fade"]'); + + expect(largeHeader.className).not.toContain('header-collapse-condense-inactive'); + expect(collapseHeader.className).toContain('header-collapse-condense-inactive'); + }); + + describe('when the scroll container has overflow', () => { + + it('should display the collapsed title on scroll', async () => { + const screenshotCompares = []; + + screenshotCompares.push(await page.compareScreenshot('large title expanded')); + + const largeHeader = await page.find('ion-header[collapse="condense"]'); + const collapseHeader = await page.find('ion-header[collapse="fade"]'); + + await scrollToBottom(page, '#scroll-target'); + await page.waitForChanges(); + + expect(largeHeader.className).toContain('header-collapse-condense-inactive'); + expect(collapseHeader.className).not.toContain('header-collapse-condense-inactive'); + + screenshotCompares.push(await page.compareScreenshot('large title collapsed')); + + for (const screenshotCompare of screenshotCompares) { + expect(screenshotCompare).toMatchScreenshot(); + } + + }); + + }); + + }); + +}); diff --git a/core/src/components/header/test/scroll-target/index.html b/core/src/components/header/test/scroll-target/index.html new file mode 100644 index 0000000000..ef6d2238e5 --- /dev/null +++ b/core/src/components/header/test/scroll-target/index.html @@ -0,0 +1,107 @@ + + + + + + Header - Custom Scroll Target + + + + + + + + + + + +
+ + + Mailboxes + + + +
+ + + Mailboxes + + +
+
+
+
+
+
+
+
+
+
+
+
+ + + Updated Just Now + + +
+
+ + + diff --git a/core/src/components/infinite-scroll/infinite-scroll.tsx b/core/src/components/infinite-scroll/infinite-scroll.tsx index f9e6c5558a..f9c49f6739 100644 --- a/core/src/components/infinite-scroll/infinite-scroll.tsx +++ b/core/src/components/infinite-scroll/infinite-scroll.tsx @@ -1,7 +1,7 @@ import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, Watch, h, readTask, writeTask } from '@stencil/core'; +import { findClosestIonContent, getScrollElement, printIonContentErrorMsg } from '@utils/content'; import { getIonMode } from '../../global/ionic-global'; -import { componentOnReady } from '../../utils/helpers'; @Component({ tag: 'ion-infinite-scroll', @@ -78,13 +78,12 @@ export class InfiniteScroll implements ComponentInterface { @Event() ionInfinite!: EventEmitter; async connectedCallback() { - const contentEl = this.el.closest('ion-content'); + const contentEl = findClosestIonContent(this.el); if (!contentEl) { - console.error(' must be used inside an '); + printIonContentErrorMsg(this.el); return; } - await new Promise(resolve => componentOnReady(contentEl, resolve)); - this.scrollEl = await contentEl.getScrollElement(); + this.scrollEl = await getScrollElement(contentEl); this.thresholdChanged(); this.disabledChanged(); if (this.position === 'top') { diff --git a/core/src/components/infinite-scroll/readme.md b/core/src/components/infinite-scroll/readme.md index 6f7a35f30d..01ed533dc8 100644 --- a/core/src/components/infinite-scroll/readme.md +++ b/core/src/components/infinite-scroll/readme.md @@ -12,6 +12,21 @@ The `ion-infinite-scroll` component has the infinite scroll logic. It requires a Separating the `ion-infinite-scroll` and `ion-infinite-scroll-content` components allows developers to create their own content components, if desired. This content can contain anything, from an SVG element to elements with unique CSS animations. +## Usage with Virtual Scroll + +Infinite scroll requires a scroll container to function. When using a virtual scrolling solution, you will need to disable scrolling on the `ion-content` and indicate which element container is responsible for the scroll container with the `.ion-content-scroll-host` class target. + +```html + + + + + + + + +``` + ## Interfaces ### InfiniteScrollCustomEvent diff --git a/core/src/components/infinite-scroll/test/basic/index.html b/core/src/components/infinite-scroll/test/basic/index.html index 113f39a45c..32a8299930 100644 --- a/core/src/components/infinite-scroll/test/basic/index.html +++ b/core/src/components/infinite-scroll/test/basic/index.html @@ -16,7 +16,6 @@ - Infinite Scroll - Basic diff --git a/core/src/components/infinite-scroll/test/scroll-target/e2e.ts b/core/src/components/infinite-scroll/test/scroll-target/e2e.ts new file mode 100644 index 0000000000..0dbd9f9af9 --- /dev/null +++ b/core/src/components/infinite-scroll/test/scroll-target/e2e.ts @@ -0,0 +1,35 @@ +import { newE2EPage } from '@stencil/core/testing'; +import type { E2EPage } from '@stencil/core/testing'; + +import { scrollToBottom } from '@utils/test'; + +/** + * Scrolls an `ion-content` element to the bottom, triggering the `ionInfinite` event. + * Waits for the custom event to complete. + */ +async function scrollPage(page: E2EPage) { + await scrollToBottom(page, '#scroll-target'); + await page.waitForChanges(); + + const ev = await page.spyOnEvent('ionInfiniteComplete', 'document'); + await ev.next(); +} + +describe('infinite-scroll: custom scroll target', () => { + + it('should load more items when scrolled to the bottom', async () => { + const page = await newE2EPage({ + url: '/src/components/infinite-scroll/test/scroll-target?ionic:_testing=true' + }); + + const initialItems = await page.findAll('ion-item'); + expect(initialItems.length).toBe(30); + + await scrollPage(page); + + const items = await page.findAll('ion-item'); + + expect(items.length).toBe(60); + }); + +}); diff --git a/core/src/components/infinite-scroll/test/scroll-target/index.html b/core/src/components/infinite-scroll/test/scroll-target/index.html new file mode 100644 index 0000000000..3f5f3e21da --- /dev/null +++ b/core/src/components/infinite-scroll/test/scroll-target/index.html @@ -0,0 +1,80 @@ + + + + + + Infinite Scroll - Custom Scroll Target + + + + + + + + + + + + + + + + Infinite Scroll - Custom Scroll Target + + + + +
+ + + + + + + +
+
+ +
+ + + + + diff --git a/core/src/components/item-sliding/item-sliding.tsx b/core/src/components/item-sliding/item-sliding.tsx index f7bb3295e9..709fdc7f3e 100644 --- a/core/src/components/item-sliding/item-sliding.tsx +++ b/core/src/components/item-sliding/item-sliding.tsx @@ -188,12 +188,12 @@ export class ItemSliding implements ComponentInterface { return false; } - /** - * Given an optional side, return the ion-item-options element. - * - * @param side This side of the options to get. If a side is not provided it will - * return the first one available. - */ + /** + * Given an optional side, return the ion-item-options element. + * + * @param side This side of the options to get. If a side is not provided it will + * return the first one available. + */ private getOptions(side?: string): HTMLIonItemOptionsElement | undefined { if (side === undefined) { return this.leftOptions || this.rightOptions; diff --git a/core/src/components/refresher/readme.md b/core/src/components/refresher/readme.md index 65bdcd972d..f46d190fb1 100644 --- a/core/src/components/refresher/readme.md +++ b/core/src/components/refresher/readme.md @@ -24,6 +24,21 @@ The iOS native `ion-refresher` relies on rubber band scrolling in order to work Using the MD native `ion-refresher` requires setting the `pullingIcon` property on `ion-refresher-content` to the value of one of the available spinners. See the [ion-spinner Documentation](../spinner#properties) for accepted values. `pullingIcon` defaults to the `circular` spinner on MD. +### Virtual Scroll Usage + +Refresher requires a scroll container to function. When using a virtual scrolling solution, you will need to disable scrolling on the `ion-content` and indicate which element container is responsible for the scroll container with the `.ion-content-scroll-host` class target. + +```html + + + + + + + + +``` + ## Interfaces ### RefresherEventDetail diff --git a/core/src/components/refresher/refresher.tsx b/core/src/components/refresher/refresher.tsx index d31eabf929..3d84345969 100644 --- a/core/src/components/refresher/refresher.tsx +++ b/core/src/components/refresher/refresher.tsx @@ -1,9 +1,10 @@ import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, Watch, h, readTask, writeTask } from '@stencil/core'; +import { findClosestIonContent, getScrollElement, printIonContentErrorMsg } from '@utils/content'; import { getIonMode } from '../../global/ionic-global'; import { Animation, Gesture, GestureDetail, RefresherEventDetail } from '../../interface'; import { getTimeGivenProgression } from '../../utils/animation/cubic-bezier'; -import { clamp, componentOnReady, getElementRoot, raf, transitionEndAsync } from '../../utils/helpers'; +import { clamp, getElementRoot, raf, transitionEndAsync } from '../../utils/helpers'; import { hapticImpact } from '../../utils/native/haptic'; import { @@ -253,50 +254,50 @@ export class Refresher implements ComponentInterface { this.scrollEl!.addEventListener('scroll', this.scrollListenerCallback); this.gesture = (await import('../../utils/gesture')).createGesture({ - el: this.scrollEl!, - gestureName: 'refresher', - gesturePriority: 31, - direction: 'y', - threshold: 5, - onStart: () => { - this.pointerDown = true; + el: this.scrollEl!, + gestureName: 'refresher', + gesturePriority: 31, + direction: 'y', + threshold: 5, + onStart: () => { + this.pointerDown = true; - if (!this.didRefresh) { - translateElement(this.elementToTransform, '0px'); - } + if (!this.didRefresh) { + translateElement(this.elementToTransform, '0px'); + } - /** - * If the content had `display: none` when - * the refresher was initialized, its clientHeight - * will be 0. When the gesture starts, the content - * will be visible, so try to get the correct - * client height again. This is most common when - * using the refresher in an ion-menu. - */ - if (MAX_PULL === 0) { - MAX_PULL = this.scrollEl!.clientHeight * 0.16; - } - }, - onMove: ev => { - this.lastVelocityY = ev.velocityY; - }, - onEnd: () => { - this.pointerDown = false; - this.didStart = false; + /** + * If the content had `display: none` when + * the refresher was initialized, its clientHeight + * will be 0. When the gesture starts, the content + * will be visible, so try to get the correct + * client height again. This is most common when + * using the refresher in an ion-menu. + */ + if (MAX_PULL === 0) { + MAX_PULL = this.scrollEl!.clientHeight * 0.16; + } + }, + onMove: ev => { + this.lastVelocityY = ev.velocityY; + }, + onEnd: () => { + this.pointerDown = false; + this.didStart = false; - if (this.needsCompletion) { - this.resetNativeRefresher(this.elementToTransform, RefresherState.Completing); - this.needsCompletion = false; - } else if (this.didRefresh) { - readTask(() => translateElement(this.elementToTransform, `${this.el.clientHeight}px`)); - } - }, - }); + if (this.needsCompletion) { + this.resetNativeRefresher(this.elementToTransform, RefresherState.Completing); + this.needsCompletion = false; + } else if (this.didRefresh) { + readTask(() => translateElement(this.elementToTransform, `${this.el.clientHeight}px`)); + } + }, + }); this.disabledChanged(); } - private async setupMDNativeRefresher(contentEl: HTMLIonContentElement, pullingSpinner: HTMLIonSpinnerElement, refreshingSpinner: HTMLIonSpinnerElement) { + private async setupMDNativeRefresher(contentEl: HTMLElement, pullingSpinner: HTMLIonSpinnerElement, refreshingSpinner: HTMLIonSpinnerElement) { const circle = getElementRoot(pullingSpinner).querySelector('circle'); const pullingRefresherIcon = this.el.querySelector('ion-refresher-content .refresher-pulling-icon') as HTMLElement; const refreshingCircle = getElementRoot(refreshingSpinner).querySelector('circle'); @@ -383,7 +384,7 @@ export class Refresher implements ComponentInterface { this.disabledChanged(); } - private async setupNativeRefresher(contentEl: HTMLIonContentElement | null) { + private async setupNativeRefresher(contentEl: HTMLElement | null) { if (this.scrollListenerCallback || !contentEl || this.nativeRefresher || !this.scrollEl) { return; } @@ -419,16 +420,25 @@ export class Refresher implements ComponentInterface { return; } - const contentEl = this.el.closest('ion-content'); + const contentEl = findClosestIonContent(this.el); if (!contentEl) { - console.error(' must be used inside an '); + printIonContentErrorMsg(this.el); return; } - await new Promise(resolve => componentOnReady(contentEl, resolve)); + this.scrollEl = await getScrollElement(contentEl); - this.scrollEl = await contentEl.getScrollElement(); - this.backgroundContentEl = getElementRoot(contentEl).querySelector('#background-content') as HTMLElement; + /** + * Query the host `ion-content` directly (if it is available), to use its + * inner #background-content has the target. Otherwise fallback to the + * custom scroll target host. + * + * This makes it so that implementers do not need to re-create the background content + * element and styles. + */ + const backgroundContentHost = this.el.closest('ion-content') ?? contentEl; + + this.backgroundContentEl = getElementRoot(backgroundContentHost).querySelector('#background-content') as HTMLElement; if (await shouldUseNativeRefresher(this.el, getIonMode(this))) { this.setupNativeRefresher(contentEl); diff --git a/core/src/components/refresher/refresher.utils.ts b/core/src/components/refresher/refresher.utils.ts index 22be82a889..b596d07ed1 100644 --- a/core/src/components/refresher/refresher.utils.ts +++ b/core/src/components/refresher/refresher.utils.ts @@ -8,7 +8,7 @@ import { isPlatform } from '../../utils/platform'; // ----------------------------- type RefresherAnimationType = 'scale' | 'translate'; -export const getRefresherAnimationType = (contentEl: HTMLIonContentElement): RefresherAnimationType => { +export const getRefresherAnimationType = (contentEl: HTMLElement): RefresherAnimationType => { const previousSibling = contentEl.previousElementSibling; const hasHeader = previousSibling !== null && previousSibling.tagName === 'ION-HEADER'; diff --git a/core/src/components/refresher/test/scroll-target/e2e.ts b/core/src/components/refresher/test/scroll-target/e2e.ts new file mode 100644 index 0000000000..beff734820 --- /dev/null +++ b/core/src/components/refresher/test/scroll-target/e2e.ts @@ -0,0 +1,53 @@ +import type { E2EPage } from '@stencil/core/testing'; +import { newE2EPage } from '@stencil/core/testing'; + +import { pullToRefresh } from '../test.utils'; + +describe('refresher: custom scroll target', () => { + + let page: E2EPage; + + beforeEach(async () => { + page = await newE2EPage({ + url: '/src/components/refresher/test/scroll-target?ionic:_testing=true' + }); + }); + + describe('legacy refresher', () => { + + it('should load more items when performing a pull-to-refresh', async () => { + const initialItems = await page.findAll('ion-item'); + expect(initialItems.length).toBe(30); + + await pullToRefresh(page); + + const items = await page.findAll('ion-item'); + expect(items.length).toBe(60); + }); + + }); + + describe('native refresher', () => { + + it('should load more items when performing a pull-to-refresh', async () => { + const refresherContent = await page.$('ion-refresher-content'); + refresherContent.evaluate((el: any) => { + // Resets the pullingIcon to enable the native refresher + el.pullingIcon = undefined; + }); + + await page.waitForChanges(); + + const initialItems = await page.findAll('ion-item'); + expect(initialItems.length).toBe(30); + + await pullToRefresh(page); + + const items = await page.findAll('ion-item'); + expect(items.length).toBe(60); + }); + + }); + + +}); diff --git a/core/src/components/refresher/test/scroll-target/index.html b/core/src/components/refresher/test/scroll-target/index.html new file mode 100644 index 0000000000..c0bb100fb0 --- /dev/null +++ b/core/src/components/refresher/test/scroll-target/index.html @@ -0,0 +1,93 @@ + + + + + + Refresher - Custom Scroll Target + + + + + + + + + + + + + + Pull To Refresh + + + + + + + +
+
+ +
+
+
+
+ + + + + diff --git a/core/src/components/reorder-group/readme.md b/core/src/components/reorder-group/readme.md index 1708ac5941..53410257e7 100644 --- a/core/src/components/reorder-group/readme.md +++ b/core/src/components/reorder-group/readme.md @@ -6,6 +6,31 @@ Once the user drags an item and drops it in a new position, the `ionItemReorder` The `detail` property of the `ionItemReorder` event includes all of the relevant information about the reorder operation, including the `from` and `to` indexes. In the context of reordering, an item moves `from` an index `to` a new index. +## Usage with Virtual Scroll + +The reorder group requires a scroll container to function. When using a virtual scrolling solution, you will need to disable scrolling on the `ion-content` and indicate which element container is responsible for the scroll container with the `.ion-content-scroll-host` class target. + +```html + + + + + + Item 1 + + + + + + Item 2 + + + + + + +``` + ## Interfaces ### ItemReorderEventDetail diff --git a/core/src/components/reorder-group/reorder-group.tsx b/core/src/components/reorder-group/reorder-group.tsx index f148e3ae60..c7fbbffaff 100644 --- a/core/src/components/reorder-group/reorder-group.tsx +++ b/core/src/components/reorder-group/reorder-group.tsx @@ -1,8 +1,8 @@ import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, Watch, h } from '@stencil/core'; +import { findClosestIonContent, getScrollElement } from '@utils/content'; import { getIonMode } from '../../global/ionic-global'; import { Gesture, GestureDetail, ItemReorderEventDetail } from '../../interface'; -import { componentOnReady } from '../../utils/helpers'; import { hapticSelectionChanged, hapticSelectionEnd, hapticSelectionStart } from '../../utils/native/haptic'; const enum ReorderGroupState { @@ -54,10 +54,9 @@ export class ReorderGroup implements ComponentInterface { @Event() ionItemReorder!: EventEmitter; async connectedCallback() { - const contentEl = this.el.closest('ion-content'); + const contentEl = findClosestIonContent(this.el); if (contentEl) { - await new Promise(resolve => componentOnReady(contentEl, resolve)); - this.scrollEl = await contentEl.getScrollElement(); + this.scrollEl = await getScrollElement(contentEl); } this.gesture = (await import('../../utils/gesture')).createGesture({ el: this.el, diff --git a/core/src/components/reorder-group/test/interactive/e2e.ts b/core/src/components/reorder-group/test/interactive/e2e.ts index 66e3a593e0..e5ef1cba37 100644 --- a/core/src/components/reorder-group/test/interactive/e2e.ts +++ b/core/src/components/reorder-group/test/interactive/e2e.ts @@ -1,4 +1,4 @@ -import * as pd from '@stencil/core/dist/testing/puppeteer/puppeteer-declarations'; +import type { E2EPage } from '@stencil/core/testing'; import { newE2EPage } from '@stencil/core/testing'; import { getElementProperty, queryDeep } from '@utils/test'; @@ -51,7 +51,7 @@ test('reorder: interactive', async () => { } }); -const moveItem = async (id: string, page: pd.E2EPage, direction: 'up' | 'down' = 'up', numberOfSpaces = 1, ...parentSelectors: string[]) => { +const moveItem = async (id: string, page: E2EPage, direction: 'up' | 'down' = 'up', numberOfSpaces = 1, ...parentSelectors: string[]) => { try { await moveReorderItem(`#${id}`, page, direction, numberOfSpaces, ...parentSelectors); await page.waitForTimeout(50); diff --git a/core/src/components/reorder-group/test/scroll-target/e2e.ts b/core/src/components/reorder-group/test/scroll-target/e2e.ts new file mode 100644 index 0000000000..7e31d5d09a --- /dev/null +++ b/core/src/components/reorder-group/test/scroll-target/e2e.ts @@ -0,0 +1,43 @@ +import type { E2EPage } from '@stencil/core/testing'; +import { newE2EPage } from '@stencil/core/testing'; + +import { getElementProperty, queryDeep } from '../../../../utils/test/utils'; +import { moveReorderItem } from '../test.utils'; + +it('reorder: custom scroll target', async () => { + const page = await newE2EPage({ + url: '/src/components/reorder-group/test/scroll-target?ionic:_testing=true' + }); + + const compares = []; + compares.push(await page.compareScreenshot('reorder: interactive before move')); + + const items = await page.$$('ion-reorder'); + const getItemId = await getElementProperty(items[0], 'id'); + expect(getItemId).toEqual('item-0'); + + await moveItem(getItemId, page, 'down', 1); + + const itemsAfterFirstMove = await page.$$('ion-reorder'); + expect(await getElementProperty(itemsAfterFirstMove[0], 'id')).toEqual('item-1'); + + await moveItem(getItemId, page, 'up', 1); + + const itemsAfterSecondMove = await page.$$('ion-reorder'); + expect(await getElementProperty(itemsAfterSecondMove[0], 'id')).toEqual('item-0'); + + compares.push(await page.compareScreenshot('reorder: interactive after move')); + + for (const compare of compares) { + expect(compare).toMatchScreenshot(); + } +}); + +const moveItem = async (id: string, page: E2EPage, direction: 'up' | 'down' = 'up', numberOfSpaces = 1, ...parentSelectors: string[]) => { + try { + await moveReorderItem(`#${id}`, page, direction, numberOfSpaces, ...parentSelectors); + await page.waitForTimeout(50); + } catch (err) { + throw err; + } +}; diff --git a/core/src/components/reorder-group/test/scroll-target/index.html b/core/src/components/reorder-group/test/scroll-target/index.html new file mode 100644 index 0000000000..71033dc406 --- /dev/null +++ b/core/src/components/reorder-group/test/scroll-target/index.html @@ -0,0 +1,74 @@ + + + + + + Reorder - Custom Scroll Target + + + + + + + + + + + + + + + + + Reorder - Custom Scroll Target + + + + +
+ + + + + +

Item 0

+
+
+
+ + + +

Item 1

+
+
+
+ + + +

Item 2

+
+
+
+
+
+
+
+ +
+ + + + + diff --git a/core/src/components/virtual-scroll/readme.md b/core/src/components/virtual-scroll/readme.md index 8ebd89b168..129558463e 100644 --- a/core/src/components/virtual-scroll/readme.md +++ b/core/src/components/virtual-scroll/readme.md @@ -299,7 +299,7 @@ Type: `Promise` This method marks a subset of items as dirty, so they can be re-rendered. Items should be marked as dirty any time the content or their style changes. -The subset of items to be updated can are specifing by an offset and a length. +The subset of items to be updated can are specifying by an offset and a length. #### Returns diff --git a/core/src/components/virtual-scroll/virtual-scroll.tsx b/core/src/components/virtual-scroll/virtual-scroll.tsx index cc8cac41a7..554c62e057 100644 --- a/core/src/components/virtual-scroll/virtual-scroll.tsx +++ b/core/src/components/virtual-scroll/virtual-scroll.tsx @@ -195,7 +195,7 @@ export class VirtualScroll implements ComponentInterface { * This method marks a subset of items as dirty, so they can be re-rendered. Items should be marked as * dirty any time the content or their style changes. * - * The subset of items to be updated can are specifing by an offset and a length. + * The subset of items to be updated can are specifying by an offset and a length. */ @Method() async checkRange(offset: number, len = -1) { @@ -443,7 +443,7 @@ export class VirtualScroll implements ComponentInterface { } } -const VirtualProxy: FunctionalComponent<{dom: VirtualNode[]}> = ({ dom }, children, utils) => { +const VirtualProxy: FunctionalComponent<{ dom: VirtualNode[] }> = ({ dom }, children, utils) => { return utils.map(children, (child, i) => { const node = dom[i]; const vattrs = child.vattrs || {}; diff --git a/core/src/utils/content/content.utils.spec.ts b/core/src/utils/content/content.utils.spec.ts new file mode 100644 index 0000000000..a5dd43e1dd --- /dev/null +++ b/core/src/utils/content/content.utils.spec.ts @@ -0,0 +1,189 @@ +import { scrollToTop, scrollByPoint, printIonContentErrorMsg, findClosestIonContent, findIonContent, getScrollElement } from './index'; + +describe('Content Utils', () => { + + describe('getScrollElement', () => { + + it('should return the scroll element for ion-content', async () => { + const res = await getScrollElement({ + tagName: 'ION-CONTENT', + getScrollElement: () => Promise.resolve({ + tagName: 'my-scroll-element' + }) + }); + + expect(res).toStrictEqual({ + tagName: 'my-scroll-element' + }); + }); + + }); + + describe('findIonContent', () => { + + it('should query the ion-content element', () => { + const querySelectorMock = jest.fn(); + + findIonContent({ + querySelector: querySelectorMock + }); + + expect(querySelectorMock).toHaveBeenCalledWith('ion-content, .ion-content-scroll-host'); + }); + + }); + + describe('findClosestIonContent', () => { + + it('should query the closest ion-content', () => { + const closestMock = jest.fn(); + + findClosestIonContent({ + closest: closestMock + }); + + expect(closestMock).toHaveBeenCalledWith('ion-content, .ion-content-scroll-host'); + }); + }); + + describe('scrollToTop', () => { + + describe('scroll duration is 0', () => { + + it('should call scrollToTop when the tag name is ion-content', () => { + const scrollToTopMock = jest.fn(); + + scrollToTop({ + tagName: 'ION-CONTENT', + scrollToTop: scrollToTopMock + }, 0); + + expect(scrollToTopMock).toHaveBeenCalledWith(0); + }); + + it('should call the element scrollTo when the tag name is not ion-content', async () => { + const scrollToMock = jest.fn(); + + await scrollToTop({ + tagName: 'DIV', + scrollTo: scrollToMock + }, 0); + + expect(scrollToMock).toHaveBeenCalledWith({ + top: 0, + left: 0, + behavior: 'auto' + }); + }); + + }); + + describe('scroll duration is greater than 0', () => { + + it('should smooth scroll ion-content', () => { + const scrollToTopMock = jest.fn(); + + scrollToTop({ + tagName: 'ION-CONTENT', + scrollToTop: scrollToTopMock + }, 300); + + expect(scrollToTopMock).toHaveBeenCalledWith(300); + }); + + it('should smooth scroll the element', async () => { + const scrollToMock = jest.fn(); + + await scrollToTop({ + tagName: 'DIV', + scrollTo: scrollToMock + }, 300); + + expect(scrollToMock).toHaveBeenCalledWith({ + top: 0, + left: 0, + behavior: 'smooth' + }); + }); + + }); + + }); + + describe('scrollByPoint', () => { + + describe('scroll duration is 0', () => { + + it('should call scrollByPoint when the tag name is ion-content', async () => { + const scrollByPointMock = jest.fn(); + + await scrollByPoint({ + tagName: 'ION-CONTENT', + scrollByPoint: scrollByPointMock + }, 10, 15, 0); + + expect(scrollByPointMock).toHaveBeenCalledWith(10, 15, 0); + }); + + it('should call the element scrollBy when the tag name is not ion-content', async () => { + const scrollByMock = jest.fn(); + + await scrollByPoint({ + tagName: 'DIV', + scrollBy: scrollByMock + }, 10, 15, 0); + + expect(scrollByMock).toHaveBeenCalledWith({ + top: 15, + left: 10, + behavior: 'auto' + }); + }); + + }); + + describe('scroll duration is greater than 0', () => { + + it('should smooth scroll ion-content', async () => { + const scrollByPointMock = jest.fn(); + + await scrollByPoint({ + tagName: 'ION-CONTENT', + scrollByPoint: scrollByPointMock + }, 10, 15, 300); + + expect(scrollByPointMock).toHaveBeenCalledWith(10, 15, 300); + }); + + it('should smooth scroll the element', async () => { + const scrollByMock = jest.fn(); + + await scrollByPoint({ + tagName: 'DIV', + scrollBy: scrollByMock + }, 10, 15, 300); + + expect(scrollByMock).toHaveBeenCalledWith({ + top: 15, + left: 10, + behavior: 'smooth' + }); + }); + + }); + + }); + + it('printIonContentErrorMsg should display " must be used inside ion-content."', () => { + const consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(); + + printIonContentErrorMsg({ + tagName: 'MY-EL' + }); + + expect(consoleErrorMock).toHaveBeenCalledWith(' must be used inside ion-content.'); + + consoleErrorMock.mockRestore(); + }); + +}); diff --git a/core/src/utils/content/index.ts b/core/src/utils/content/index.ts new file mode 100644 index 0000000000..ad5932ded4 --- /dev/null +++ b/core/src/utils/content/index.ts @@ -0,0 +1,99 @@ +import { componentOnReady } from '../helpers'; +import { printRequiredElementError } from '../logging'; + +const ION_CONTENT_TAG_NAME = 'ION-CONTENT'; +const ION_CONTENT_ELEMENT_SELECTOR = 'ion-content'; +const ION_CONTENT_CLASS_SELECTOR = '.ion-content-scroll-host'; +/** + * Selector used for implementations reliant on `` for scroll event changes. + * + * Developers should use the `.ion-content-scroll-host` selector to target the element emitting + * scroll events. With virtual scroll implementations this will be the host element for + * the scroll viewport. + */ +const ION_CONTENT_SELECTOR = `${ION_CONTENT_ELEMENT_SELECTOR}, ${ION_CONTENT_CLASS_SELECTOR}`; + +const isIonContent = (el: Element) => el && el.tagName === ION_CONTENT_TAG_NAME; + +/** + * Waits for the element host fully initialize before + * returning the inner scroll element. + * + * For `ion-content` the scroll target will be the result + * of the `getScrollElement` function. + * + * For custom implementations it will be the element host + * or a selector within the host, if supplied through `scrollTarget`. + */ +export const getScrollElement = async (el: Element) => { + if (isIonContent(el)) { + await new Promise(resolve => componentOnReady(el, resolve)); + return (el as HTMLIonContentElement).getScrollElement(); + } + + return el as HTMLElement; +} + +/** + * Queries the element matching the selector for IonContent. + * See ION_CONTENT_SELECTOR for the selector used. + */ +export const findIonContent = (el: Element) => { + /** + * First we try to query the custom scroll host selector in cases where + * the implementation is using an outer `ion-content` with an inner custom + * scroll container. + */ + const customContentHost = el.querySelector(ION_CONTENT_CLASS_SELECTOR); + if (customContentHost) { + return customContentHost; + } + return el.querySelector(ION_CONTENT_SELECTOR); +} + +/** + * Queries the closest element matching the selector for IonContent. + */ +export const findClosestIonContent = (el: Element) => { + return el.closest(ION_CONTENT_SELECTOR); +} + +/** + * Scrolls to the top of the element. If an `ion-content` is found, it will scroll + * using the public API `scrollToTop` with a duration. + */ +export const scrollToTop = (el: HTMLElement, durationMs: number): Promise => { + if (isIonContent(el)) { + const content = el as HTMLIonContentElement; + return content.scrollToTop(durationMs); + } + return Promise.resolve(el.scrollTo({ + top: 0, + left: 0, + behavior: durationMs > 0 ? 'smooth' : 'auto' + })); +} + +/** + * Scrolls by a specified X/Y distance in the component. If an `ion-content` is found, it will scroll + * using the public API `scrollByPoint` with a duration. + */ +export const scrollByPoint = (el: HTMLElement, x: number, y: number, durationMs: number) => { + if (isIonContent(el)) { + const content = el as HTMLIonContentElement; + return content.scrollByPoint(x, y, durationMs); + } + return Promise.resolve(el.scrollBy({ + top: y, + left: x, + behavior: durationMs > 0 ? 'smooth' : 'auto' + })); +} + +/** + * Prints an error informing developers that an implementation requires an element to be used + * within either the `ion-content` selector or the `.ion-content-scroll-host` class. + */ +export const printIonContentErrorMsg = (el: HTMLElement) => { + return printRequiredElementError(el, ION_CONTENT_ELEMENT_SELECTOR); +} diff --git a/core/src/utils/input-shims/hacks/hide-caret.ts b/core/src/utils/input-shims/hacks/hide-caret.ts index ab8db50ed0..a951613c38 100644 --- a/core/src/utils/input-shims/hacks/hide-caret.ts +++ b/core/src/utils/input-shims/hacks/hide-caret.ts @@ -2,7 +2,7 @@ import { addEventListener, removeEventListener } from '../../helpers'; import { isFocused, relocateInput } from './common'; -export const enableHideCaretOnScroll = (componentEl: HTMLElement, inputEl: HTMLInputElement | HTMLTextAreaElement | undefined, scrollEl: HTMLIonContentElement | undefined) => { +export const enableHideCaretOnScroll = (componentEl: HTMLElement, inputEl: HTMLInputElement | HTMLTextAreaElement | undefined, scrollEl: HTMLElement | undefined) => { if (!scrollEl || !inputEl) { return () => { return; }; } diff --git a/core/src/utils/input-shims/hacks/scroll-assist.ts b/core/src/utils/input-shims/hacks/scroll-assist.ts index d2c8cff6b3..0bd4ca20a6 100644 --- a/core/src/utils/input-shims/hacks/scroll-assist.ts +++ b/core/src/utils/input-shims/hacks/scroll-assist.ts @@ -1,3 +1,5 @@ +import { getScrollElement, scrollByPoint } from '@utils/content'; + import { pointerCoord, raf } from '../../helpers'; import { isFocused, relocateInput } from './common'; @@ -6,7 +8,7 @@ import { getScrollData } from './scroll-data'; export const enableScrollAssist = ( componentEl: HTMLElement, inputEl: HTMLInputElement | HTMLTextAreaElement, - contentEl: HTMLIonContentElement | null, + contentEl: HTMLElement | null, footerEl: HTMLIonFooterElement | null, keyboardHeight: number ) => { @@ -44,7 +46,7 @@ export const enableScrollAssist = ( const jsSetFocus = async ( componentEl: HTMLElement, inputEl: HTMLInputElement | HTMLTextAreaElement, - contentEl: HTMLIonContentElement | null, + contentEl: HTMLElement | null, footerEl: HTMLIonFooterElement | null, keyboardHeight: number ) => { @@ -85,7 +87,7 @@ const jsSetFocus = async ( // scroll the input into place if (contentEl) { - await contentEl.scrollByPoint(0, scrollData.scrollAmount, scrollData.scrollDuration); + await scrollByPoint(contentEl, 0, scrollData.scrollAmount, scrollData.scrollDuration); } // the scroll view is in the correct position now @@ -102,7 +104,7 @@ const jsSetFocus = async ( }; if (contentEl) { - const scrollEl = await contentEl.getScrollElement(); + const scrollEl = await getScrollElement(contentEl); /** * scrollData will only consider the amount we need diff --git a/core/src/utils/input-shims/hacks/scroll-padding.ts b/core/src/utils/input-shims/hacks/scroll-padding.ts index cf9037e45f..a7af255c70 100644 --- a/core/src/utils/input-shims/hacks/scroll-padding.ts +++ b/core/src/utils/input-shims/hacks/scroll-padding.ts @@ -1,3 +1,5 @@ +import { findClosestIonContent } from '@utils/content'; + const PADDING_TIMER_KEY = '$ionPaddingTimer'; export const enableScrollPadding = (keyboardHeight: number) => { @@ -34,7 +36,7 @@ const setScrollPadding = (input: HTMLElement, keyboardHeight: number) => { return; } - const el = input.closest('ion-content'); + const el = findClosestIonContent(input); if (el === null) { return; } diff --git a/core/src/utils/input-shims/input-shims.ts b/core/src/utils/input-shims/input-shims.ts index e36c920e07..01cc8c9661 100644 --- a/core/src/utils/input-shims/input-shims.ts +++ b/core/src/utils/input-shims/input-shims.ts @@ -1,3 +1,5 @@ +import { findClosestIonContent } from '@utils/content'; + import { Config } from '../../interface'; import { componentOnReady } from '../helpers'; @@ -28,7 +30,7 @@ export const startInputShims = (config: Config) => { const inputRoot = componentEl.shadowRoot || componentEl; const inputEl = inputRoot.querySelector('input') || inputRoot.querySelector('textarea'); - const scrollEl = componentEl.closest('ion-content'); + const scrollEl = findClosestIonContent(componentEl); const footerEl = (!scrollEl) ? componentEl.closest('ion-footer') as HTMLIonFooterElement | null : null; if (!inputEl) { diff --git a/core/src/utils/logging/index.ts b/core/src/utils/logging/index.ts index 0db8c10ac0..6e6c458894 100644 --- a/core/src/utils/logging/index.ts +++ b/core/src/utils/logging/index.ts @@ -17,4 +17,16 @@ export const printIonWarning = (message: string) => { */ export const printIonError = (message: string, ...params: any) => { return console.error(`[Ionic Error]: ${message}`, ...params); -} \ No newline at end of file +} +/** + * Prints an error informing developers that an implementation requires an element to be used + * within a specific select.ro + * + * @param el The web component element this is requiring the element. + * @param targetSelectors The selector or selectors that were not found. + */ +export const printRequiredElementError = (el: HTMLElement, ...targetSelectors: string[]) => { + return console.error( + `<${el.tagName.toLowerCase()}> must be used inside ${targetSelectors.join(' or ')}.` + ); +} diff --git a/core/src/utils/status-tap.ts b/core/src/utils/status-tap.ts index cc25d851c3..a9178537e4 100644 --- a/core/src/utils/status-tap.ts +++ b/core/src/utils/status-tap.ts @@ -1,4 +1,5 @@ import { readTask, writeTask } from '@stencil/core'; +import { findClosestIonContent, scrollToTop } from '@utils/content'; import { componentOnReady } from './helpers'; @@ -12,7 +13,7 @@ export const startStatusTap = () => { if (!el) { return; } - const contentEl = el.closest('ion-content'); + const contentEl = findClosestIonContent(el); if (contentEl) { new Promise(resolve => componentOnReady(contentEl, resolve)).then(() => { writeTask(async () => { @@ -26,7 +27,7 @@ export const startStatusTap = () => { */ contentEl.style.setProperty('--overflow', 'hidden'); - await contentEl.scrollToTop(300); + await scrollToTop(contentEl, 300); contentEl.style.removeProperty('--overflow'); }); diff --git a/core/src/utils/test/utils.ts b/core/src/utils/test/utils.ts index f7216ea833..e89fa99865 100644 --- a/core/src/utils/test/utils.ts +++ b/core/src/utils/test/utils.ts @@ -1,4 +1,5 @@ -import { E2EElement, E2EPage } from '@stencil/core/testing'; +import type { E2EPage } from '@stencil/core/testing'; +import { E2EElement } from '@stencil/core/testing'; import { ElementHandle } from 'puppeteer'; /** @@ -120,7 +121,7 @@ export const dragElementBy = async ( * @param interval: number - Interval to run setInterval on */ export const waitForFunctionTestContext = async (fn: any, params: any, interval = 16): Promise => { - return new Promise(resolve => { + return new Promise(resolve => { const intervalId = setInterval(() => { if (fn(params)) { clearInterval(intervalId); @@ -186,6 +187,30 @@ export const checkModeClasses = async (el: E2EElement, globalMode: string) => { expect(el).toHaveClass(`${mode}`); }; +/** + * Scrolls to a specific x/y coordinate within a scroll container. Supports custom + * method for `ion-content` implementations. + * + * @param page The Puppeteer page object + * @param selector The element to scroll within. + * @param x The x coordinate to scroll to. + * @param y The y coordinate to scroll to. + */ +export const scrollTo = async (page: E2EPage, selector: string, x: number, y: number) => { + await page.evaluate(async selector => { + const el = document.querySelector(selector); + if (el) { + if (el.tagName === 'ION-CONTENT') { + await (el as any).scrollToPoint(x, y); + } else { + el.scroll(x, y); + } + } else { + console.error(`Unable to find element with selector: ${selector}`); + } + }, selector); +} + /** * Scrolls to the bottom of a scroll container. Supports custom method for * `ion-content` implementations. diff --git a/core/stencil.config.ts b/core/stencil.config.ts index af5814d55f..dfe0e3f268 100644 --- a/core/stencil.config.ts +++ b/core/stencil.config.ts @@ -263,7 +263,8 @@ export const config: Config = { waitBeforeScreenshot: 20, moduleNameMapper: { "@utils/test": ["/src/utils/test/utils"], - "@utils/logging": ["/src/utils/logging"] + "@utils/logging": ["/src/utils/logging"], + }, emulate: [ {