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
176 changes: 176 additions & 0 deletions packages/main/src/components/ObjectPage/ObjectPage.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ObjectPage
data-testid="op"
titleArea={DPTitle}
headerArea={
<ObjectPageHeader>
<div style={{ height: '250px', width: '100%', background: 'lightyellow' }}>ObjectPageHeader</div>
</ObjectPageHeader>
}
mode={ObjectPageMode.IconTabBar}
style={{ height: '95vh', scrollBehavior: 'auto' }}
>
<ObjectPageSection key="s1" id="s1" titleText="Section 1">
<div style={{ height: '2000px', background: 'lightblue' }}>section 1 content</div>
</ObjectPageSection>
<ObjectPageSection key="s2" id="s2" titleText="Section 2">
<div style={{ height: '2000px', background: 'lightgreen' }}>section 2 content</div>
</ObjectPageSection>
</ObjectPage>,
);

// 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(
<ObjectPage
data-testid="op"
titleArea={DPTitle}
headerArea={
<ObjectPageHeader>
<div style={{ height: '250px', width: '100%', background: 'lightyellow' }}>ObjectPageHeader</div>
</ObjectPageHeader>
}
mode={ObjectPageMode.IconTabBar}
style={{ height: '95vh', scrollBehavior: 'auto' }}
>
<ObjectPageSection key="s1" id="s1" titleText="Section 1">
<ObjectPageSubSection id="s1-sub1" titleText="Subsection 1" aria-label="Subsection 1">
<div style={{ height: '1500px', background: 'lightblue' }}>subsection 1 content</div>
</ObjectPageSubSection>
<ObjectPageSubSection id="s1-sub2" titleText="Subsection 2" aria-label="Subsection 2">
<div style={{ height: '1500px', background: 'lightgreen' }}>subsection 2 content</div>
</ObjectPageSubSection>
</ObjectPageSection>
<ObjectPageSection key="s2" id="s2" titleText="Section 2">
<div style={{ height: '2000px', background: 'lightyellow' }}>section 2 content</div>
</ObjectPageSection>
</ObjectPage>,
);

// 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(
<ObjectPage
data-testid="op"
titleArea={DPTitle}
headerArea={
<ObjectPageHeader>
<div style={{ height: '250px', width: '100%', background: 'lightyellow' }}>ObjectPageHeader</div>
</ObjectPageHeader>
}
mode={mode}
style={{ height: '95vh', scrollBehavior: 'auto' }}
>
<ObjectPageSection key="s1" id="s1" titleText="Section 1">
<div style={{ height: '2000px', background: 'lightblue' }}>section 1 content</div>
</ObjectPageSection>
<ObjectPageSection key="s2" id="s2" titleText="Section 2">
<div style={{ height: '2000px', background: 'lightgreen' }}>section 2 content</div>
</ObjectPageSection>
</ObjectPage>,
);

const spacerHeight = ($op: JQuery<HTMLElement>) =>
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 = (
Expand Down
31 changes: 25 additions & 6 deletions packages/main/src/components/ObjectPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((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 | string>(undefined);
Expand Down Expand Up @@ -176,6 +178,7 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((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}`;
Expand All @@ -186,15 +189,18 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((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;
}
}
setHeaderCollapsedInternal(true);
setScrolledHeaderExpanded(false);
} else {
manuallyCollapsedRef.current = false;
setHeaderCollapsedInternal(false);
if (objectPageRef.current.scrollTop >= headerContentHeight && objectPageRef.current.scrollTop > 0) {
setScrolledHeaderExpanded(true);
Expand Down Expand Up @@ -325,7 +331,13 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((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;
Expand Down Expand Up @@ -620,7 +632,16 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((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);
Expand Down Expand Up @@ -896,9 +917,7 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
<div
style={{
height:
((headerCollapsed && !headerPinned) || scrolledHeaderExpanded) &&
!toggledCollapsedHeaderWasVisible &&
!(mode === ObjectPageMode.IconTabBar && scrollTimeout.current >= performance.now())
((headerCollapsed && !headerPinned) || scrolledHeaderExpanded) && !toggledCollapsedHeaderWasVisible
? `${headerContentHeight}px`
: 0,
}}
Expand Down
6 changes: 6 additions & 0 deletions packages/main/src/internal/useObserveHeights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ export const useObserveHeights = (
fixedHeader = false,
scrollTimeout = { current: 0 },
preserveHeaderStateOnScroll,
manuallyCollapsed = { current: false },
}: {
noHeader: boolean;
fixedHeader?: boolean;
scrollTimeout?: MutableRefObject<number>;
preserveHeaderStateOnScroll?: boolean;
manuallyCollapsed?: MutableRefObject<boolean>;
},
) => {
const [topHeaderHeight, setTopHeaderHeight] = useState(0);
Expand All @@ -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);
Expand Down
Loading