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
96 changes: 96 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,102 @@ describe('ObjectPage', () => {
cy.findByText('Custom Header Section Two').should('not.be.visible');
cy.findByText('Subsection1').should('be.visible');
});

it('fitContent sections', () => {
const FitContentComp = () => (
<ObjectPage
data-testid="op"
titleArea={DPTitle}
headerArea={DPContent}
mode={ObjectPageMode.IconTabBar}
footerArea={Footer}
style={{ height: '800px' }}
>
<ObjectPageSection titleText="Leaf" id="leaf" aria-label="Leaf" fitContent>
<div data-testid="leaf-scroller" style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
<div style={{ height: '2000px', background: 'lightyellow' }}>Tall leaf content</div>
</div>
</ObjectPageSection>
<ObjectPageSection titleText="Subsections" id="subs" aria-label="Subsections" fitContent>
<ObjectPageSubSection titleText="Sub A" id="subA" aria-label="Sub A">
<div style={{ height: '800px', background: 'lightblue' }}>Content A</div>
</ObjectPageSubSection>
<ObjectPageSubSection titleText="Sub B" id="subB" aria-label="Sub B">
<div style={{ height: '800px', background: 'lightgreen' }}>Content B</div>
</ObjectPageSubSection>
</ObjectPageSection>
<ObjectPageSection titleText="Normal" id="normal" aria-label="Normal">
<div style={{ height: '2000px', background: 'lightsalmon' }}>Normal content</div>
</ObjectPageSection>
</ObjectPage>
);

cy.mount(<FitContentComp />);

// leaf fitContent: content scrolls internally, OP container does not (footer stays pinned)
cy.findByTestId('leaf-scroller').then(($el) => {
const el = $el[0];
expect(el.scrollHeight).to.be.greaterThan(el.clientHeight);
});
// a leaf fitContent section (no subsections) must not become its own scroll container
cy.get('[data-component-name="ObjectPageSection"]').should('not.have.css', 'overflow-y', 'auto');
cy.findByTestId('op').then(($op) => {
expect($op[0].scrollHeight).to.equal($op[0].clientHeight);
});
cy.findByText('Accept').should('be.visible');

// fitContent + subsections: the section becomes its own scroll container, OP container does not scroll
cy.get('[ui5-tabcontainer]').findUi5TabByText('Subsections').click();
cy.wait(100);
cy.get('[data-component-name="ObjectPageSection"]').should('have.css', 'overflow-y', 'auto');
cy.findByTestId('op').then(($op) => {
expect($op[0].scrollHeight).to.equal($op[0].clientHeight);
});
cy.findByText('Accept').should('be.visible');

// regression: unitless `0` broke the sticky offset calc() and fell back to `auto`
cy.findByText('Sub A')
.parent()
.should('have.css', 'position', 'sticky')
.and(($el) => {
expect($el.css('inset-block-start')).to.not.equal('auto');
});

// sticky header sticks to the section top, not below the ObjectPage header
cy.get('[data-component-name="ObjectPageSection"]').scrollTo(0, 400);
cy.findByText('Sub A')
.parent()
.then(($hdr) => {
const header = $hdr[0];
const section = header.closest('[data-component-name="ObjectPageSection"]');
expect(Math.abs(header.getBoundingClientRect().top - section.getBoundingClientRect().top)).to.be.lessThan(3);
});

// sub-tab scroll-to scrolls the section (not the OP) and lands the subsection at the section top
cy.get('[data-component-name="ObjectPageSection"]').scrollTo('top');
cy.get('[ui5-tabcontainer]').findUi5TabOpenPopoverButtonByText('Subsections').click();
cy.get('[ui5-list]').should('be.visible');
cy.wait(200);
cy.realPress('ArrowDown');
cy.realPress('ArrowDown');
cy.realPress('Enter');
cy.wait(300);
cy.get('[data-component-name="ObjectPageSection"]').then(($section) => {
const section = $section[0];
expect(section.scrollTop).to.be.greaterThan(0);
const targetTop = document.getElementById('ObjectPageSubSection-subB').getBoundingClientRect().top;
const sectionTop = section.getBoundingClientRect().top;
expect(Math.abs(targetTop - sectionTop)).to.be.lessThan(40);
});

// non-fitContent section keeps the normal page scroll (OP container scrolls)
cy.get('[ui5-tabcontainer]').findUi5TabByText('Normal').click();
cy.wait(100);
cy.get('[data-component-name="ObjectPageSection"]').should('not.have.css', 'overflow-y', 'auto');
cy.findByTestId('op').then(($op) => {
expect($op[0].scrollHeight).to.be.greaterThan($op[0].clientHeight);
});
});
});

const DPTitle = (
Expand Down
27 changes: 22 additions & 5 deletions packages/main/src/components/ObjectPage/ObjectPage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -72,19 +72,36 @@ This component exposes public methods. You can use them directly on the instance

<Canvas of={ComponentStories.SectionWithCustomHeader} />

## TabBar ObjectPage with fullscreen section
## IconTabBar ObjectPage with fullscreen section

To render a single section in fullscreen mode, set its height to `100%`.
To render a section in fullscreen mode, so that it fills the remaining available height instead of growing with its content, set the `fitContent` prop on the `ObjectPageSection`. To make content fill or scroll within the section, wrap it in an element with `flex: 1` and `min-height: 0`.

**Note:** This is only supported for sections in `TabBar` mode! Using multiple sections with `height: 100%;` on the same page will most probably break your layout.
**Note:** `fitContent` is only supported in `IconTabBar` mode.

<Canvas of={ComponentStories.FullScreenSingleSection} />

### Example section

```jsx
<ObjectPageSection titleText="Section with Overflow" id="section3" style={{ height: '100%', overflow: 'auto' }}>
<div style={{ height: '300%', background: 'lightyellow' }} />
<ObjectPageSection titleText="Section with Overflow" id="section3" fitContent>
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
<div style={{ height: '300%', background: 'lightyellow' }} />
</div>
</ObjectPageSection>
```

### Sections with subsections

When a `fitContent` section contains `ObjectPageSubSection`s, keep them as direct children — don't wrap them. The section becomes the scroll container itself, so overflowing subsections scroll within it while the footer stays visible.

```jsx
<ObjectPageSection titleText="Details" id="details" fitContent>
<ObjectPageSubSection titleText="Subsection 1" id="sub1">
<div style={{ height: '80vh' }} />
</ObjectPageSubSection>
<ObjectPageSubSection titleText="Subsection 2" id="sub2">
<div style={{ height: '80vh' }} />
</ObjectPageSubSection>
</ObjectPageSection>
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,13 @@
z-index: 0;
}

/* compound selector for specificity over the base .content rule */
.content.fitContent {
display: flex;
flex-direction: column;
min-height: 0;
}

@container (max-width: 599px) {
.header,
.headerContainer {
Expand Down
101 changes: 93 additions & 8 deletions packages/main/src/components/ObjectPage/ObjectPage.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { fn } from 'storybook/test';
import { Toolbar as LegacyToolbar, ToolbarSpacer as LegacyToolbarSpacer } from '../../../../compat/src/index.js';
import type { ObjectPageDomRef } from '../../index.js';
import {
AnalyticalTable,
AnalyticalTableVisibleRowCountMode,
Bar,
Breadcrumbs,
BreadcrumbsItem,
Expand All @@ -33,6 +35,12 @@ import {
ObjectPageMode,
ObjectPageSection,
ObjectPageSubSection,
Table,
TableCell,
TableGrowing,
TableHeaderCell,
TableHeaderRow,
TableRow,
Text,
Title,
Toolbar,
Expand All @@ -41,6 +49,18 @@ import {
import { Tag } from '../../webComponents/Tag/index.js';
import { ObjectPage } from './index.js';

const tableRows = Array.from({ length: 60 }, (_, i) => ({
name: `Employee ${i + 1}`,
role: i % 2 ? 'Developer' : 'Designer',
location: i % 3 ? 'Walldorf' : 'San Jose',
}));

const tableColumns = [
{ accessor: 'name', Header: 'Name' },
{ accessor: 'role', Header: 'Role' },
{ accessor: 'location', Header: 'Location' },
];

const meta = {
title: 'Layouts & Floorplans / ObjectPage',
component: ObjectPage,
Expand Down Expand Up @@ -426,22 +446,87 @@ export const SectionWithCustomHeader: Story = {
};

export const FullScreenSingleSection: Story = {
args: { selectedSectionId: 'section1' },
name: 'with fullscreen section',
render(args) {
return (
<ObjectPage {...args} mode={ObjectPageMode.IconTabBar} onBeforeNavigate={args.onBeforeNavigate}>
<ObjectPageSection titleText="Section 1" id="section1" style={{ height: '100%' }}>
<ObjectPageSection titleText="Section 1" id="section1" fitContent>
<div style={{ height: '100%', background: 'lightblue' }}>
It is recommended to only use fullscreen sections in TabBar mode, otherwise your layout will most probably
break!
It is recommended to only use fullscreen sections in `IconTabBar` mode, otherwise your layout will most
probably break!
</div>
</ObjectPageSection>
<ObjectPageSection titleText="Analytical Table" id="analytical" fitContent>
<div style={{ flex: 1, minHeight: 0 }}>
<AnalyticalTable
columns={tableColumns}
data={tableRows}
visibleRowCountMode={AnalyticalTableVisibleRowCountMode.AutoWithEmptyRows}
/>
</div>
</ObjectPageSection>
<ObjectPageSection titleText="Section 2" id="section2" style={{ height: '100%' }}>
<div style={{ height: '100%', background: 'lightgreen' }} />
<ObjectPageSection titleText="Responsive Table" id="table" fitContent>
<Table
overflowMode="Scroll"
style={{ flex: 1, minHeight: 0 }}
headerRow={
<TableHeaderRow sticky>
<TableHeaderCell minWidth="200px">Name</TableHeaderCell>
<TableHeaderCell minWidth="150px">Role</TableHeaderCell>
<TableHeaderCell minWidth="150px">Location</TableHeaderCell>
</TableHeaderRow>
}
features={<TableGrowing mode="Scroll" />}
>
{tableRows.map((row, i) => (
<TableRow key={i} rowKey={String(i)}>
<TableCell>
<span>{row.name}</span>
</TableCell>
<TableCell>
<span>{row.role}</span>
</TableCell>
<TableCell>
<span>{row.location}</span>
</TableCell>
</TableRow>
))}
</Table>
</ObjectPageSection>
<ObjectPageSection titleText="Section with Overflow" id="section3" fitContent>
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
<div style={{ height: '300%', background: 'lightyellow' }} />
</div>
</ObjectPageSection>
<ObjectPageSection titleText="Section with Overflow" id="section3" style={{ height: '100%', overflow: 'auto' }}>
<div style={{ height: '300%', background: 'lightyellow' }} />
<ObjectPageSection titleText="Subsections" id="subsections" fitContent>
<ObjectPageSubSection titleText="Subsection 1" id="subsections-1">
<div style={{ background: 'lightblue', padding: '1rem' }}>
Fullscreen section with subsections. The content fits, so nothing scrolls.
</div>
</ObjectPageSubSection>
<ObjectPageSubSection titleText="Subsection 2" id="subsections-2">
<div style={{ background: 'lightgreen', padding: '1rem' }}>More content that still fits.</div>
</ObjectPageSubSection>
</ObjectPageSection>
<ObjectPageSection titleText="Subsections with Overflow" id="subsections-overflow" fitContent>
{Array.from({ length: 5 }, (_, i) => (
<ObjectPageSubSection key={i} titleText={`Subsection ${i + 1}`} id={`subsections-overflow-${i + 1}`}>
<div style={{ height: '60vh', background: i % 2 ? 'lightgreen' : 'lightblue' }}>
Tall subsection content — the section scrolls internally while the footer stays visible.
</div>
</ObjectPageSubSection>
))}
</ObjectPageSection>
<ObjectPageSection titleText="Section without fullscreen" id="section4">
<div style={{ background: 'lightgreen' }}>
This section doesn&apos;t use `fitContent`, so it grows with its content and the page scrolls normally.
</div>
</ObjectPageSection>
<ObjectPageSection titleText="Section without fullscreen (overflow)" id="section5">
<div style={{ height: '150vh', background: 'lightsalmon' }}>
This section doesn&apos;t use `fitContent` and its content exceeds the viewport, so the standard page scroll
is visible.
</div>
</ObjectPageSection>
</ObjectPage>
);
Expand Down
24 changes: 21 additions & 3 deletions packages/main/src/components/ObjectPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
() => (mode === ObjectPageMode.IconTabBar ? getSectionById(children, internalSelectedSectionId) : null),
[mode, children, internalSelectedSectionId],
);
const isActiveSectionFitContent =
mode === ObjectPageMode.IconTabBar &&
isValidElement<ObjectPageSectionPropTypes>(currentTabModeSection) &&
!!currentTabModeSection.props.fitContent;
const [toggledCollapsedHeaderWasVisible, setToggledCollapsedHeaderWasVisible] = useState(false);
const sections = mode === ObjectPageMode.IconTabBar ? currentTabModeSection : children;
const scrollEndHandler = useOnScrollEnd({ objectPageRef, setTabSelectId });
Expand Down Expand Up @@ -255,6 +259,19 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
const section = getSectionElementById(objectPageRef.current, isSubSection, id);
scrollTimeout.current = performance.now() + 500;
if (section) {
// fitContent sections with subsections are their own scroll container, so scroll the section, not the ObjectPage
const fitContentScroller =
isSubSection && isActiveSectionFitContent
? section.closest<HTMLElement>('[data-component-name="ObjectPageSection"]')
: null;
if (fitContentScroller && fitContentScroller.scrollHeight > fitContentScroller.clientHeight) {
section.focus({ preventScroll: true });
const sectionRect = section.getBoundingClientRect();
const scrollerRect = fitContentScroller.getBoundingClientRect();
fitContentScroller.scrollTop = sectionRect.top - scrollerRect.top + fitContentScroller.scrollTop;
return;
}

const safeTopHeaderHeight = topHeaderHeight || prevTopHeaderHeight.current;

const scrollMargin =
Expand Down Expand Up @@ -293,6 +310,7 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
headerPinned,
headerCollapsed,
headerContentHeight,
isActiveSectionFitContent,
],
);

Expand Down Expand Up @@ -871,7 +889,7 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
)}
<div
data-component-name="ObjectPageContent"
className={classNames.content}
className={clsx(classNames.content, isActiveSectionFitContent && classNames.fitContent)}
ref={(node) => {
if (node) {
if (mode === ObjectPageMode.IconTabBar && wasUserSectionChange) {
Expand Down Expand Up @@ -905,9 +923,9 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
aria-hidden="true"
/>
{placeholder ? placeholder : sections}
<div style={{ height: `${sectionSpacer}px` }} aria-hidden="true" />
<div style={{ height: `${isActiveSectionFitContent ? 0 : sectionSpacer}px` }} aria-hidden="true" />
</div>
{footerArea && mode === ObjectPageMode.IconTabBar && !sectionSpacer && (
{footerArea && mode === ObjectPageMode.IconTabBar && !sectionSpacer && !isActiveSectionFitContent && (
<div className={classNames.footerSpacer} data-component-name="ObjectPageFooterSpacer" aria-hidden="true" />
)}
{footerArea && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,33 @@
box-sizing: border-box;
}

.fitContent {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;

.sectionContent {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}

.sectionContentInner {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
}

/* fitContent sections with subsections scroll themselves; reset the header offset so sticky subsection headers stick to the section top */
.fitContentScroll {
--_ui5wcr_ObjectPage_header_height: 0px;
overflow: auto;
}

@container (max-width: 599px) {
.section {
padding-inline: 1rem;
Expand Down
Loading
Loading