Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/main/src/components/ObjectPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((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}`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ObjectPage
mode={ObjectPageMode.IconTabBar}
style={{ height: '100vh' }}
titleArea={
<ObjectPageTitle
header={<Title>Denise Smith</Title>}
snappedHeader={<Title>Denise Smith (snapped)</Title>}
subHeader="Senior UI Developer"
/>
}
headerArea={
<ObjectPageHeader>
<div>Priority: Very High</div>
<div>Status: Sent</div>
</ObjectPageHeader>
}
>
<ObjectPageSection titleText="Discussion" id="discussion" aria-label="Discussion">
<div
style={{ height: 'calc(100vh - 150px)', display: 'flex', flexDirection: 'column', background: 'lightblue' }}
>
<div>Scroll me down to see the bottom</div>
<div style={{ marginTop: 'auto' }}>Bottom content</div>
</div>
</ObjectPageSection>
<ObjectPageSection titleText="Details" id="details" aria-label="Details">
<div style={{ height: '1500px', width: '100%', background: 'lightsalmon' }}>Details content</div>
</ObjectPageSection>
</ObjectPage>
);
};

// 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
13 changes: 12 additions & 1 deletion packages/main/src/internal/useObserveHeights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ export const useObserveHeights = (
scrollTimeout = { current: 0 },
preserveHeaderStateOnScroll,
manuallyCollapsed = { current: false },
suppressScrollCollapse = false,
}: {
noHeader: boolean;
fixedHeader?: boolean;
scrollTimeout?: MutableRefObject<number>;
preserveHeaderStateOnScroll?: boolean;
manuallyCollapsed?: MutableRefObject<boolean>;
suppressScrollCollapse?: boolean;
},
) => {
const [topHeaderHeight, setTopHeaderHeight] = useState(0);
Expand All @@ -42,6 +44,15 @@ export const useObserveHeights = (
}

if (scrollDown && e.target.scrollTop >= headerContentHeight && !headerCollapsed) {
// 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);
setHeaderCollapsed(true);
} else if (
Expand All @@ -53,7 +64,7 @@ export const useObserveHeights = (
setHeaderCollapsed(false);
}
},
[headerContentHeight, topHeaderHeight, headerCollapsed],
[headerContentHeight, topHeaderHeight, headerCollapsed, suppressScrollCollapse],
);

useEffect(() => {
Expand Down
Loading