mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
feat(content, reorder-group, header, footer, infinite-scroll, refresher): add custom scroll target to improve compatibility with virtual scroll (#24883)
Resolves #23437
This commit is contained in:
2
core/src/components.d.ts
vendored
2
core/src/components.d.ts
vendored
@@ -2991,7 +2991,7 @@ export namespace Components {
|
||||
*/
|
||||
"checkEnd": () => Promise<void>;
|
||||
/**
|
||||
* 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<void>;
|
||||
"domRender"?: DomRenderFn;
|
||||
|
||||
@@ -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 &&
|
||||
<div class="footer-background"></div>
|
||||
}
|
||||
<slot></slot>
|
||||
|
||||
@@ -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
|
||||
<ion-content scroll-y="false">
|
||||
<virtual-scroll-element class="ion-content-scroll-host">
|
||||
<!-- Your virtual scroll content -->
|
||||
</virtual-scroll-element>
|
||||
</ion-content>
|
||||
<ion-footer collapse="fade">
|
||||
<ion-toolbar>
|
||||
<ion-title>Footer</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
```
|
||||
|
||||
<!-- Auto Generated Below -->
|
||||
|
||||
|
||||
|
||||
35
core/src/components/footer/test/scroll-target/e2e.ts
Normal file
35
core/src/components/footer/test/scroll-target/e2e.ts
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
102
core/src/components/footer/test/scroll-target/index.html
Normal file
102
core/src/components/footer/test/scroll-target/index.html
Normal file
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Footer - Fade (custom scroll host)</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>
|
||||
<style>
|
||||
.red {
|
||||
background-color: #ea445a;
|
||||
}
|
||||
|
||||
.green {
|
||||
background-color: #76d672;
|
||||
}
|
||||
|
||||
.blue {
|
||||
background-color: #3478f6;
|
||||
}
|
||||
|
||||
.yellow {
|
||||
background-color: #ffff80;
|
||||
}
|
||||
|
||||
.pink {
|
||||
background-color: #ff6b86;
|
||||
}
|
||||
|
||||
.purple {
|
||||
background-color: #7e34f6;
|
||||
}
|
||||
|
||||
.black {
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.orange {
|
||||
background-color: #f69234;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: 10px;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
#scroll-target {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
padding: 50px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<ion-app>
|
||||
<div class="ion-page">
|
||||
<ion-header translucent="true">
|
||||
<ion-toolbar>
|
||||
<ion-title>Mailboxes</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content fullscreen="true" scroll-y="false">
|
||||
<div id="scroll-target" class="ion-content-scroll-host">
|
||||
<div class="grid ion-padding">
|
||||
<div class="grid-item red"></div>
|
||||
<div class="grid-item green"></div>
|
||||
<div class="grid-item blue"></div>
|
||||
<div class="grid-item yellow"></div>
|
||||
<div class="grid-item pink"></div>
|
||||
<div class="grid-item purple"></div>
|
||||
<div class="grid-item black"></div>
|
||||
<div class="grid-item orange"></div>
|
||||
</div>
|
||||
</div>
|
||||
</ion-content>
|
||||
<ion-footer collapse="fade" translucent="true">
|
||||
<ion-toolbar>
|
||||
<ion-title>Updated Just Now</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
</div>
|
||||
</ion-app>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -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 &&
|
||||
<div class="header-background"></div>
|
||||
}
|
||||
<slot></slot>
|
||||
|
||||
@@ -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
|
||||
<ion-header collapse="fade">
|
||||
<ion-toolbar>
|
||||
<ion-title>Header</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content fullscreen="true" scroll-y="false">
|
||||
<ion-header collapse="condense">
|
||||
<ion-toolbar>
|
||||
<ion-title size="large">Header</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<virtual-scroll-element class="ion-content-scroll-host">
|
||||
<!-- Your virtual scroll content -->
|
||||
</virtual-scroll-element>
|
||||
</ion-content>
|
||||
```
|
||||
|
||||
<!-- Auto Generated Below -->
|
||||
|
||||
|
||||
@@ -1,89 +1,93 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Header - Fade</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>
|
||||
<style>
|
||||
.red {
|
||||
background-color: #ea445a;
|
||||
}
|
||||
|
||||
.green {
|
||||
background-color: #76d672;
|
||||
}
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Header - Fade</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>
|
||||
<style>
|
||||
.red {
|
||||
background-color: #ea445a;
|
||||
}
|
||||
|
||||
.blue {
|
||||
background-color: #3478f6;
|
||||
}
|
||||
.green {
|
||||
background-color: #76d672;
|
||||
}
|
||||
|
||||
.yellow {
|
||||
background-color: #ffff80;
|
||||
}
|
||||
.blue {
|
||||
background-color: #3478f6;
|
||||
}
|
||||
|
||||
.pink {
|
||||
background-color: #ff6b86;
|
||||
}
|
||||
.yellow {
|
||||
background-color: #ffff80;
|
||||
}
|
||||
|
||||
.purple {
|
||||
background-color: #7e34f6;
|
||||
}
|
||||
.pink {
|
||||
background-color: #ff6b86;
|
||||
}
|
||||
|
||||
.black {
|
||||
background-color: #000;
|
||||
}
|
||||
.purple {
|
||||
background-color: #7e34f6;
|
||||
}
|
||||
|
||||
.orange {
|
||||
background-color: #f69234;
|
||||
}
|
||||
.black {
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: 10px;
|
||||
}
|
||||
.orange {
|
||||
background-color: #f69234;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
height: 200px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: 10px;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
height: 200px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<ion-app>
|
||||
<div class="ion-page">
|
||||
<ion-header collapse="fade" translucent="true">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<ion-app>
|
||||
<div class="ion-page">
|
||||
<ion-header collapse="fade" translucent="true">
|
||||
<ion-toolbar>
|
||||
<ion-title>Mailboxes</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content fullscreen="true">
|
||||
<ion-header collapse="condense">
|
||||
<ion-toolbar>
|
||||
<ion-title>Mailboxes</ion-title>
|
||||
<ion-title size="large">Mailboxes</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content fullscreen="true">
|
||||
<ion-header collapse="condense">
|
||||
<ion-toolbar>
|
||||
<ion-title size="large">Mailboxes</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<div class="grid ion-padding">
|
||||
<div class="grid-item red"></div>
|
||||
<div class="grid-item green"></div>
|
||||
<div class="grid-item blue"></div>
|
||||
<div class="grid-item yellow"></div>
|
||||
<div class="grid-item pink"></div>
|
||||
<div class="grid-item purple"></div>
|
||||
<div class="grid-item black"></div>
|
||||
<div class="grid-item orange"></div>
|
||||
</div>
|
||||
</ion-content>
|
||||
<ion-footer translucent="true">
|
||||
<ion-toolbar>
|
||||
<ion-title>Updated Just Now</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
</div>
|
||||
</ion-app>
|
||||
</body>
|
||||
<div class="grid ion-padding">
|
||||
<div class="grid-item red"></div>
|
||||
<div class="grid-item green"></div>
|
||||
<div class="grid-item blue"></div>
|
||||
<div class="grid-item yellow"></div>
|
||||
<div class="grid-item pink"></div>
|
||||
<div class="grid-item purple"></div>
|
||||
<div class="grid-item black"></div>
|
||||
<div class="grid-item orange"></div>
|
||||
</div>
|
||||
</ion-content>
|
||||
<ion-footer translucent="true">
|
||||
<ion-toolbar>
|
||||
<ion-title>Updated Just Now</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
</div>
|
||||
</ion-app>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
59
core/src/components/header/test/scroll-target/e2e.ts
Normal file
59
core/src/components/header/test/scroll-target/e2e.ts
Normal file
@@ -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();
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
107
core/src/components/header/test/scroll-target/index.html
Normal file
107
core/src/components/header/test/scroll-target/index.html
Normal file
@@ -0,0 +1,107 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Header - Custom Scroll Target</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>
|
||||
<style>
|
||||
.red {
|
||||
background-color: #ea445a;
|
||||
}
|
||||
|
||||
.green {
|
||||
background-color: #76d672;
|
||||
}
|
||||
|
||||
.blue {
|
||||
background-color: #3478f6;
|
||||
}
|
||||
|
||||
.yellow {
|
||||
background-color: #ffff80;
|
||||
}
|
||||
|
||||
.pink {
|
||||
background-color: #ff6b86;
|
||||
}
|
||||
|
||||
.purple {
|
||||
background-color: #7e34f6;
|
||||
}
|
||||
|
||||
.black {
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.orange {
|
||||
background-color: #f69234;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: 10px;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
#scroll-target {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
padding: 50px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<ion-app>
|
||||
<div class="ion-page">
|
||||
<ion-header collapse="fade" translucent="true">
|
||||
<ion-toolbar>
|
||||
<ion-title>Mailboxes</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content fullscreen="true" scroll-y="false">
|
||||
<div id="scroll-target" class="ion-content-scroll-host">
|
||||
<ion-header collapse="condense">
|
||||
<ion-toolbar>
|
||||
<ion-title size="large">Mailboxes</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<div class="grid ion-padding">
|
||||
<div class="grid-item red"></div>
|
||||
<div class="grid-item green"></div>
|
||||
<div class="grid-item blue"></div>
|
||||
<div class="grid-item yellow"></div>
|
||||
<div class="grid-item pink"></div>
|
||||
<div class="grid-item purple"></div>
|
||||
<div class="grid-item black"></div>
|
||||
<div class="grid-item orange"></div>
|
||||
</div>
|
||||
</div>
|
||||
</ion-content>
|
||||
<ion-footer translucent="true">
|
||||
<ion-toolbar>
|
||||
<ion-title>Updated Just Now</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
</div>
|
||||
</ion-app>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -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<void>;
|
||||
|
||||
async connectedCallback() {
|
||||
const contentEl = this.el.closest('ion-content');
|
||||
const contentEl = findClosestIonContent(this.el);
|
||||
if (!contentEl) {
|
||||
console.error('<ion-infinite-scroll> must be used inside an <ion-content>');
|
||||
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') {
|
||||
|
||||
@@ -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
|
||||
<ion-content scroll-y="false">
|
||||
<virtual-scroll-element class="ion-content-scroll-host">
|
||||
<!-- Your virtual scroll content -->
|
||||
</virtual-scroll-element>
|
||||
<ion-infinite-scroll>
|
||||
<ion-infinite-scroll-content></ion-infinite-scroll-content>
|
||||
</ion-infinite-scroll>
|
||||
</ion-content>
|
||||
```
|
||||
|
||||
## Interfaces
|
||||
|
||||
### InfiniteScrollCustomEvent
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
<body>
|
||||
<ion-app>
|
||||
|
||||
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Infinite Scroll - Basic</ion-title>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Infinite Scroll - Custom Scroll Target</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>
|
||||
|
||||
<style>
|
||||
#scroll-target {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<ion-app>
|
||||
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Infinite Scroll - Custom Scroll Target</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content scroll-y="false">
|
||||
<div id="scroll-target" class="ion-padding ion-content-scroll-host">
|
||||
|
||||
<ion-list></ion-list>
|
||||
|
||||
<ion-infinite-scroll threshold="100px">
|
||||
<ion-infinite-scroll-content loading-spinner="crescent" loading-text="Loading more data...">
|
||||
</ion-infinite-scroll-content>
|
||||
</ion-infinite-scroll>
|
||||
</div>
|
||||
</ion-content>
|
||||
|
||||
</ion-app>
|
||||
|
||||
<script>
|
||||
let count = 0;
|
||||
const list = document.querySelector('ion-list');
|
||||
const infiniteScroll = document.querySelector('ion-infinite-scroll');
|
||||
|
||||
infiniteScroll.addEventListener('ionInfinite', async function () {
|
||||
await wait(500);
|
||||
infiniteScroll.complete();
|
||||
appendItems();
|
||||
// Custom event consumed in the e2e tests
|
||||
document.dispatchEvent(new CustomEvent('ionInfiniteComplete'));
|
||||
});
|
||||
|
||||
function appendItems() {
|
||||
for (var i = 0; i < 30; i++) {
|
||||
const el = document.createElement('ion-item');
|
||||
el.textContent = `${++count}`;
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
function wait(time) {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, time);
|
||||
});
|
||||
}
|
||||
|
||||
appendItems();
|
||||
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
<ion-content scroll-y="false">
|
||||
<ion-refresher slot="fixed">
|
||||
<ion-refresher-content></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
<virtual-scroll-element class="ion-content-scroll-host">
|
||||
<!-- Your virtual scroll content -->
|
||||
</virtual-scroll-element>
|
||||
</ion-content>
|
||||
```
|
||||
|
||||
## Interfaces
|
||||
|
||||
### RefresherEventDetail
|
||||
|
||||
@@ -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('<ion-refresher> must be used inside an <ion-content>');
|
||||
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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
53
core/src/components/refresher/test/scroll-target/e2e.ts
Normal file
53
core/src/components/refresher/test/scroll-target/e2e.ts
Normal file
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
93
core/src/components/refresher/test/scroll-target/index.html
Normal file
93
core/src/components/refresher/test/scroll-target/index.html
Normal file
@@ -0,0 +1,93 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Refresher - Custom Scroll Target</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>
|
||||
<style>
|
||||
#content {
|
||||
position: relative;
|
||||
|
||||
display: block;
|
||||
flex: 1;
|
||||
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
contain: size style;
|
||||
}
|
||||
|
||||
#inner-scroll {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<ion-app>
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Pull To Refresh</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content scroll-y="false">
|
||||
<ion-refresher id="refresher" slot="fixed">
|
||||
<ion-refresher-content></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
<div id="content" class="ion-content-scroll-host">
|
||||
<div id="inner-scroll">
|
||||
<ion-list id="list"></ion-list>
|
||||
</div>
|
||||
</div>
|
||||
</ion-content>
|
||||
</ion-app>
|
||||
|
||||
<script>
|
||||
let items = createItems();
|
||||
|
||||
const list = document.getElementById('list');
|
||||
const refresher = document.getElementById('refresher');
|
||||
|
||||
refresher.addEventListener('ionRefresh', async function () {
|
||||
const data = await getAsyncData();
|
||||
items = items.concat(data);
|
||||
refresher.complete();
|
||||
render();
|
||||
// Custom event consumed by e2e tests
|
||||
document.dispatchEvent(new CustomEvent('ionRefreshComplete'));
|
||||
});
|
||||
|
||||
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(() => resolve(createItems(items.length)), 500);
|
||||
});
|
||||
}
|
||||
|
||||
function createItems(start = 0) {
|
||||
return new Array(30).fill().map((_, i) => start + i + 1);
|
||||
}
|
||||
|
||||
render();
|
||||
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -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
|
||||
<ion-content scroll-y="false">
|
||||
<virtual-scroll-element class="ion-content-scroll-host">
|
||||
<ion-reorder-group disabled="false">
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
Item 1
|
||||
</ion-label>
|
||||
<ion-reorder slot="end"></ion-reorder>
|
||||
</ion-item>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
Item 2
|
||||
</ion-label>
|
||||
<ion-reorder slot="end"></ion-reorder>
|
||||
</ion-item>
|
||||
</ion-reorder-group>
|
||||
</virtual-scroll-element>
|
||||
</ion-content>
|
||||
```
|
||||
|
||||
## Interfaces
|
||||
|
||||
### ItemReorderEventDetail
|
||||
|
||||
@@ -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<ItemReorderEventDetail>;
|
||||
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
43
core/src/components/reorder-group/test/scroll-target/e2e.ts
Normal file
43
core/src/components/reorder-group/test/scroll-target/e2e.ts
Normal file
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Reorder - Custom Scroll Target</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>
|
||||
|
||||
<style>
|
||||
#content {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<ion-app>
|
||||
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Reorder - Custom Scroll Target</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content scroll-y="false">
|
||||
<div id="content" class="ion-content-scroll-host">
|
||||
<ion-list>
|
||||
<ion-reorder-group id="reorder" disabled="false">
|
||||
<ion-reorder id="item-0">
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>Item 0</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-reorder>
|
||||
<ion-reorder id="item-1">
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>Item 1</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-reorder>
|
||||
<ion-reorder id="item-2">
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>Item 2</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-reorder>
|
||||
</ion-reorder-group>
|
||||
</ion-list>
|
||||
</div>
|
||||
</ion-content>
|
||||
|
||||
</ion-app>
|
||||
|
||||
<script>
|
||||
const lists = Array.from(document.getElementsByTagName('ion-reorder-group'));
|
||||
|
||||
for (const list of lists) {
|
||||
list.addEventListener('ionItemReorder', ({ detail }) => detail.complete(true));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -299,7 +299,7 @@ Type: `Promise<void>`
|
||||
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
|
||||
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
189
core/src/utils/content/content.utils.spec.ts
Normal file
189
core/src/utils/content/content.utils.spec.ts
Normal file
@@ -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(<any>{
|
||||
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(<any>{
|
||||
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(<any>{
|
||||
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(<any>{
|
||||
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(<any>{
|
||||
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(<any>{
|
||||
tagName: 'ION-CONTENT',
|
||||
scrollToTop: scrollToTopMock
|
||||
}, 300);
|
||||
|
||||
expect(scrollToTopMock).toHaveBeenCalledWith(300);
|
||||
});
|
||||
|
||||
it('should smooth scroll the element', async () => {
|
||||
const scrollToMock = jest.fn();
|
||||
|
||||
await scrollToTop(<any>{
|
||||
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(<any>{
|
||||
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(<any>{
|
||||
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(<any>{
|
||||
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(<any>{
|
||||
tagName: 'DIV',
|
||||
scrollBy: scrollByMock
|
||||
}, 10, 15, 300);
|
||||
|
||||
expect(scrollByMock).toHaveBeenCalledWith({
|
||||
top: 15,
|
||||
left: 10,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
it('printIonContentErrorMsg should display "<my-el> must be used inside ion-content."', () => {
|
||||
const consoleErrorMock = jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
printIonContentErrorMsg(<any>{
|
||||
tagName: 'MY-EL'
|
||||
});
|
||||
|
||||
expect(consoleErrorMock).toHaveBeenCalledWith('<my-el> must be used inside ion-content.');
|
||||
|
||||
consoleErrorMock.mockRestore();
|
||||
});
|
||||
|
||||
});
|
||||
99
core/src/utils/content/index.ts
Normal file
99
core/src/utils/content/index.ts
Normal file
@@ -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 `<ion-content>` 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<HTMLElement>(ION_CONTENT_CLASS_SELECTOR);
|
||||
if (customContentHost) {
|
||||
return customContentHost;
|
||||
}
|
||||
return el.querySelector<HTMLElement>(ION_CONTENT_SELECTOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the closest element matching the selector for IonContent.
|
||||
*/
|
||||
export const findClosestIonContent = (el: Element) => {
|
||||
return el.closest<HTMLElement>(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<any> => {
|
||||
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);
|
||||
}
|
||||
@@ -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; };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -17,4 +17,16 @@ export const printIonWarning = (message: string) => {
|
||||
*/
|
||||
export const printIonError = (message: string, ...params: any) => {
|
||||
return console.error(`[Ionic Error]: ${message}`, ...params);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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 ')}.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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<any> => {
|
||||
return new Promise(resolve => {
|
||||
return new Promise<void>(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<HTMLElement>(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.
|
||||
|
||||
Reference in New Issue
Block a user