From 0cb2f05d5891cac2ec0cf11405e23238a2760915 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Tue, 4 Aug 2026 12:17:34 +0200 Subject: [PATCH] feat(ObjectPage): add `fitContent` prop for full-height sections in tab-bar mode --- .../components/ObjectPage/ObjectPage.cy.tsx | 96 +++++++++++++++++ .../src/components/ObjectPage/ObjectPage.mdx | 27 ++++- .../ObjectPage/ObjectPage.module.css | 7 ++ .../ObjectPage/ObjectPage.stories.tsx | 101 ++++++++++++++++-- .../main/src/components/ObjectPage/index.tsx | 24 ++++- .../ObjectPageSection.module.css | 27 +++++ .../components/ObjectPageSection/index.tsx | 19 +++- 7 files changed, 284 insertions(+), 17 deletions(-) diff --git a/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx b/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx index e7ccc502041..f49c131b780 100644 --- a/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx +++ b/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx @@ -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 = () => ( + + +
+
Tall leaf content
+
+
+ + +
Content A
+
+ +
Content B
+
+
+ +
Normal content
+
+
+ ); + + cy.mount(); + + // 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 = ( diff --git a/packages/main/src/components/ObjectPage/ObjectPage.mdx b/packages/main/src/components/ObjectPage/ObjectPage.mdx index f0d929e8492..ee64b317568 100644 --- a/packages/main/src/components/ObjectPage/ObjectPage.mdx +++ b/packages/main/src/components/ObjectPage/ObjectPage.mdx @@ -72,19 +72,36 @@ This component exposes public methods. You can use them directly on the instance -## 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. ### Example section ```jsx - -
+ +
+
+
+ +``` + +### 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 + + +
+ + +
+ ``` diff --git a/packages/main/src/components/ObjectPage/ObjectPage.module.css b/packages/main/src/components/ObjectPage/ObjectPage.module.css index 87132f90837..aabd388e528 100644 --- a/packages/main/src/components/ObjectPage/ObjectPage.module.css +++ b/packages/main/src/components/ObjectPage/ObjectPage.module.css @@ -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 { diff --git a/packages/main/src/components/ObjectPage/ObjectPage.stories.tsx b/packages/main/src/components/ObjectPage/ObjectPage.stories.tsx index fc046a78df9..7ec697c637b 100644 --- a/packages/main/src/components/ObjectPage/ObjectPage.stories.tsx +++ b/packages/main/src/components/ObjectPage/ObjectPage.stories.tsx @@ -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, @@ -33,6 +35,12 @@ import { ObjectPageMode, ObjectPageSection, ObjectPageSubSection, + Table, + TableCell, + TableGrowing, + TableHeaderCell, + TableHeaderRow, + TableRow, Text, Title, Toolbar, @@ -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, @@ -426,22 +446,87 @@ export const SectionWithCustomHeader: Story = { }; export const FullScreenSingleSection: Story = { - args: { selectedSectionId: 'section1' }, name: 'with fullscreen section', render(args) { return ( - +
- 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! +
+
+ +
+
- -
+ + + Name + Role + Location + + } + features={} + > + {tableRows.map((row, i) => ( + + + {row.name} + + + {row.role} + + + {row.location} + + + ))} +
+
+ +
+
+
- -
+ + +
+ Fullscreen section with subsections. The content fits, so nothing scrolls. +
+
+ +
More content that still fits.
+
+
+ + {Array.from({ length: 5 }, (_, i) => ( + +
+ Tall subsection content — the section scrolls internally while the footer stays visible. +
+
+ ))} +
+ +
+ This section doesn't use `fitContent`, so it grows with its content and the page scrolls normally. +
+
+ +
+ This section doesn't use `fitContent` and its content exceeds the viewport, so the standard page scroll + is visible. +
); diff --git a/packages/main/src/components/ObjectPage/index.tsx b/packages/main/src/components/ObjectPage/index.tsx index c980801409a..8ecb938c520 100644 --- a/packages/main/src/components/ObjectPage/index.tsx +++ b/packages/main/src/components/ObjectPage/index.tsx @@ -122,6 +122,10 @@ const ObjectPage = forwardRef((props, ref () => (mode === ObjectPageMode.IconTabBar ? getSectionById(children, internalSelectedSectionId) : null), [mode, children, internalSelectedSectionId], ); + const isActiveSectionFitContent = + mode === ObjectPageMode.IconTabBar && + isValidElement(currentTabModeSection) && + !!currentTabModeSection.props.fitContent; const [toggledCollapsedHeaderWasVisible, setToggledCollapsedHeaderWasVisible] = useState(false); const sections = mode === ObjectPageMode.IconTabBar ? currentTabModeSection : children; const scrollEndHandler = useOnScrollEnd({ objectPageRef, setTabSelectId }); @@ -255,6 +259,19 @@ const ObjectPage = forwardRef((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('[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 = @@ -293,6 +310,7 @@ const ObjectPage = forwardRef((props, ref headerPinned, headerCollapsed, headerContentHeight, + isActiveSectionFitContent, ], ); @@ -871,7 +889,7 @@ const ObjectPage = forwardRef((props, ref )}
{ if (node) { if (mode === ObjectPageMode.IconTabBar && wasUserSectionChange) { @@ -905,9 +923,9 @@ const ObjectPage = forwardRef((props, ref aria-hidden="true" /> {placeholder ? placeholder : sections} -