diff --git a/packages/main/cypress/specs/List.cy.tsx b/packages/main/cypress/specs/List.cy.tsx index b349c98105a5..0878bb6b3330 100644 --- a/packages/main/cypress/specs/List.cy.tsx +++ b/packages/main/cypress/specs/List.cy.tsx @@ -13,36 +13,17 @@ import Select from "../../src/Select.js"; import Option from "../../src/Option.js"; import CheckBox from "../../src/CheckBox.js"; -describe("List Tests", () => { - it("tests 'loadMore' event fired upon infinite scroll", () => { - cy.mount( - - Laptop Lenovo - IPhone 3 - HP Monitor 24 - Audio cabel - DVD set - HP Monitor 24 - Audio cabel - Last Item - ); - - cy.get("[ui5-list]") - .as("list"); - - cy.get("@list") - .then(list => { - list.get(0)?.addEventListener("ui5-load-more", cy.stub().as("loadMore")); - }) - .shadow() - .find(".ui5-list-scroll-container") - .as("scrollContainer") - .scrollTo("bottom", { duration: 100 }); - - cy.get("@loadMore") - .should("have.been.calledOnce"); - }); +function getGrowingWithScrollList(length: number, height: string = "100px") { + return ( + + {Array.from({ length }, (_, index) => ( + Item {index + 1} + ))} + + ); +} +describe("List Tests", () => { it("Arrow down and up navigation between last item and growing button", () => { cy.mount( @@ -193,6 +174,150 @@ describe("List Tests", () => { }); }); +describe("List - Growing with scroll", () => { + it("tests 'loadMore' event not fired initially when the list did not overflow", () => { + cy.mount( +
+ + + + +
+ ); + + cy.get("input").should("have.value", "0"); + }); + it("tests start marker is present in DOM", () => { + cy.mount(getGrowingWithScrollList(5)); + cy.get("[ui5-list]") + .shadow() + .find(".ui5-list-start-marker") + .should("exist") + .should("have.attr", "tabindex", "-1") + .should("have.attr", "aria-hidden", "true"); + }); + + it("tests end marker is present in DOM", () => { + cy.mount(getGrowingWithScrollList(5)); + cy.get("[ui5-list]") + .shadow() + .find(".ui5-list-end-marker") + .should("exist") + .should("have.attr", "tabindex", "-1") + .should("have.attr", "aria-hidden", "true"); + }); + + it("End marker has correct CSS properties", () => { + cy.mount(getGrowingWithScrollList(5)); + cy.get("[ui5-list]") + .shadow() + .find(".ui5-list-end-marker") + .should("have.css", "display", "inline-block"); + }); + + it("tests start marker observation works when scrolled", () => { + cy.mount(getGrowingWithScrollList(5)); + cy.get("[ui5-list]").as("list"); + + // Initially, start marker should be in view, so _startMarkerOutOfView should be false + cy.get("@list") + .should(($el) => { + const list = $el.get(0); + expect(list._startMarkerOutOfView).to.be.false; + }); + + // Scroll down so start marker goes out of view + cy.get("@list") + .shadow() + .find(".ui5-list-scroll-container") + .scrollTo(0, 200, { duration: 100 }); + + cy.get("@list") + .should(($el) => { + const list = $el.get(0); + expect(list._startMarkerOutOfView).to.be.true; + }); + }); + + it("tests end marker observation works when scrolled to bottom as load-more is being fired", () => { + cy.mount(getGrowingWithScrollList(5)); + cy.get("[ui5-list]").as("list"); + + cy.get("@list") + .then(list => { + list.get(0)?.addEventListener("ui5-load-more", cy.stub().as("loadMore")); + }); + + // Scroll to bottom so end marker becomes visible + cy.get("@list") + .shadow() + .find(".ui5-list-scroll-container") + .scrollTo("bottom", { duration: 100 }); + + // The load-more event should be fired when end marker becomes visible + // (assuming start marker is also out of view due to scrolling) + cy.get("@loadMore") + .should("have.been.called"); + }); + + it("tests rerender/content change does not fire load-more event if conditions are met", () => { + cy.mount( +
+ {getGrowingWithScrollList(5, "")} +
+ ); + + cy.get("[ui5-list]").as("list"); + + cy.get("@list") + .then(list => { + list.get(0)?.addEventListener("ui5-load-more", cy.stub().as("loadMore")); + }); + + // Scroll the container to bottom to meet the conditions + cy.get("@list") + .parent() + .scrollTo("bottom", { duration: 100 }); + + cy.get("@loadMore").invoke("resetHistory"); + + // Simulate rerender by replacing content + cy.get("@list") + .then(($list) => { + $list[0].innerHTML = 'New Item'; + }); + + cy.get("@loadMore") + .should("not.have.been.called"); + }); + + it("tests load-more event fires when the scrollable container is a parent element", () => { + cy.mount( +
+ + {getGrowingWithScrollList(5, "")} + +
+ ); + + cy.get("[ui5-list]").as("list"); + + // Set up load-more event listener + cy.get("@list") + .then(list => { + list.get(0)?.addEventListener("ui5-load-more", cy.stub().as("loadMore")); + }); + + // Scroll the parent container (not the list itself) to bottom + cy.get("#scrollable-container") + .scrollTo("bottom", { duration: 100 }); + + // The load-more event should still fire because intersection observers use viewport + cy.get("@loadMore") + .should("have.been.called"); + }); +}); + describe("List - Accessibility", () => { it("tests active state announcement", () => { cy.mount( @@ -1180,19 +1305,6 @@ describe("List Tests", () => { cy.get("[ui5-li]").first().should("be.focused"); }); - it("tests 'loadMore' event not fired initially when the list did not overflow", () => { - cy.mount( -
- - - - -
- ); - - cy.get("input").should("have.value", "0"); - }); - it("detailPress event is fired", () => { cy.mount(
@@ -1638,26 +1750,6 @@ describe("List Tests", () => { .should("have.attr", "tabindex", "0"); }); - it("End marker has correct CSS properties", () => { - cy.mount( - - Laptop Lenovo - IPhone 3 - HP Monitor 24 - Audio cabel - DVD set - HP Monitor 24 - Audio cabel - Last Item - - ); - - cy.get("[ui5-list]") - .shadow() - .find(".ui5-list-end-marker") - .should("have.css", "display", "inline-block"); - }); - it("Checks if tooltip property value equals the title of li element", () => { cy.mount( diff --git a/packages/main/src/List.ts b/packages/main/src/List.ts index 195c18227460..976f0d7570a3 100644 --- a/packages/main/src/List.ts +++ b/packages/main/src/List.ts @@ -73,7 +73,6 @@ import type CheckBox from "./CheckBox.js"; import type RadioButton from "./RadioButton.js"; import { isInstanceOfListItemGroup } from "./ListItemGroup.js"; import type ListItemGroup from "./ListItemGroup.js"; -import { findVerticalScrollContainer } from "./TableUtils.js"; const INFINITE_SCROLL_DEBOUNCE_RATE = 250; // ms @@ -525,17 +524,17 @@ class List extends UI5Element { static i18nBundle: I18nBundle; _previouslyFocusedItem: ListItemBase | null; _forwardingFocus: boolean; - listEndObserved: boolean; - _handleResizeCallback: ResizeObserverCallback; - initialIntersection: boolean; _selectionRequested?: boolean; _groupCount: number; _groupItemCount: number; - growingIntersectionObserver?: IntersectionObserver | null; + _endIntersectionObserver?: IntersectionObserver | null; + _startIntersectionObserver?: IntersectionObserver | null; _itemNavigation: ItemNavigation; _beforeElement?: HTMLElement | null; _afterElement?: HTMLElement | null; + _startMarkerOutOfView: boolean = false; + handleResizeCallback: ResizeObserverCallback; onItemFocusedBound: (e: CustomEvent) => void; onForwardAfterBound: (e: CustomEvent) => void; onForwardBeforeBound: (e: CustomEvent) => void; @@ -551,20 +550,14 @@ class List extends UI5Element { // Indicates that the List is forwarding the focus before or after the internal ul. this._forwardingFocus = false; - // Indicates if the IntersectionObserver started observing the List - this.listEndObserved = false; - this._itemNavigation = new ItemNavigation(this, { skipItemsSize: PAGE_UP_DOWN_SIZE, // PAGE_UP and PAGE_DOWN will skip trough 10 items navigationMode: NavigationMode.Vertical, getItemsCallback: () => this.getEnabledItems(), }); - this._handleResizeCallback = this._handleResize.bind(this); + this.handleResizeCallback = this._handleResize.bind(this); - // Indicates the List bottom most part has been detected by the IntersectionObserver - // for the first time. - this.initialIntersection = true; this._groupCount = 0; this._groupItemCount = 0; @@ -600,13 +593,14 @@ class List extends UI5Element { onEnterDOM() { registerUI5Element(this, this._updateAssociatedLabelsTexts.bind(this)); DragRegistry.subscribe(this); - ResizeHandler.register(this.getDomRef()!, this._handleResizeCallback); + ResizeHandler.register(this.getDomRef()!, this.handleResizeCallback); } onExitDOM() { deregisterUI5Element(this); this.unobserveListEnd(); - ResizeHandler.deregister(this.getDomRef()!, this._handleResizeCallback); + this.unobserveListStart(); + ResizeHandler.deregister(this.getDomRef()!, this.handleResizeCallback); DragRegistry.unsubscribe(this); } @@ -617,11 +611,10 @@ class List extends UI5Element { onAfterRendering() { this.attachGroupHeaderEvents(); - if (this.growsOnScroll) { - this.observeListEnd(); - } else if (this.listEndObserved) { - this.unobserveListEnd(); - } + this.unobserveListEnd(); + this.unobserveListStart(); + this.observeListEnd(); + this.observeListStart(); if (this.grows) { this.checkListInViewport(); @@ -670,6 +663,10 @@ class List extends UI5Element { return this.shadowRoot!.querySelector(".ui5-list-end-marker"); } + get listStartDOM() { + return this.shadowRoot!.querySelector(".ui5-list-start-marker"); + } + get dropIndicatorDOM(): DropIndicator | null { return this.shadowRoot!.querySelector("[ui5-drop-indicator]"); } @@ -736,13 +733,9 @@ class List extends UI5Element { return this.accessibilityAttributes.growingButton?.name ? undefined : `${this._id}-growingButton-text`; } - get scrollContainer() { - return this.shadowRoot!.querySelector(".ui5-list-scroll-container"); - } - hasGrowingComponent(): boolean { - if (this.growsOnScroll && this.scrollContainer) { - return this.scrollContainer.clientHeight !== this.scrollContainer.scrollHeight; + if (this.growsOnScroll) { + return this._startMarkerOutOfView; } return this.growsWithButton; @@ -824,26 +817,30 @@ class List extends UI5Element { } async observeListEnd() { - if (!this.listEndObserved) { - await renderFinished(); - this.getIntersectionObserver().observe(this.listEndDOM!); - this.listEndObserved = true; - } + await renderFinished(); + this.getEndIntersectionObserver().observe(this.listEndDOM!); } unobserveListEnd() { - if (this.growingIntersectionObserver) { - this.growingIntersectionObserver.disconnect(); - this.growingIntersectionObserver = null; - this.listEndObserved = false; + if (this._endIntersectionObserver) { + this._endIntersectionObserver.disconnect(); + this._endIntersectionObserver = null; } } - onInteresection(entries: Array) { - if (this.initialIntersection) { - this.initialIntersection = false; - return; + async observeListStart() { + await renderFinished(); + this.getStartIntersectionObserver().observe(this.listStartDOM!); + } + + unobserveListStart() { + if (this._startIntersectionObserver) { + this._startIntersectionObserver.disconnect(); + this._startIntersectionObserver = null; } + } + + onEndIntersection(entries: Array) { entries.forEach(entry => { if (entry.isIntersecting) { debounce(this.loadMore.bind(this), INFINITE_SCROLL_DEBOUNCE_RATE); @@ -851,6 +848,12 @@ class List extends UI5Element { }); } + onStartIntersection(entries: Array) { + entries.forEach(entry => { + this._startMarkerOutOfView = !entry.isIntersecting; + }); + } + /* * ITEM SELECTION BASED ON THE CURRENT MODE */ @@ -1443,18 +1446,28 @@ class List extends UI5Element { return this._beforeElement; } - getIntersectionObserver() { - if (!this.growingIntersectionObserver) { - const scrollContainer = this.scrollContainer || findVerticalScrollContainer(this.getDomRef()!); + getEndIntersectionObserver() { + if (!this._endIntersectionObserver) { + this._endIntersectionObserver = new IntersectionObserver(this.onEndIntersection.bind(this), { + root: null, // null means the viewport + rootMargin: "0px", + threshold: 1.0, + }); + } + + return this._endIntersectionObserver; + } - this.growingIntersectionObserver = new IntersectionObserver(this.onInteresection.bind(this), { - root: scrollContainer, - rootMargin: "5px", + getStartIntersectionObserver() { + if (!this._startIntersectionObserver) { + this._startIntersectionObserver = new IntersectionObserver(this.onStartIntersection.bind(this), { + root: null, // null means the viewport + rootMargin: "0px", threshold: 1.0, }); } - return this.growingIntersectionObserver; + return this._startIntersectionObserver; } } diff --git a/packages/main/src/ListTemplate.tsx b/packages/main/src/ListTemplate.tsx index d6262b1bdd9d..b3fd20cd80b2 100644 --- a/packages/main/src/ListTemplate.tsx +++ b/packages/main/src/ListTemplate.tsx @@ -29,7 +29,10 @@ export default function ListTemplate(this: List) { active={this.showBusyIndicatorOverlay} class="ui5-list-busy-indicator" > +
+ + {this.header.length > 0 && } {this.shouldRenderH1 &&