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