From 345a93040dcb7a1575ccacf12e70be10b8a49cf3 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Thu, 12 Nov 2026 10:14:05 +0100 Subject: [PATCH 1/2] fix(ObjectPage): prevent header flickering on scroll in IconTabBar mode --- .../main/src/components/ObjectPage/index.tsx | 2 + .../ObjectPage/test/ObjectPage.gallery.tsx | 37 ++++++++++++++++ .../ObjectPage/test/ObjectPage.spec.tsx | 43 +++++++++++++++++++ .../main/src/internal/useObserveHeights.ts | 8 +++- 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/packages/main/src/components/ObjectPage/index.tsx b/packages/main/src/components/ObjectPage/index.tsx index 3b01850cc0b..25f9635cac0 100644 --- a/packages/main/src/components/ObjectPage/index.tsx +++ b/packages/main/src/components/ObjectPage/index.tsx @@ -187,6 +187,8 @@ const ObjectPage = forwardRef((props, ref fixedHeader: headerPinned, scrollTimeout, manuallyCollapsed: manuallyCollapsedRef, + suppressScrollCollapse: + mode === ObjectPageMode.IconTabBar && !isActiveSectionFitContent && !isActiveTabSectionTallEnough, }, ); const scrollPaddingBlock = `${Math.ceil(12 + topHeaderHeight + tabContainerHeaderHeight + (!headerCollapsed && headerPinned ? headerContentHeight : 0))}px ${footerArea ? 'calc(var(--_ui5wcr-BarHeight) + 1.25rem)' : 0}`; diff --git a/packages/main/src/components/ObjectPage/test/ObjectPage.gallery.tsx b/packages/main/src/components/ObjectPage/test/ObjectPage.gallery.tsx index d8f2446f753..32a6dc0878e 100644 --- a/packages/main/src/components/ObjectPage/test/ObjectPage.gallery.tsx +++ b/packages/main/src/components/ObjectPage/test/ObjectPage.gallery.tsx @@ -84,6 +84,43 @@ export const ObjectPageFitContentTabSwitchTestComp = () => { ); }; +// IconTabBar + a section in the "collapse dead-band": scrollable while the header is expanded, but short enough to fit +// once collapsed. The header must stay expanded on scroll instead of looping collapse/expand. Heights are viewport- +// relative so the section stays in the band for any test viewport. +export const ObjectPageHeaderTwitchTestComp = () => { + return ( + Denise Smith} + snappedHeader={Denise Smith (snapped)} + subHeader="Senior UI Developer" + /> + } + headerArea={ + +
Priority: Very High
+
Status: Sent
+
+ } + > + +
+
Scroll me down to see the bottom
+
Bottom content
+
+
+ +
Details content
+
+
+ ); +}; + // IconTabBar + non-fitContent sections: after collapsing the header and switching to a section shorter than the // viewport, the top header spacer must not remain as dead space above the section (it can't be scrolled away). // A tall section keeps the spacer so scroll-up can re-expand the collapsed header. diff --git a/packages/main/src/components/ObjectPage/test/ObjectPage.spec.tsx b/packages/main/src/components/ObjectPage/test/ObjectPage.spec.tsx index 00f76fdede6..573c4b700fb 100644 --- a/packages/main/src/components/ObjectPage/test/ObjectPage.spec.tsx +++ b/packages/main/src/components/ObjectPage/test/ObjectPage.spec.tsx @@ -126,4 +126,47 @@ test.describe('ObjectPage', () => { await page.getByRole('tab', { name: 'Short' }).click(); await expect.poll(topSpacerHeight).toBe(0); }); + + test('IconTabBar: a dead-band section keeps the header expanded instead of twitching on scroll', async ({ + mount, + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await mount('ObjectPage/ObjectPageHeaderTwitchTestComp'); + + await expect(page.getByText('Priority: Very High')).toBeVisible(); + + // A twitch shows up as the scroll position repeatedly dropping back toward the top. + const { maxScroll, resets, finalTop } = await page.evaluate( + () => + new Promise<{ maxScroll: number; resets: number; finalTop: number }>((resolve) => { + const el = document.querySelector('[data-component-name="ObjectPage"]'); + const maxScroll = el.scrollHeight - el.clientHeight; + let resets = 0; + let prev = 0; + let frames = 0; + const tick = () => { + el.scrollTop = el.scrollTop + 40; + const top = el.scrollTop; + if (top < prev - 10) { + resets++; + } + prev = top; + frames++; + if (frames >= 60) { + resolve({ maxScroll: Math.round(maxScroll), resets, finalTop: Math.round(el.scrollTop) }); + return; + } + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }), + ); + + expect(maxScroll).toBeGreaterThan(0); // section must actually be scrollable + expect(resets).toBe(0); // no collapse/expand twitch + expect(finalTop).toBeGreaterThan(0); // scroll holds at the bottom + await expect(page.getByText('Priority: Very High')).toBeVisible(); + await expect(page.getByText('Bottom content')).toBeInViewport(); + }); }); diff --git a/packages/main/src/internal/useObserveHeights.ts b/packages/main/src/internal/useObserveHeights.ts index 0fc4dfeaa41..3f37cbb6eb9 100644 --- a/packages/main/src/internal/useObserveHeights.ts +++ b/packages/main/src/internal/useObserveHeights.ts @@ -14,12 +14,14 @@ export const useObserveHeights = ( scrollTimeout = { current: 0 }, preserveHeaderStateOnScroll, manuallyCollapsed = { current: false }, + suppressScrollCollapse = false, }: { noHeader: boolean; fixedHeader?: boolean; scrollTimeout?: MutableRefObject; preserveHeaderStateOnScroll?: boolean; manuallyCollapsed?: MutableRefObject; + suppressScrollCollapse?: boolean; }, ) => { const [topHeaderHeight, setTopHeaderHeight] = useState(0); @@ -42,6 +44,10 @@ export const useObserveHeights = ( } if (scrollDown && e.target.scrollTop >= headerContentHeight && !headerCollapsed) { + // Collapsing a section too short to keep the top spacer would shrink the container, clamp scrollTop to 0 and re-expand in a loop. + if (suppressScrollCollapse) { + return; + } setIsIntersecting(false); setHeaderCollapsed(true); } else if ( @@ -53,7 +59,7 @@ export const useObserveHeights = ( setHeaderCollapsed(false); } }, - [headerContentHeight, topHeaderHeight, headerCollapsed], + [headerContentHeight, topHeaderHeight, headerCollapsed, suppressScrollCollapse], ); useEffect(() => { From 70ec0e2d986f68ab9ff34ca58b32d83cb9da26ee Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Fri, 13 Nov 2026 13:01:55 +0100 Subject: [PATCH 2/2] Update useObserveHeights.ts --- packages/main/src/internal/useObserveHeights.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/main/src/internal/useObserveHeights.ts b/packages/main/src/internal/useObserveHeights.ts index 3f37cbb6eb9..80c77edff22 100644 --- a/packages/main/src/internal/useObserveHeights.ts +++ b/packages/main/src/internal/useObserveHeights.ts @@ -44,8 +44,13 @@ export const useObserveHeights = ( } if (scrollDown && e.target.scrollTop >= headerContentHeight && !headerCollapsed) { - // Collapsing a section too short to keep the top spacer would shrink the container, clamp scrollTop to 0 and re-expand in a loop. - if (suppressScrollCollapse) { + // Skip the collapse only when it can't be sustained: the section is flagged too short to keep the top spacer + // and the scroll range left after collapse would clamp back toward the top and re-expand in a loop. + const reExpandThreshold = topHeaderHeight + Math.max(0, headerContentHeight - topHeaderHeight); + if ( + suppressScrollCollapse && + e.target.scrollHeight - e.target.clientHeight - headerContentHeight <= reExpandThreshold + ) { return; } setIsIntersecting(false);