From 7d338e141c7cd7081bb57c4ca54a8f759246096f Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Fri, 9 Jan 2026 16:17:28 +0100 Subject: [PATCH 01/11] feat(ObjectPage): make section headers sticky --- .../components/ObjectPage/ObjectPage.cy.tsx | 111 +++++++++++++++--- .../ObjectPage/ObjectPage.module.css | 21 +--- .../ObjectPage/ObjectPage.stories.tsx | 63 +++++++++- .../main/src/components/ObjectPage/index.tsx | 7 +- .../ObjectPageSection.module.css | 13 ++ .../components/ObjectPageSection/index.tsx | 7 +- .../ObjectPageSubSection.module.css | 11 +- 7 files changed, 192 insertions(+), 41 deletions(-) diff --git a/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx b/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx index 0ac7d1137aa..0b972be7065 100644 --- a/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx +++ b/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx @@ -114,7 +114,7 @@ describe('ObjectPage', () => { cy.get('[ui5-tabcontainer]').findUi5TabByText('Section 15').should('have.attr', 'aria-selected', 'true'); if (mode === ObjectPageMode.Default) { - cy.findByTestId('op').scrollTo(0, 4750); + cy.findByTestId('op').scrollTo(0, 4858); cy.findByText('Content 7').should('be.visible'); cy.get('[ui5-tabcontainer]').findUi5TabByText('Section 7').should('have.attr', 'aria-selected', 'true'); @@ -124,7 +124,7 @@ describe('ObjectPage', () => { for (let i = 0; i < 15; i++) { cy.findByText('Add').click(); } - cy.findByTestId('op').scrollTo(0, 4750); + cy.findByTestId('op').scrollTo(0, 4858); cy.findByText('Content 7').should('be.visible'); cy.get('[ui5-tabcontainer]').findUi5TabByText('Section 7').should('have.attr', 'aria-selected', 'true'); @@ -374,12 +374,6 @@ describe('ObjectPage', () => { ); cy.wait(200); - // first titleText should never be displayed (not.be.visible doesn't work here - only invisible for sighted users) - cy.findByText('Goals') - .parent() - .should('have.css', 'width', '1px') - .and('have.css', 'margin', '-1px') - .and('have.css', 'position', 'absolute'); cy.findByText('Employment').should('not.be.visible'); cy.findByText('Test').should('be.visible'); @@ -712,19 +706,19 @@ describe('ObjectPage', () => { }; cy.mount(); cy.findByText('Update Heights').click(); - cy.findByText('{"offset":1080,"scroll":2290}').should('exist'); + cy.findByText('{"offset":1080,"scroll":2330}').should('exist'); cy.findByTestId('op').scrollTo('bottom'); cy.findByText('Update Heights').click({ force: true }); - cy.findByText('{"offset":1080,"scroll":2290}').should('exist'); + cy.findByText('{"offset":1080,"scroll":2330}').should('exist'); cy.mount(); cy.findByText('Update Heights').click(); - cy.findByText('{"offset":1080,"scroll":2330}').should('exist'); + cy.findByText('{"offset":1080,"scroll":2370}').should('exist'); cy.findByTestId('op').scrollTo('bottom'); cy.findByText('Update Heights').click({ force: true }); - cy.findByText('{"offset":1080,"scroll":2330}').should('exist'); + cy.findByText('{"offset":1080,"scroll":2370}').should('exist'); cy.mount(); cy.findByText('Update Heights').click(); @@ -923,12 +917,6 @@ describe('ObjectPage', () => { cy.get('[ui5-tabcontainer]').findUi5TabByText('Goals').click(); cy.findByText('Custom Header Section One').should('be.visible'); cy.findByText('toggle titleText1').click({ scrollBehavior: false, force: true }); - // first titleText should never be displayed (not.be.visible doesn't work here - only invisible for sighted users) - cy.findByText('Goals') - .parent() - .should('have.css', 'width', '1px') - .and('have.css', 'margin', '-1px') - .and('have.css', 'position', 'absolute'); cy.findByText('Custom Header Section One').should('be.visible'); cy.get('[ui5-tabcontainer]').findUi5TabByText('Personal').click(); @@ -1843,6 +1831,55 @@ describe('ObjectPage', () => { } cy.focused().should('be.visible').and('have.attr', 'ui5-table-row'); }); + + it('sticky headers', () => { + cy.mount( + + {OPContent} + {OPContentWithCustomHeaderSections} + , + ); + + cy.findByText('Goals').should('not.be.visible'); + cy.get('[ui5-tabcontainer]').findUi5TabByText('Employment').click(); + cy.findByText('Employment').should('not.be.visible'); + cy.findByText('Employee Details').parent().should('have.css', 'position', 'sticky'); + + cy.mount( + + {OPContent} + {OPContentWithCustomHeaderSections} + , + ); + + cy.findByText('Goals').should('be.visible').parent().should('have.css', 'position', 'sticky'); + cy.findByTestId('op').scrollTo(0, 500); + cy.findByText('Goals').should('be.visible'); + cy.get('[ui5-tabcontainer]').findUi5TabByText('Personal').click(); + // has subsections -> only subsection headers are sticky + cy.findByText('Personal').should('be.visible').parent().should('have.css', 'position', 'static'); + cy.findByText('Connect').should('be.visible').parent().should('have.css', 'position', 'sticky'); + cy.findByTestId('op').scrollTo(0, 2500); + cy.findByText('Goals').should('not.be.visible'); + cy.findByText('Payment Information').should('be.visible'); + cy.get('[ui5-tabcontainer]').findUi5TabByText('Custom Header Section One').click(); + cy.findByText('Custom Header Section One').should('be.visible').parent().should('have.css', 'position', 'sticky'); + cy.findByTestId('op').scrollTo(0, 3500); + cy.findByText('Custom Header Section One').should('be.visible'); + cy.get('[ui5-tabcontainer]').findUi5TabByText('Custom Header Section Two').click(); + // has subsections -> only subsection headers are sticky + cy.findByText('Custom Header Section Two').should('be.visible').parent().should('have.css', 'position', 'static'); + cy.findByText('Subsection1').should('be.visible').parent().should('have.css', 'position', 'sticky'); + cy.findByTestId('op').scrollTo(0, 4000); + cy.findByText('Custom Header Section Two').should('not.be.visible'); + cy.findByText('Subsection1').should('be.visible'); + }); }); const DPTitle = ( @@ -1942,6 +1979,44 @@ const OPContent = [ , ]; +const OPContentWithCustomHeaderSections = [ + Custom Header Section One} + > +
+ , + Custom Header Section Two} + > + + + + + {JSON.stringify(showCurrentHeights)} +
+ + +
+
+
+ ) : (
- - ); - }; - cy.mount(); + )} + + ); + }; + + it('single section - Default mode', () => { + document.body.style.margin = '0px'; + + cy.mount(); + + cy.get('[data-component-name="ObjectPageTabContainerPlaceholder"]').should('exist'); + cy.get('[data-component-name="ObjectPageTabContainer"]').should('not.exist'); + cy.findByText('Update Heights').click(); - cy.findByText('{"offset":1080,"scroll":2330}').should('exist'); + cy.findByText('{"offset":1080,"scroll":2280}').should('exist'); cy.findByTestId('op').scrollTo('bottom'); cy.findByText('Update Heights').click({ force: true }); - cy.findByText('{"offset":1080,"scroll":2330}').should('exist'); + cy.findByText('{"offset":1080,"scroll":2280}').should('exist'); - cy.mount(); + cy.mount(); cy.findByText('Update Heights').click(); - cy.findByText('{"offset":1080,"scroll":2370}').should('exist'); + cy.findByText('{"offset":1080,"scroll":2320}').should('exist'); cy.findByTestId('op').scrollTo('bottom'); cy.findByText('Update Heights').click({ force: true }); - cy.findByText('{"offset":1080,"scroll":2370}').should('exist'); + cy.findByText('{"offset":1080,"scroll":2320}').should('exist'); - cy.mount(); + cy.mount(); cy.findByText('Update Heights').click(); cy.findByText('{"offset":1080,"scroll":1080}').should('exist'); @@ -732,7 +764,7 @@ describe('ObjectPage', () => { cy.findByText('Update Heights').click({ force: true }); cy.findByText('{"offset":1080,"scroll":1080}').should('exist'); - cy.mount(); + cy.mount(); cy.findByText('Update Heights').click(); cy.findByText('{"offset":1080,"scroll":1080}').should('exist'); @@ -744,7 +776,7 @@ describe('ObjectPage', () => { cy.findByText('Update Heights').click({ force: true }); cy.findByText('{"offset":1080,"scroll":1080}').should('exist'); - cy.mount(); + cy.mount(); cy.findByText('https://github.com/UI5/webcomponents-react').should('be.visible'); cy.wait(50); @@ -757,67 +789,36 @@ describe('ObjectPage', () => { cy.get('[data-component-name="ObjectPageAnchorBarExpandBtn"]').click(); cy.findByText('https://github.com/UI5/webcomponents-react').should('not.be.visible'); + + cy.log('with subsections'); + cy.mount(); + cy.get('[data-component-name="ObjectPageTabContainerPlaceholder"]').should('exist'); + cy.get('[data-component-name="ObjectPageTabContainer"]').should('not.exist'); }); it('single section - Tab mode', () => { document.body.style.margin = '0px'; - const TestComp = ({ - mode, - height, - withFooter, - }: { - height: CSSProperties['height']; - withFooter?: boolean; - mode: ObjectPageMode; - }) => { - const ref = useRef(null); - const [showCurrentHeights, setShowCurrentHeights] = useState({ offset: null, scroll: null }); - return ( - - -
- - {JSON.stringify(showCurrentHeights)} -
-
-
- ); - }; - cy.mount(); + cy.mount(); + + cy.get('[data-component-name="ObjectPageTabContainerPlaceholder"]').should('exist'); + cy.get('[data-component-name="ObjectPageTabContainer"]').should('not.exist'); + cy.findByText('Update Heights').click(); - cy.findByText('{"offset":1080,"scroll":2290}').should('exist'); + cy.findByText('{"offset":1080,"scroll":2240}').should('exist'); cy.findByTestId('op').scrollTo('bottom'); cy.findByText('Update Heights').click({ force: true }); - cy.findByText('{"offset":1080,"scroll":2290}').should('exist'); + cy.findByText('{"offset":1080,"scroll":2240}').should('exist'); - cy.mount(); + cy.mount(); cy.findByText('Update Heights').click(); - cy.findByText('{"offset":1080,"scroll":2350}').should('exist'); + cy.findByText('{"offset":1080,"scroll":2300}').should('exist'); cy.findByTestId('op').scrollTo('bottom'); cy.findByText('Update Heights').click({ force: true }); - cy.findByText('{"offset":1080,"scroll":2350}').should('exist'); + cy.findByText('{"offset":1080,"scroll":2300}').should('exist'); - cy.mount(); + cy.mount(); cy.findByText('Update Heights').click(); cy.findByText('{"offset":1080,"scroll":1080}').should('exist'); @@ -829,7 +830,7 @@ describe('ObjectPage', () => { cy.findByText('Update Heights').click({ force: true }); cy.findByText('{"offset":1080,"scroll":1080}').should('exist'); - cy.mount(); + cy.mount(); cy.findByText('Update Heights').click(); cy.findByText('{"offset":1080,"scroll":1080}').should('exist'); @@ -841,7 +842,7 @@ describe('ObjectPage', () => { cy.findByText('Update Heights').click({ force: true }); cy.findByText('{"offset":1080,"scroll":1080}').should('exist'); - cy.mount(); + cy.mount(); cy.findByText('https://github.com/UI5/webcomponents-react').should('be.visible'); cy.wait(50); @@ -854,7 +855,13 @@ describe('ObjectPage', () => { cy.get('[data-component-name="ObjectPageAnchorBarExpandBtn"]').click(); cy.findByText('https://github.com/UI5/webcomponents-react').should('not.be.visible'); + + cy.log('with subsections'); + cy.mount(); + cy.get('[data-component-name="ObjectPageTabContainerPlaceholder"]').should('exist'); + cy.get('[data-component-name="ObjectPageTabContainer"]').should('not.exist'); }); + [ObjectPageMode.Default, ObjectPageMode.IconTabBar].forEach((mode) => { it(`ObjectPageSection/SubSection: Custom header & hideTitleText (mode: ${mode})`, () => { document.body.style.margin = '0px'; diff --git a/packages/main/src/components/ObjectPage/ObjectPage.mdx b/packages/main/src/components/ObjectPage/ObjectPage.mdx index 244b7ae1c93..f0d929e8492 100644 --- a/packages/main/src/components/ObjectPage/ObjectPage.mdx +++ b/packages/main/src/components/ObjectPage/ObjectPage.mdx @@ -88,6 +88,12 @@ To render a single section in fullscreen mode, set its height to `100%`. ``` +## ObjectPage with single section + +When only a single section is available, the tabbar is hidden. + + + ## Opening popover components by pressing an action Please see the [Docs](?path=/docs/layouts-floorplans-toolbar--docs#open-popovers-with-toolbarbutton) of the `Toolbar` component. diff --git a/packages/main/src/components/ObjectPage/ObjectPage.module.css b/packages/main/src/components/ObjectPage/ObjectPage.module.css index c163698ddcf..4e65a0756b6 100644 --- a/packages/main/src/components/ObjectPage/ObjectPage.module.css +++ b/packages/main/src/components/ObjectPage/ObjectPage.module.css @@ -104,6 +104,13 @@ background: var(--sapObjectHeader_Background); } +.tabContainerPlaceholder { + composes: tabContainer; + box-shadow: var(--sapContent_HeaderShadow); + height: 1px; + flex-shrink: 0; +} + .tabContainerComponent { &::part(content) { display: none; diff --git a/packages/main/src/components/ObjectPage/ObjectPage.stories.tsx b/packages/main/src/components/ObjectPage/ObjectPage.stories.tsx index e339c20e9ae..bf8e26d8e79 100644 --- a/packages/main/src/components/ObjectPage/ObjectPage.stories.tsx +++ b/packages/main/src/components/ObjectPage/ObjectPage.stories.tsx @@ -56,7 +56,6 @@ const meta = { }, args: { mode: ObjectPageMode.Default, - selectedSectionId: 'goals', imageShapeCircle: true, image: SampleImage, style: { height: '700px', maxHeight: '90vh' }, @@ -451,6 +450,93 @@ export const FullScreenSingleSection: Story = { }, }; +export const SingleSection: Story = { + name: 'with single section', + render(args) { + return ( + + + +
+ Job Classification}> + + Senior UI Developer + + + + Job Title}> + Developer + + Employee Class}> + Employee + + Manager}> + + Dan Smith + + + + Pay Grade}> + Salary Grade 18 (GR-14) + + FTE}> + 1 + +
+
+ +
+ Start Date}> + Jan 01, 2018 + + End Date}> + Dec 31, 9999 + + Payroll Start Date}> + Jan 01, 2018 + + Benefits Start Date}> + Jul 01, 2018 + + Company Car Eligibility}> + Jan 01, 2021 + + Equity Start Date}> + Jul 01, 2018 + +
+
+ +
+ Manager}> + John Doe + + Scrum Master}> + Michael Adams + + Product Owner}> + John Miller + +
+
+
+
+ ); + }, +}; + export const LegacyToolbarSupport: Story = { render(args) { const objectPageRef = useRef(null); diff --git a/packages/main/src/components/ObjectPage/index.tsx b/packages/main/src/components/ObjectPage/index.tsx index 5dbc5be39f5..3d39728f29b 100644 --- a/packages/main/src/components/ObjectPage/index.tsx +++ b/packages/main/src/components/ObjectPage/index.tsx @@ -11,7 +11,17 @@ import { } from '@ui5/webcomponents-react-base'; import { clsx } from 'clsx'; import type { CSSProperties, FocusEventHandler, MouseEventHandler, ReactElement, UIEventHandler } from 'react'; -import { cloneElement, forwardRef, isValidElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + cloneElement, + Children, + forwardRef, + isValidElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { ObjectPageMode } from '../../enums/ObjectPageMode.js'; import { safeGetChildrenArray } from '../../internal/safeGetChildrenArray.js'; import { useObserveHeights } from '../../internal/useObserveHeights.js'; @@ -85,6 +95,8 @@ const ObjectPage = forwardRef((props, ref selectedSectionId ?? firstSectionId, ); const [tabSelectId, setTabSelectId] = useState(null); + const hasOnlySingleSection = Children.count(children) === 1; + const tabContainerHeaderHeight = hasOnlySingleSection ? 1 : TAB_CONTAINER_HEADER_HEIGHT; const isProgrammaticallyScrolled = useRef(false); const [componentRef, objectPageRef] = useSyncRef(ref); @@ -150,7 +162,7 @@ const ObjectPage = forwardRef((props, ref scrollTimeout, }, ); - const scrollPaddingBlock = `${Math.ceil(12 + topHeaderHeight + TAB_CONTAINER_HEADER_HEIGHT + (!headerCollapsed && headerPinned ? headerContentHeight : 0))}px ${footerArea ? 'calc(var(--_ui5wcr-BarHeight) + 1.25rem)' : 0}`; + const scrollPaddingBlock = `${Math.ceil(12 + topHeaderHeight + tabContainerHeaderHeight + (!headerCollapsed && headerPinned ? headerContentHeight : 0))}px ${footerArea ? 'calc(var(--_ui5wcr-BarHeight) + 1.25rem)' : 0}`; useEffect(() => { if (typeof onToggleHeaderArea === 'function' && isToggledRef.current) { @@ -207,7 +219,7 @@ const ObjectPage = forwardRef((props, ref const scrollMargin = -1 /* reduce margin-block so that intersection observer detects correct section*/ + safeTopHeaderHeight + - TAB_CONTAINER_HEADER_HEIGHT + + tabContainerHeaderHeight + (headerPinned && !headerCollapsed ? headerContentHeight : 0); section.style.scrollMarginBlockStart = scrollMargin + 'px'; if (isSubSection) { @@ -426,7 +438,16 @@ const ObjectPage = forwardRef((props, ref return () => { observer.disconnect(); }; - }, [topHeaderHeight, headerContentHeight, currentTabModeSection, children, mode, isHeaderPinnedAndExpanded]); + }, [ + topHeaderHeight, + headerContentHeight, + currentTabModeSection, + children, + mode, + isHeaderPinnedAndExpanded, + hasOnlySingleSection, + objectPageRef, + ]); const onToggleHeaderContentVisibility = (e) => { isToggledRef.current = true; @@ -459,7 +480,7 @@ const ObjectPage = forwardRef((props, ref } const sectionNodes = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]'); // only the sticky part of the header must be added as margin - const rootMargin = `-${((headerPinned && !headerCollapsed) || scrolledHeaderExpanded ? totalHeaderHeight : topHeaderHeight) + TAB_CONTAINER_HEADER_HEIGHT}px 0px 0px 0px`; + const rootMargin = `-${((headerPinned && !headerCollapsed) || scrolledHeaderExpanded ? totalHeaderHeight : topHeaderHeight) + tabContainerHeaderHeight}px 0px 0px 0px`; const observer = new IntersectionObserver( (entries) => { @@ -613,8 +634,8 @@ const ObjectPage = forwardRef((props, ref ...style, [ObjectPageCssVariables.fullHeaderHeight]: headerPinned || scrolledHeaderExpanded - ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight) + TAB_CONTAINER_HEADER_HEIGHT}px` - : `${topHeaderHeight + TAB_CONTAINER_HEADER_HEIGHT}px`, + ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight) + tabContainerHeaderHeight}px` + : `${topHeaderHeight + tabContainerHeaderHeight}px`, } as CSSProperties; if (headerCollapsed === true && headerArea) { objectPageStyles[ObjectPageCssVariables.titleFontSize] = ThemingParameters.sapObjectHeader_Title_SnappedFontSize; @@ -722,7 +743,21 @@ const ObjectPage = forwardRef((props, ref /> )} - {!placeholder && ( + {hasOnlySingleSection && ( +