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:
@@ -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>
|
||||
Reference in New Issue
Block a user