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:
Sean Perkins
2022-03-15 11:47:46 -04:00
committed by GitHub
parent 171020e9d2
commit 2a438da010
38 changed files with 1303 additions and 176 deletions

View File

@@ -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;

View File

@@ -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>

View File

@@ -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 -->

View 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();
}
});
});

View 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>

View File

@@ -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>

View File

@@ -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 -->

View File

@@ -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>

View 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();
}
});
});
});
});

View 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>

View File

@@ -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') {

View File

@@ -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

View File

@@ -16,7 +16,6 @@
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Infinite Scroll - Basic</ion-title>

View File

@@ -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);
});
});

View File

@@ -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>

View File

@@ -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;

View File

@@ -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

View File

@@ -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);

View File

@@ -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';

View 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);
});
});
});

View 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>

View File

@@ -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

View File

@@ -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,

View File

@@ -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);

View 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;
}
};

View File

@@ -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>

View File

@@ -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

View File

@@ -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 || {};

View 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();
});
});

View 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);
}

View File

@@ -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; };
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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 ')}.`
);
}

View File

@@ -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');
});

View File

@@ -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.