fix(refresher): attach scroll listener to custom scroll target (#25335)

Resolves #25318
This commit is contained in:
Sean Perkins
2022-05-24 16:22:53 -04:00
committed by GitHub
parent 15f0c0669f
commit 8f5e4cd935
14 changed files with 188 additions and 275 deletions

View File

@ -4,7 +4,12 @@ import { Component, Element, Event, Host, Method, Prop, State, Watch, h, readTas
import { getIonMode } from '../../global/ionic-global'; import { getIonMode } from '../../global/ionic-global';
import type { Animation, Gesture, GestureDetail, RefresherEventDetail } from '../../interface'; import type { Animation, Gesture, GestureDetail, RefresherEventDetail } from '../../interface';
import { getTimeGivenProgression } from '../../utils/animation/cubic-bezier'; import { getTimeGivenProgression } from '../../utils/animation/cubic-bezier';
import { findClosestIonContent, getScrollElement, printIonContentErrorMsg } from '../../utils/content'; import {
getScrollElement,
ION_CONTENT_CLASS_SELECTOR,
ION_CONTENT_ELEMENT_SELECTOR,
printIonContentErrorMsg,
} from '../../utils/content';
import { clamp, getElementRoot, raf, transitionEndAsync } from '../../utils/helpers'; import { clamp, getElementRoot, raf, transitionEndAsync } from '../../utils/helpers';
import { hapticImpact } from '../../utils/native/haptic'; import { hapticImpact } from '../../utils/native/haptic';
@ -436,13 +441,21 @@ export class Refresher implements ComponentInterface {
return; return;
} }
const contentEl = findClosestIonContent(this.el); const contentEl = this.el.closest(ION_CONTENT_ELEMENT_SELECTOR);
if (!contentEl) { if (!contentEl) {
printIonContentErrorMsg(this.el); printIonContentErrorMsg(this.el);
return; return;
} }
this.scrollEl = await getScrollElement(contentEl); const customScrollTarget = contentEl.querySelector(ION_CONTENT_CLASS_SELECTOR);
/**
* Query the custom scroll target (if available), first. In refresher implementations,
* the ion-refresher element will always be a direct child of ion-content (slot="fixed"). By
* querying the custom scroll target first and falling back to the ion-content element,
* the correct scroll element will be returned by the implementation.
*/
this.scrollEl = await getScrollElement(customScrollTarget ?? contentEl);
/** /**
* Query the host `ion-content` directly (if it is available), to use its * Query the host `ion-content` directly (if it is available), to use its
@ -452,9 +465,7 @@ export class Refresher implements ComponentInterface {
* This makes it so that implementers do not need to re-create the background content * This makes it so that implementers do not need to re-create the background content
* element and styles. * element and styles.
*/ */
const backgroundContentHost = this.el.closest('ion-content') ?? contentEl; this.backgroundContentEl = getElementRoot(contentEl ?? customScrollTarget).querySelector(
this.backgroundContentEl = getElementRoot(backgroundContentHost).querySelector(
'#background-content' '#background-content'
) as HTMLElement; ) as HTMLElement;

View File

@ -1,51 +0,0 @@
import type { E2EPage } from '@stencil/core/testing';
import { newE2EPage } from '@stencil/core/testing';
import { pullToRefresh } from '../test.utils';
describe('refresher: basic', () => {
let page: E2EPage;
beforeEach(async () => {
page = await newE2EPage({
url: '/src/components/refresher/test/basic?ionic:_testing=true',
});
});
it('should match existing visual screenshots', async () => {
const compare = await page.compareScreenshot();
expect(compare).toMatchScreenshot();
});
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);
});
});
});

View File

@ -53,7 +53,7 @@
refresher.complete(); refresher.complete();
render(); render();
// Custom event consumed by e2e tests // Custom event consumed by e2e tests
document.dispatchEvent(new CustomEvent('ionRefreshComplete')); window.dispatchEvent(new CustomEvent('ionRefreshComplete'));
}); });
function render() { function render() {

View File

@ -0,0 +1,41 @@
import { expect } from '@playwright/test';
import { test } from '@utils/test/playwright';
import { pullToRefresh } from '../test.utils';
test.describe('refresher: basic', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/refresher/test/basic');
});
test.describe('legacy refresher', () => {
test('should load more items when performing a pull-to-refresh', async ({ page }) => {
const items = page.locator('ion-item');
expect(await items.count()).toBe(30);
await pullToRefresh(page);
expect(await items.count()).toBe(60);
});
});
test.describe('native refresher', () => {
test('should load more items when performing a pull-to-refresh', async ({ page }) => {
const refresherContent = page.locator('ion-refresher-content');
refresherContent.evaluateHandle((el: any) => {
// Resets the pullingIcon to enable the native refresher
el.pullingIcon = undefined;
});
await page.waitForChanges();
const items = page.locator('ion-item');
expect(await items.count()).toBe(30);
await pullToRefresh(page);
expect(await items.count()).toBe(60);
});
});
});

View File

@ -1,47 +0,0 @@
import type { E2EPage } from '@stencil/core/testing';
import { newE2EPage } from '@stencil/core/testing';
import { pullToRefresh } from '../test.utils';
// TODO(FW-1134) Re-write these tests so that they test correct functionality.
describe.skip('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);
});
});
});

View File

@ -44,8 +44,8 @@
<ion-refresher id="refresher" slot="fixed"> <ion-refresher id="refresher" slot="fixed">
<ion-refresher-content></ion-refresher-content> <ion-refresher-content></ion-refresher-content>
</ion-refresher> </ion-refresher>
<div id="content" class="ion-content-scroll-host"> <div id="content">
<div id="inner-scroll"> <div id="inner-scroll" class="ion-content-scroll-host">
<ion-list id="list"></ion-list> <ion-list id="list"></ion-list>
</div> </div>
</div> </div>
@ -65,7 +65,7 @@
refresher.complete(); refresher.complete();
render(); render();
// Custom event consumed by e2e tests // Custom event consumed by e2e tests
document.dispatchEvent(new CustomEvent('ionRefreshComplete')); window.dispatchEvent(new CustomEvent('ionRefreshComplete'));
}); });
function render() { function render() {

View File

@ -0,0 +1,42 @@
import { expect } from '@playwright/test';
import { test } from '@utils/test/playwright';
import { pullToRefresh } from '../test.utils';
test.describe('refresher: custom scroll target', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/refresher/test/scroll-target');
});
test.describe('legacy refresher', () => {
test('should load more items when performing a pull-to-refresh', async ({ page }) => {
const items = page.locator('ion-item');
expect(await items.count()).toBe(30);
await pullToRefresh(page, '#inner-scroll');
expect(await items.count()).toBe(60);
});
});
test.describe('native refresher', () => {
test('should load more items when performing a pull-to-refresh', async ({ page }) => {
const refresherContent = page.locator('ion-refresher-content');
refresherContent.evaluateHandle((el: any) => {
// Resets the pullingIcon to enable the native refresher
el.pullingIcon = undefined;
});
await page.waitForChanges();
const items = page.locator('ion-item');
expect(await items.count()).toBe(30);
await pullToRefresh(page, '#inner-scroll');
expect(await items.count()).toBe(60);
});
});
});

View File

@ -1,10 +0,0 @@
import { newE2EPage } from '@stencil/core/testing';
test('refresher: spec', async () => {
const page = await newE2EPage({
url: '/src/components/refresher/test/spec?ionic:_testing=true',
});
const compare = await page.compareScreenshot();
expect(compare).toMatchScreenshot();
});

View File

@ -1,131 +0,0 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Refresher - Spec</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
</head>
<style>
ion-spinner {
color: #555;
}
</style>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button default-href="/" text="Mailboxes"></ion-back-button>
</ion-buttons>
<ion-title>All Inboxes</ion-title>
<ion-buttons slot="end">
<ion-button>Edit</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher id="refresher" slot="fixed">
<ion-refresher-content></ion-refresher-content>
</ion-refresher>
<ion-header collapse="condense">
<ion-toolbar>
<ion-title size="large">All Inboxes</ion-title>
</ion-toolbar>
<ion-toolbar>
<ion-searchbar></ion-searchbar>
</ion-toolbar>
</ion-header>
<ion-list id="list"></ion-list>
<ion-fab vertical="bottom" horizontal="end" slot="fixed">
<ion-fab-button onclick="toggleHeader()">
<ion-icon name="settings"></ion-icon>
</ion-fab-button>
</ion-fab>
</ion-content>
</ion-app>
<script>
const toggleHeader = () => {
const app = document.querySelector('ion-app');
const header = app.querySelector('ion-header');
if (header) {
header.parentNode.removeChild(header);
} else {
app.insertAdjacentHTML(
'afterbegin',
`
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button default-href="/" text="Mailboxes"></ion-back-button>
</ion-buttons>
<ion-title>All Inboxes</ion-title>
<ion-buttons slot="end">
<ion-button>Edit</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
`
);
}
};
let items = [];
for (var i = 0; i < 30; i++) {
items.push(i + 1);
}
const list = document.getElementById('list');
const refresher = document.getElementById('refresher');
refresher.addEventListener('ionPull', () => {
console.log('ionPull');
});
refresher.addEventListener('ionStart', () => {
console.log('ionStart');
});
refresher.addEventListener('ionRefresh', async function () {
console.log('Loading data...');
const data = await getAsyncData();
items = items.concat(data);
refresher.complete();
render();
console.log('Done');
});
function render() {
let html = '';
for (let item of items) {
html += `<ion-item button>${item}</ion-item>`;
}
list.innerHTML = html;
}
function getAsyncData() {
// async return mock data
return new Promise((resolve) => {
setTimeout(() => {
let data = [];
for (var i = 0; i < 30; i++) {
data.push(i);
}
resolve(data);
}, 5000);
});
}
render();
</script>
</body>
</html>

View File

@ -1,5 +1,4 @@
import type { E2EPage } from '@stencil/core/testing'; import type { E2EPage } from '@utils/test/playwright';
import { dragElementBy } from '@utils/test';
/** /**
* Emulates a pull-to-refresh drag gesture (pulls down and releases). * Emulates a pull-to-refresh drag gesture (pulls down and releases).
@ -12,10 +11,28 @@ import { dragElementBy } from '@utils/test';
* @param selector The element selector to center the drag gesture on. Defaults to `body`. * @param selector The element selector to center the drag gesture on. Defaults to `body`.
*/ */
const pullToRefresh = async (page: E2EPage, selector = 'body') => { const pullToRefresh = async (page: E2EPage, selector = 'body') => {
const target = (await page.$(selector))!; const target = page.locator(selector);
await dragElementBy(target, page, 0, 200); await page.waitForSelector('ion-refresher.hydrated', { state: 'attached' });
const ev = await page.spyOnEvent('ionRefreshComplete', 'document');
const ev = await page.spyOnEvent('ionRefreshComplete');
const boundingBox = await target.boundingBox();
if (!boundingBox) {
return;
}
const startX = boundingBox.x + boundingBox.width / 2;
const startY = boundingBox.y + boundingBox.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
for (let i = 0; i < 400; i += 20) {
await page.mouse.move(startX, startY + i);
}
await page.mouse.up();
await ev.next(); await ev.next();
}; };

View File

@ -2,8 +2,8 @@ import { componentOnReady } from '../helpers';
import { printRequiredElementError } from '../logging'; import { printRequiredElementError } from '../logging';
const ION_CONTENT_TAG_NAME = 'ION-CONTENT'; const ION_CONTENT_TAG_NAME = 'ION-CONTENT';
const ION_CONTENT_ELEMENT_SELECTOR = 'ion-content'; export const ION_CONTENT_ELEMENT_SELECTOR = 'ion-content';
const ION_CONTENT_CLASS_SELECTOR = '.ion-content-scroll-host'; export const ION_CONTENT_CLASS_SELECTOR = '.ion-content-scroll-host';
/** /**
* Selector used for implementations reliant on `<ion-content>` for scroll event changes. * Selector used for implementations reliant on `<ion-content>` for scroll event changes.
* *

View File

@ -7,7 +7,7 @@ import type { Page, TestInfo } from '@playwright/test';
* automatically waits for the Stencil components * automatically waits for the Stencil components
* to be hydrated before proceeding with the test. * to be hydrated before proceeding with the test.
*/ */
export const goto = async (page: Page, url: string, testInfo: TestInfo, originalFn: typeof page.goto) => { export const goto = async (page: Page, url: string, options: any, testInfo: TestInfo, originalFn: typeof page.goto) => {
const { mode, rtl, _testing } = testInfo.project.metadata; const { mode, rtl, _testing } = testInfo.project.metadata;
const splitUrl = url.split('?'); const splitUrl = url.split('?');
@ -38,7 +38,7 @@ export const goto = async (page: Page, url: string, testInfo: TestInfo, original
const result = await Promise.all([ const result = await Promise.all([
page.waitForFunction(() => (window as any).testAppLoaded === true, { timeout: 4750 }), page.waitForFunction(() => (window as any).testAppLoaded === true, { timeout: 4750 }),
originalFn(formattedUrl), originalFn(formattedUrl, options),
]); ]);
return result[1]; return result[1];

View File

@ -28,8 +28,35 @@ export interface E2EPage extends Page {
* @param url URL to navigate page to. The url should include scheme, e.g. `https://`. When a `baseURL` via the context options was provided and the passed URL is a path, it gets merged via the * @param url URL to navigate page to. The url should include scheme, e.g. `https://`. When a `baseURL` via the context options was provided and the passed URL is a path, it gets merged via the
* [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. * [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor.
*/ */
goto: (url: string) => Promise<null | Response>; goto: (
url: string,
options?: {
/**
* Referer header value. If provided it will take preference over the referer header value set by
* [page.setExtraHTTPHeaders(headers)](https://playwright.dev/docs/api/class-page#page-set-extra-http-headers).
*/
referer?: string;
/**
* Maximum operation time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be
* changed by using the
* [browserContext.setDefaultNavigationTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-navigation-timeout),
* [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout),
* [page.setDefaultNavigationTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-navigation-timeout)
* or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods.
*/
timeout?: number;
/**
* When to consider operation succeeded, defaults to `load`. Events can be either:
* - `'domcontentloaded'` - consider operation to be finished when the `DOMContentLoaded` event is fired.
* - `'load'` - consider operation to be finished when the `load` event is fired.
* - `'networkidle'` - consider operation to be finished when there are no network connections for at least `500` ms.
* - `'commit'` - consider operation to be finished when network response is received and the document started loading.
*/
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' | 'commit';
}
) => Promise<null | Response>;
/** /**
* Find an element by selector. * Find an element by selector.
* See https://playwright.dev/docs/locators for more information. * See https://playwright.dev/docs/locators for more information.
@ -58,9 +85,11 @@ export interface E2EPage extends Page {
* never fires. * never fires.
* *
* Usage: * Usage:
* ```ts
* const ionChange = await page.spyOnEvent('ionChange'); * const ionChange = await page.spyOnEvent('ionChange');
* ... * ...
* await ionChange.next(); * await ionChange.next();
* ```
*/ */
spyOnEvent: (eventName: string) => Promise<EventSpy>; spyOnEvent: (eventName: string) => Promise<EventSpy>;
_e2eEventsIds: number; _e2eEventsIds: number;

View File

@ -31,24 +31,36 @@ type CustomFixtures = {
page: E2EPage; page: E2EPage;
}; };
/**
* Extends the base `page` test figure within Playwright.
* @param page The page to extend.
* @param testInfo The test info.
* @returns The modified playwright page with extended functionality.
*/
export async function extendPageFixture(page: E2EPage, testInfo: TestInfo) {
const originalGoto = page.goto.bind(page);
const originalLocator = page.locator.bind(page);
// Overridden Playwright methods
page.goto = (url: string, options) => goToPage(page, url, options, testInfo, originalGoto);
page.setContent = (html: string) => setContent(page, html);
page.locator = (selector: string, options?: LocatorOptions) => locator(page, originalLocator, selector, options);
// Custom Ionic methods
page.getSnapshotSettings = () => getSnapshotSettings(page, testInfo);
page.setIonViewport = () => setIonViewport(page);
page.waitForChanges = (timeoutMs?: number) => waitForChanges(page, timeoutMs);
page.spyOnEvent = (eventName: string) => spyOnEvent(page, eventName);
// Custom event behavior
await initPageEvents(page);
return page;
}
export const test = base.extend<CustomFixtures>({ export const test = base.extend<CustomFixtures>({
page: async ({ page }: CustomTestArgs, use: (r: E2EPage) => Promise<void>, testInfo: TestInfo) => { page: async ({ page }: CustomTestArgs, use: (r: E2EPage) => Promise<void>, testInfo: TestInfo) => {
const originalGoto = page.goto.bind(page); page = await extendPageFixture(page, testInfo);
const originalLocator = page.locator.bind(page);
// Overridden Playwright methods
page.goto = (url: string) => goToPage(page, url, testInfo, originalGoto);
page.setContent = (html: string) => setContent(page, html);
page.locator = (selector: string, options?: LocatorOptions) => locator(page, originalLocator, selector, options);
// Custom Ionic methods
page.getSnapshotSettings = () => getSnapshotSettings(page, testInfo);
page.setIonViewport = () => setIonViewport(page);
page.waitForChanges = (timeoutMs?: number) => waitForChanges(page, timeoutMs);
page.spyOnEvent = (eventName: string) => spyOnEvent(page, eventName);
// Custom event behavior
await initPageEvents(page);
await use(page); await use(page);
}, },
}); });