Skip to content
119 changes: 100 additions & 19 deletions packages/main/src/components/ObjectPage/ObjectPage.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import InputType from '@ui5/webcomponents/dist/types/InputType.js';
import TitleLevel from '@ui5/webcomponents/dist/types/TitleLevel.js';
import ValueState from '@ui5/webcomponents-base/dist/types/ValueState.js';
import IllustrationMessageType from '@ui5/webcomponents-fiori/dist/types/IllustrationMessageType.js';
import { useEffect, useLayoutEffect, useReducer, useRef, useState } from 'react';
import { useEffect, useLayoutEffect, useReducer, useRef, useState, version as reactVersion } from 'react';
import type { CSSProperties } from 'react';
import type { ObjectPagePropTypes } from '../..';
import {
Expand Down Expand Up @@ -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');
Expand All @@ -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');
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -712,19 +706,19 @@ describe('ObjectPage', () => {
};
cy.mount(<TestComp height="2000px" mode={ObjectPageMode.Default} />);
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(<TestComp height="2000px" withFooter mode={ObjectPageMode.Default} />);
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(<TestComp height="400px" mode={ObjectPageMode.Default} />);
cy.findByText('Update Heights').click();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1853,6 +1841,61 @@ describe('ObjectPage', () => {
}
cy.focused().should('be.visible').and('have.attr', 'ui5-table-row');
});

it('sticky headers', () => {
cy.mount(
<ObjectPage
titleArea={DPTitle}
headerArea={DPContent}
mode="IconTabBar"
style={{ height: '1000px' }}
data-testid="op"
>
{OPContent}
{OPContentWithCustomHeaderSections}
</ObjectPage>,
);

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(
<ObjectPage
titleArea={DPTitle}
headerArea={DPContent}
// scrollBehavior "auto" prevents flaky behavior when test is run with React18
style={{ height: '1000px', scrollBehavior: reactVersion.startsWith('18') ? 'auto' : 'smooth' }}
data-testid="op"
>
{OPContent}
{OPContentWithCustomHeaderSections}
</ObjectPage>,
);

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 = (
Expand Down Expand Up @@ -1952,6 +1995,44 @@ const OPContent = [
</ObjectPageSection>,
];

const OPContentWithCustomHeaderSections = [
<ObjectPageSection
key={'customheader1'}
titleText="Custom Header Section One"
hideTitleText
id="custom1"
header={<Title>Custom Header Section One</Title>}
>
<div style={{ width: '100%', height: '200px', background: 'lightgreen' }} />
</ObjectPageSection>,
<ObjectPageSection
key={'customheader2'}
titleText="Custom Header Section Two"
hideTitleText
id="custom2"
header={<MessageStrip hideCloseButton>Custom Header Section Two</MessageStrip>}
>
<ObjectPageSubSection
titleText="Subsection1"
id="sub1"
actions={
<>
<Button design={ButtonDesign.Emphasized} style={{ minWidth: '120px' }}>
Custom Action
</Button>
<Button design={ButtonDesign.Transparent} icon="action-settings" tooltip="settings" />
<Button design={ButtonDesign.Transparent} icon="download" tooltip="download" />
</>
}
>
<div style={{ width: '100%', height: '300px', background: 'cadetblue' }} />
</ObjectPageSubSection>
<ObjectPageSubSection titleText="Subsection2" id="sub2">
<div style={{ width: '100%', height: '300px', background: 'cadetblue' }} />
</ObjectPageSubSection>
</ObjectPageSection>,
];

const HeaderWithLargeForm = (
<ObjectPageHeader>
<Form layout="S1 M2 L2 XL2">
Expand Down
21 changes: 5 additions & 16 deletions packages/main/src/components/ObjectPage/ObjectPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
container: objectPage / inline-size;
--_ui5wcr_ObjectPage_header_display: block;
--_ui5wcr_ObjectPage_title_fontsize: var(--sapObjectHeader_Title_FontSize);
--_ui5wcr_ObjectPage_header_height: 0;

box-sizing: border-box;
position: relative;
Expand All @@ -23,19 +24,6 @@
&[data-in-iframe='true'] {
scroll-behavior: auto;
}

/*invisible first heading*/
section[id*='ObjectPageSection-']:first-of-type > div[role='heading'] {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
white-space: nowrap;
}
}

.iconTabBarMode section[data-component-name='ObjectPageSection'] > div[role='heading'] {
Expand All @@ -49,7 +37,7 @@
background-color: var(--sapObjectHeader_Background);
position: sticky;
inset-block-start: 0;
z-index: 4;
z-index: 5;
cursor: pointer;
display: grid;
grid-auto-columns: 100%;
Expand Down Expand Up @@ -102,7 +90,7 @@

.anchorBar {
position: sticky;
z-index: 4;
z-index: 5;
}

.tabContainerSpacer {
Expand All @@ -112,7 +100,7 @@

.tabContainer {
position: sticky;
z-index: 3;
z-index: 4;
background: var(--sapObjectHeader_Background);
}

Expand Down Expand Up @@ -169,6 +157,7 @@
position: sticky;
inset-block-end: 0.5rem;
margin: 0 0.5rem;
z-index: 4;
}

.footerSpacer {
Expand Down
63 changes: 62 additions & 1 deletion packages/main/src/components/ObjectPage/ObjectPage.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,68 @@ export const SectionWithCustomHeader: Story = {
aria-label="Personal"
header={<MessageStrip hideCloseButton>Custom Header Section Two</MessageStrip>}
>
<div style={{ width: '100%', height: '500px', background: 'cadetblue' }} />
<ObjectPageSubSection
titleText="Connect"
id="personal-connect"
aria-label="Connect"
actions={
<>
<Button design={ButtonDesign.Emphasized} style={{ minWidth: '120px' }}>
Custom Action
</Button>
<Button design={ButtonDesign.Transparent} icon="action-settings" tooltip="settings" />
<Button design={ButtonDesign.Transparent} icon="download" tooltip="download" />
</>
}
>
<Form style={{ alignItems: 'baseline' }}>
<FormGroup headerText="Phone Numbers">
<FormItem labelContent={<Label showColon>Home</Label>}>
<Text>+1 234-567-8901</Text>
<Text>+1 234-567-5555</Text>
</FormItem>
</FormGroup>
<FormGroup headerText="Social Accounts">
<FormItem labelContent={<Label showColon>LinkedIn</Label>}>
<Text>/DeniseSmith</Text>
</FormItem>
<FormItem labelContent={<Label showColon>Twitter</Label>}>
<Text>@DeniseSmith</Text>
</FormItem>
</FormGroup>
<FormGroup headerText="Addresses">
<FormItem labelContent={<Label showColon>Home Address</Label>}>
<Text>2096 Mission Street</Text>
</FormItem>
<FormItem labelContent={<Label showColon>Mailing Address</Label>}>
<Text>PO Box 32114</Text>
</FormItem>
</FormGroup>
<FormGroup headerText="Mailing Address">
<FormItem labelContent={<Label showColon>Work</Label>}>
<Text>DeniseSmith@sap.com</Text>
</FormItem>
</FormGroup>
</Form>
</ObjectPageSubSection>
<ObjectPageSubSection
titleText="Payment Information"
id="personal-payment-information"
aria-label="Payment Information"
>
<Form>
<FormGroup headerText="Salary">
<FormItem labelContent={<Label showColon>Bank Transfer</Label>}>
<Text>Money Bank, Inc.</Text>
</FormItem>
</FormGroup>
<FormGroup headerText="Payment method for Expenses">
<FormItem labelContent={<Label showColon>Extra Travel Expenses</Label>}>
<Text>Cash 100 USD</Text>
</FormItem>
</FormGroup>
</Form>
</ObjectPageSubSection>
</ObjectPageSection>
<ObjectPageSection
titleText="Employment"
Expand Down
7 changes: 6 additions & 1 deletion packages/main/src/components/ObjectPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { useOnScrollEnd } from './useOnScrollEnd.js';
const ObjectPageCssVariables = {
headerDisplay: '--_ui5wcr_ObjectPage_header_display',
titleFontSize: '--_ui5wcr_ObjectPage_title_fontsize',
fullHeaderHeight: '--_ui5wcr_ObjectPage_header_height',
};

const TAB_CONTAINER_HEADER_HEIGHT = 44 + 4; // tabbar height + custom 4px padding-block-start
Expand Down Expand Up @@ -611,7 +612,11 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
});
const objectPageStyles: CSSProperties = {
...style,
};
[ObjectPageCssVariables.fullHeaderHeight]:
headerPinned || scrolledHeaderExpanded
? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight) + TAB_CONTAINER_HEADER_HEIGHT}px`
: `${topHeaderHeight + TAB_CONTAINER_HEADER_HEIGHT}px`,
} as CSSProperties;
if (headerCollapsed === true && headerArea) {
objectPageStyles[ObjectPageCssVariables.titleFontSize] = ThemingParameters.sapObjectHeader_Title_SnappedFontSize;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.section {
box-sizing: border-box;
background: var(--sapBackgroundColor);

&:first-of-type {
margin-block-start: 1px;
Expand All @@ -16,6 +17,10 @@
outline-offset: calc(-1 * var(--sapContent_FocusWidth));
}

.outlineSpacerDiv {
height: 2px;
}

.headerContainer {
padding-block: 0.5rem;
color: var(--sapGroup_TitleTextColor);
Expand All @@ -28,6 +33,14 @@
height: 2.25rem;
}

.sticky {
position: sticky;
background: var(--sapBackgroundColor);
/*-1 -> subpixel rounding errors */
inset-block-start: calc(var(--_ui5wcr_ObjectPage_header_height) - 1px);
z-index: 3;
}

.title {
height: 2.25rem;
line-height: 2.25rem;
Expand Down
7 changes: 5 additions & 2 deletions packages/main/src/components/ObjectPageSection/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -246,12 +246,15 @@ const ObjectPageSection = forwardRef<HTMLElement, ObjectPageSectionPropTypes>((p
onBlur={objectPageMode === ObjectPageMode.Default ? handleBlur : props.onBlur}
onKeyDown={objectPageMode === ObjectPageMode.Default ? handleKeyDown : props.onKeyDown}
>
{!!header && <div className={classNames.headerContainer}>{header}</div>}
<div className={classNames.outlineSpacerDiv} aria-hidden="true" />
{!!header && (
<div className={clsx(classNames.headerContainer, !hasSubSection ? classNames.sticky : undefined)}>{header}</div>
)}
{!hideTitleText && (
<div
role="heading"
aria-level={parseInt(titleTextLevel.slice(1))}
className={classNames.titleContainer}
className={clsx(classNames.titleContainer, !header && !hasSubSection ? classNames.sticky : undefined)}
data-component-name="ObjectPageSectionTitleText"
>
<div className={titleClasses}>{titleText}</div>
Expand Down
Loading