diff --git a/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx b/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx index e7ccc502041..94c724dad80 100644 --- a/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx +++ b/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx @@ -1931,6 +1931,182 @@ describe('ObjectPage', () => { cy.findByText('Custom Header Section Two').should('not.be.visible'); cy.findByText('Subsection1').should('be.visible'); }); + + it('IconTabBar: tab switch preserves header collapsed/expanded state and stays scroll-expandable', () => { + cy.viewport(1440, 900); + cy.mount( + +
ObjectPageHeader
+ + } + mode={ObjectPageMode.IconTabBar} + style={{ height: '95vh', scrollBehavior: 'auto' }} + > + +
section 1 content
+
+ +
section 2 content
+
+
, + ); + + // switching tabs while the header is expanded keeps it expanded and scrolled to the top + cy.findByText('ObjectPageHeader').should('be.visible'); + cy.get('[ui5-tabcontainer]').findUi5TabByText('Section 2').click(); + cy.wait(600); + cy.findByText('ObjectPageHeader').should('be.visible'); + cy.findByTestId('op').then(($op) => { + expect($op[0].scrollTop).to.equal(0); + }); + + // scroll down far enough to collapse the header + cy.findByTestId('op').scrollTo(0, 800); + cy.findByText('ObjectPageHeader').should('not.be.visible'); + + // switching tabs while collapsed must preserve the collapsed state (no auto-expand) + cy.get('[ui5-tabcontainer]').findUi5TabByText('Section 1').click(); + cy.wait(600); // let the scrollTimeout window pass + cy.findByText('ObjectPageHeader').should('not.be.visible'); + // ...and must not reset scroll to the very top, otherwise there is no room to scroll up and re-expand + cy.findByTestId('op').then(($op) => { + expect($op[0].scrollTop).to.be.greaterThan(0); + }); + + // scrolling up on the new tab re-expands the header without scrolling down first + cy.findByTestId('op').scrollTo(0, 0); + cy.findByText('ObjectPageHeader').should('be.visible'); + }); + + it('IconTabBar: subsection navigation while the header is collapsed keeps it collapsed', () => { + cy.viewport(1440, 900); + cy.mount( + +
ObjectPageHeader
+ + } + mode={ObjectPageMode.IconTabBar} + style={{ height: '95vh', scrollBehavior: 'auto' }} + > + + +
subsection 1 content
+
+ +
subsection 2 content
+
+
+ +
section 2 content
+
+
, + ); + + // collapse the header by scrolling + cy.findByTestId('op').scrollTo(0, 800); + cy.findByText('ObjectPageHeader').should('not.be.visible'); + + // navigate to the second subsection via the tab popover while collapsed + cy.get('[ui5-tabcontainer]').findUi5TabOpenPopoverButtonByText('Section 1').click(); + cy.get('[ui5-list]').should('be.visible'); + cy.wait(200); + cy.realPress('ArrowDown'); + cy.realPress('ArrowDown'); + cy.realPress('Enter'); + cy.wait(500); + + // header stays collapsed and the target subsection is shown + cy.findByText('subsection 2 content').should('be.visible'); + cy.findByText('ObjectPageHeader').should('not.be.visible'); + }); + + [ObjectPageMode.Default, ObjectPageMode.IconTabBar].forEach((mode) => { + it(`manually collapsed header stays flush (no white-space band) and re-expands at the top (mode: ${mode})`, () => { + cy.viewport(1440, 900); + cy.mount( + +
ObjectPageHeader
+ + } + mode={mode} + style={{ height: '95vh', scrollBehavior: 'auto' }} + > + +
section 1 content
+
+ +
section 2 content
+
+
, + ); + + const spacerHeight = ($op: JQuery) => + Math.round( + ( + $op[0].querySelector('[data-component-name="ObjectPageContent"]')?.firstElementChild as HTMLElement + ).getBoundingClientRect().height, + ); + + const assertManualCollapseStaysFlush = () => { + // collapse manually while scrolled down: must stay flush, no spacer + cy.findByTestId('op').scrollTo(0, 700); + cy.findByText('ObjectPageHeader').should('not.be.visible'); + cy.get('[data-component-name="ObjectPageAnchorBarExpandBtn"]').click(); + cy.findByText('ObjectPageHeader').should('be.visible'); + cy.wait(600); // scrollTimeout + cy.get('[data-component-name="ObjectPageAnchorBarExpandBtn"]').click(); + cy.findByText('ObjectPageHeader').should('not.be.visible'); + cy.wait(600); + cy.findByTestId('op').should(($op) => { + expect(spacerHeight($op)).to.equal(0); + }); + + // collapse manually at the top, then scroll down: header stays collapsed and flush (no spacer) + cy.findByTestId('op').scrollTo(0, 0); + cy.findByText('ObjectPageHeader').should('be.visible'); + cy.wait(600); + cy.get('[data-component-name="ObjectPageAnchorBarExpandBtn"]').click(); + cy.findByText('ObjectPageHeader').should('not.be.visible'); + cy.wait(600); + cy.findByTestId('op').should(($op) => { + expect($op[0].scrollTop).to.equal(0); + expect(spacerHeight($op)).to.equal(0); + }); + cy.findByTestId('op').scrollTo(0, 400); + cy.wait(300); + cy.findByTestId('op').should(($op) => { + expect(spacerHeight($op)).to.equal(0); + }); + cy.findByText('ObjectPageHeader').should('not.be.visible'); + + // scrolling back up to the top re-expands the header + cy.findByTestId('op').scrollTo(0, 0); + cy.findByText('ObjectPageHeader').should('be.visible'); + }; + + // Default mode only renders the first section at the top, so the behavior applies there. + assertManualCollapseStaysFlush(); + + // IconTabBar mode renders every section at the top, so it must work after switching tabs too. + if (mode === ObjectPageMode.IconTabBar) { + cy.get('[ui5-tabcontainer]').findUi5TabByText('Section 2').click(); + cy.wait(600); + assertManualCollapseStaysFlush(); + } + }); + }); }); const DPTitle = ( diff --git a/packages/main/src/components/ObjectPage/index.tsx b/packages/main/src/components/ObjectPage/index.tsx index c980801409a..2df6f2c6b7b 100644 --- a/packages/main/src/components/ObjectPage/index.tsx +++ b/packages/main/src/components/ObjectPage/index.tsx @@ -110,6 +110,8 @@ const ObjectPage = forwardRef((props, ref const selectionScrollTimeout = useRef(null); const isToggledRef = useRef(false); const scrollTimeout = useRef(0); + // Set on manual collapse (toggle button/title): header stays collapsed without a scroll spacer and re-expands only at scrollTop 0. + const manuallyCollapsedRef = useRef(false); const prevInternalSelectedSectionId = useRef(internalSelectedSectionId); const [selectedSubSectionId, setSelectedSubSectionId] = useState(undefined); @@ -176,6 +178,7 @@ const ObjectPage = forwardRef((props, ref noHeader: !titleArea && !headerArea, fixedHeader: headerPinned, scrollTimeout, + manuallyCollapsed: manuallyCollapsedRef, }, ); const scrollPaddingBlock = `${Math.ceil(12 + topHeaderHeight + tabContainerHeaderHeight + (!headerCollapsed && headerPinned ? headerContentHeight : 0))}px ${footerArea ? 'calc(var(--_ui5wcr-BarHeight) + 1.25rem)' : 0}`; @@ -186,8 +189,10 @@ const ObjectPage = forwardRef((props, ref scrollTimeout.current = performance.now() + 500; setToggledCollapsedHeaderWasVisible(false); if (!e.detail.visible) { + // Manual collapse: no scroll spacer, re-expand via button or by scrolling back to the top. + manuallyCollapsedRef.current = true; + setToggledCollapsedHeaderWasVisible(true); if (objectPageRef.current.scrollTop <= headerContentHeight) { - setToggledCollapsedHeaderWasVisible(true); if (firstSectionId === internalSelectedSectionId || mode === ObjectPageMode.IconTabBar) { objectPageRef.current.scrollTop = 0; } @@ -195,6 +200,7 @@ const ObjectPage = forwardRef((props, ref setHeaderCollapsedInternal(true); setScrolledHeaderExpanded(false); } else { + manuallyCollapsedRef.current = false; setHeaderCollapsedInternal(false); if (objectPageRef.current.scrollTop >= headerContentHeight && objectPageRef.current.scrollTop > 0) { setScrolledHeaderExpanded(true); @@ -325,7 +331,13 @@ const ObjectPage = forwardRef((props, ref // Reset scroll for section swap; scrollTimeout preserves current header collapsed/expanded state. if (mode === ObjectPageMode.IconTabBar) { scrollTimeout.current = performance.now() + 500; - objectPageRef.current?.scrollTo({ top: 0 }); + // Section swap returns to the default collapsed behavior (spacer present, scroll-up re-expands). + manuallyCollapsedRef.current = false; + setToggledCollapsedHeaderWasVisible(false); + // When collapsed, land 1px past the expand threshold so the header stays collapsed but scroll-up can re-expand it. + objectPageRef.current?.scrollTo({ + top: headerCollapsed && !headerPinned ? Math.max(headerContentHeight, topHeaderHeight) + 1 : 0, + }); } setTabSelectId(newSelectionSectionId); scrollEvent.current = targetEvent; @@ -620,7 +632,16 @@ const ObjectPage = forwardRef((props, ref if (scrollTimeout.current >= performance.now()) { return; } - setToggledCollapsedHeaderWasVisible(false); + // Manually collapsed header stays flush (no spacer) until scrolled back to the top, where it re-expands. + if (manuallyCollapsedRef.current) { + if (target.scrollTop === 0) { + manuallyCollapsedRef.current = false; + setToggledCollapsedHeaderWasVisible(false); + setHeaderCollapsedInternal(false); + } + } else { + setToggledCollapsedHeaderWasVisible(false); + } scrollEvent.current = e; if (typeof onScrollRef.current === 'function') { onScrollRef.current(e); @@ -896,9 +917,7 @@ const ObjectPage = forwardRef((props, ref
= performance.now()) + ((headerCollapsed && !headerPinned) || scrolledHeaderExpanded) && !toggledCollapsedHeaderWasVisible ? `${headerContentHeight}px` : 0, }} diff --git a/packages/main/src/internal/useObserveHeights.ts b/packages/main/src/internal/useObserveHeights.ts index 72486485ece..0fc4dfeaa41 100644 --- a/packages/main/src/internal/useObserveHeights.ts +++ b/packages/main/src/internal/useObserveHeights.ts @@ -13,11 +13,13 @@ export const useObserveHeights = ( fixedHeader = false, scrollTimeout = { current: 0 }, preserveHeaderStateOnScroll, + manuallyCollapsed = { current: false }, }: { noHeader: boolean; fixedHeader?: boolean; scrollTimeout?: MutableRefObject; preserveHeaderStateOnScroll?: boolean; + manuallyCollapsed?: MutableRefObject; }, ) => { const [topHeaderHeight, setTopHeaderHeight] = useState(0); @@ -34,6 +36,10 @@ export const useObserveHeights = ( if (scrollTimeout.current >= performance.now()) { return; } + // Manually collapsed headers re-expand at scrollTop 0 via the ObjectPage scroll handler, skip the scroll-driven toggle here. + if (manuallyCollapsed.current) { + return; + } if (scrollDown && e.target.scrollTop >= headerContentHeight && !headerCollapsed) { setIsIntersecting(false);