Skip to content

Commit 3d0250e

Browse files
authored
feat(ObjectPage): make section headers sticky (#8087)
Closes #7780
1 parent 39a056e commit 3d0250e

7 files changed

Lines changed: 199 additions & 42 deletions

File tree

packages/main/src/components/ObjectPage/ObjectPage.cy.tsx

Lines changed: 100 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import InputType from '@ui5/webcomponents/dist/types/InputType.js';
66
import TitleLevel from '@ui5/webcomponents/dist/types/TitleLevel.js';
77
import ValueState from '@ui5/webcomponents-base/dist/types/ValueState.js';
88
import IllustrationMessageType from '@ui5/webcomponents-fiori/dist/types/IllustrationMessageType.js';
9-
import { useEffect, useLayoutEffect, useReducer, useRef, useState } from 'react';
9+
import { useEffect, useLayoutEffect, useReducer, useRef, useState, version as reactVersion } from 'react';
1010
import type { CSSProperties } from 'react';
1111
import type { ObjectPagePropTypes } from '../..';
1212
import {
@@ -114,7 +114,7 @@ describe('ObjectPage', () => {
114114
cy.get('[ui5-tabcontainer]').findUi5TabByText('Section 15').should('have.attr', 'aria-selected', 'true');
115115

116116
if (mode === ObjectPageMode.Default) {
117-
cy.findByTestId('op').scrollTo(0, 4750);
117+
cy.findByTestId('op').scrollTo(0, 4858);
118118

119119
cy.findByText('Content 7').should('be.visible');
120120
cy.get('[ui5-tabcontainer]').findUi5TabByText('Section 7').should('have.attr', 'aria-selected', 'true');
@@ -124,7 +124,7 @@ describe('ObjectPage', () => {
124124
for (let i = 0; i < 15; i++) {
125125
cy.findByText('Add').click();
126126
}
127-
cy.findByTestId('op').scrollTo(0, 4750);
127+
cy.findByTestId('op').scrollTo(0, 4858);
128128

129129
cy.findByText('Content 7').should('be.visible');
130130
cy.get('[ui5-tabcontainer]').findUi5TabByText('Section 7').should('have.attr', 'aria-selected', 'true');
@@ -374,12 +374,6 @@ describe('ObjectPage', () => {
374374
);
375375
cy.wait(200);
376376

377-
// first titleText should never be displayed (not.be.visible doesn't work here - only invisible for sighted users)
378-
cy.findByText('Goals')
379-
.parent()
380-
.should('have.css', 'width', '1px')
381-
.and('have.css', 'margin', '-1px')
382-
.and('have.css', 'position', 'absolute');
383377
cy.findByText('Employment').should('not.be.visible');
384378
cy.findByText('Test').should('be.visible');
385379

@@ -712,19 +706,19 @@ describe('ObjectPage', () => {
712706
};
713707
cy.mount(<TestComp height="2000px" mode={ObjectPageMode.Default} />);
714708
cy.findByText('Update Heights').click();
715-
cy.findByText('{"offset":1080,"scroll":2290}').should('exist');
709+
cy.findByText('{"offset":1080,"scroll":2330}').should('exist');
716710

717711
cy.findByTestId('op').scrollTo('bottom');
718712
cy.findByText('Update Heights').click({ force: true });
719-
cy.findByText('{"offset":1080,"scroll":2290}').should('exist');
713+
cy.findByText('{"offset":1080,"scroll":2330}').should('exist');
720714

721715
cy.mount(<TestComp height="2000px" withFooter mode={ObjectPageMode.Default} />);
722716
cy.findByText('Update Heights').click();
723-
cy.findByText('{"offset":1080,"scroll":2330}').should('exist');
717+
cy.findByText('{"offset":1080,"scroll":2370}').should('exist');
724718

725719
cy.findByTestId('op').scrollTo('bottom');
726720
cy.findByText('Update Heights').click({ force: true });
727-
cy.findByText('{"offset":1080,"scroll":2330}').should('exist');
721+
cy.findByText('{"offset":1080,"scroll":2370}').should('exist');
728722

729723
cy.mount(<TestComp height="400px" mode={ObjectPageMode.Default} />);
730724
cy.findByText('Update Heights').click();
@@ -923,12 +917,6 @@ describe('ObjectPage', () => {
923917
cy.get('[ui5-tabcontainer]').findUi5TabByText('Goals').click();
924918
cy.findByText('Custom Header Section One').should('be.visible');
925919
cy.findByText('toggle titleText1').click({ scrollBehavior: false, force: true });
926-
// first titleText should never be displayed (not.be.visible doesn't work here - only invisible for sighted users)
927-
cy.findByText('Goals')
928-
.parent()
929-
.should('have.css', 'width', '1px')
930-
.and('have.css', 'margin', '-1px')
931-
.and('have.css', 'position', 'absolute');
932920
cy.findByText('Custom Header Section One').should('be.visible');
933921

934922
cy.get('[ui5-tabcontainer]').findUi5TabByText('Personal').click();
@@ -1853,6 +1841,61 @@ describe('ObjectPage', () => {
18531841
}
18541842
cy.focused().should('be.visible').and('have.attr', 'ui5-table-row');
18551843
});
1844+
1845+
it('sticky headers', () => {
1846+
cy.mount(
1847+
<ObjectPage
1848+
titleArea={DPTitle}
1849+
headerArea={DPContent}
1850+
mode="IconTabBar"
1851+
style={{ height: '1000px' }}
1852+
data-testid="op"
1853+
>
1854+
{OPContent}
1855+
{OPContentWithCustomHeaderSections}
1856+
</ObjectPage>,
1857+
);
1858+
1859+
cy.findByText('Goals').should('not.be.visible');
1860+
cy.get('[ui5-tabcontainer]').findUi5TabByText('Employment').click();
1861+
cy.findByText('Employment').should('not.be.visible');
1862+
cy.findByText('Employee Details').parent().should('have.css', 'position', 'sticky');
1863+
1864+
cy.mount(
1865+
<ObjectPage
1866+
titleArea={DPTitle}
1867+
headerArea={DPContent}
1868+
// scrollBehavior "auto" prevents flaky behavior when test is run with React18
1869+
style={{ height: '1000px', scrollBehavior: reactVersion.startsWith('18') ? 'auto' : 'smooth' }}
1870+
data-testid="op"
1871+
>
1872+
{OPContent}
1873+
{OPContentWithCustomHeaderSections}
1874+
</ObjectPage>,
1875+
);
1876+
1877+
cy.findByText('Goals').should('be.visible').parent().should('have.css', 'position', 'sticky');
1878+
cy.findByTestId('op').scrollTo(0, 500);
1879+
cy.findByText('Goals').should('be.visible');
1880+
cy.get('[ui5-tabcontainer]').findUi5TabByText('Personal').click();
1881+
// has subsections -> only subsection headers are sticky
1882+
cy.findByText('Personal').should('be.visible').parent().should('have.css', 'position', 'static');
1883+
cy.findByText('Connect').should('be.visible').parent().should('have.css', 'position', 'sticky');
1884+
cy.findByTestId('op').scrollTo(0, 2500);
1885+
cy.findByText('Goals').should('not.be.visible');
1886+
cy.findByText('Payment Information').should('be.visible');
1887+
cy.get('[ui5-tabcontainer]').findUi5TabByText('Custom Header Section One').click();
1888+
cy.findByText('Custom Header Section One').should('be.visible').parent().should('have.css', 'position', 'sticky');
1889+
cy.findByTestId('op').scrollTo(0, 3500);
1890+
cy.findByText('Custom Header Section One').should('be.visible');
1891+
cy.get('[ui5-tabcontainer]').findUi5TabByText('Custom Header Section Two').click();
1892+
// has subsections -> only subsection headers are sticky
1893+
cy.findByText('Custom Header Section Two').should('be.visible').parent().should('have.css', 'position', 'static');
1894+
cy.findByText('Subsection1').should('be.visible').parent().should('have.css', 'position', 'sticky');
1895+
cy.findByTestId('op').scrollTo(0, 4000);
1896+
cy.findByText('Custom Header Section Two').should('not.be.visible');
1897+
cy.findByText('Subsection1').should('be.visible');
1898+
});
18561899
});
18571900

18581901
const DPTitle = (
@@ -1952,6 +1995,44 @@ const OPContent = [
19521995
</ObjectPageSection>,
19531996
];
19541997

1998+
const OPContentWithCustomHeaderSections = [
1999+
<ObjectPageSection
2000+
key={'customheader1'}
2001+
titleText="Custom Header Section One"
2002+
hideTitleText
2003+
id="custom1"
2004+
header={<Title>Custom Header Section One</Title>}
2005+
>
2006+
<div style={{ width: '100%', height: '200px', background: 'lightgreen' }} />
2007+
</ObjectPageSection>,
2008+
<ObjectPageSection
2009+
key={'customheader2'}
2010+
titleText="Custom Header Section Two"
2011+
hideTitleText
2012+
id="custom2"
2013+
header={<MessageStrip hideCloseButton>Custom Header Section Two</MessageStrip>}
2014+
>
2015+
<ObjectPageSubSection
2016+
titleText="Subsection1"
2017+
id="sub1"
2018+
actions={
2019+
<>
2020+
<Button design={ButtonDesign.Emphasized} style={{ minWidth: '120px' }}>
2021+
Custom Action
2022+
</Button>
2023+
<Button design={ButtonDesign.Transparent} icon="action-settings" tooltip="settings" />
2024+
<Button design={ButtonDesign.Transparent} icon="download" tooltip="download" />
2025+
</>
2026+
}
2027+
>
2028+
<div style={{ width: '100%', height: '300px', background: 'cadetblue' }} />
2029+
</ObjectPageSubSection>
2030+
<ObjectPageSubSection titleText="Subsection2" id="sub2">
2031+
<div style={{ width: '100%', height: '300px', background: 'cadetblue' }} />
2032+
</ObjectPageSubSection>
2033+
</ObjectPageSection>,
2034+
];
2035+
19552036
const HeaderWithLargeForm = (
19562037
<ObjectPageHeader>
19572038
<Form layout="S1 M2 L2 XL2">

packages/main/src/components/ObjectPage/ObjectPage.module.css

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
container: objectPage / inline-size;
33
--_ui5wcr_ObjectPage_header_display: block;
44
--_ui5wcr_ObjectPage_title_fontsize: var(--sapObjectHeader_Title_FontSize);
5+
--_ui5wcr_ObjectPage_header_height: 0;
56

67
box-sizing: border-box;
78
position: relative;
@@ -23,19 +24,6 @@
2324
&[data-in-iframe='true'] {
2425
scroll-behavior: auto;
2526
}
26-
27-
/*invisible first heading*/
28-
section[id*='ObjectPageSection-']:first-of-type > div[role='heading'] {
29-
position: absolute;
30-
width: 1px;
31-
height: 1px;
32-
padding: 0;
33-
margin: -1px;
34-
overflow: hidden;
35-
clip: rect(0, 0, 0, 0);
36-
border: 0;
37-
white-space: nowrap;
38-
}
3927
}
4028

4129
.iconTabBarMode section[data-component-name='ObjectPageSection'] > div[role='heading'] {
@@ -49,7 +37,7 @@
4937
background-color: var(--sapObjectHeader_Background);
5038
position: sticky;
5139
inset-block-start: 0;
52-
z-index: 4;
40+
z-index: 5;
5341
cursor: pointer;
5442
display: grid;
5543
grid-auto-columns: 100%;
@@ -102,7 +90,7 @@
10290

10391
.anchorBar {
10492
position: sticky;
105-
z-index: 4;
93+
z-index: 5;
10694
}
10795

10896
.tabContainerSpacer {
@@ -112,7 +100,7 @@
112100

113101
.tabContainer {
114102
position: sticky;
115-
z-index: 3;
103+
z-index: 4;
116104
background: var(--sapObjectHeader_Background);
117105
}
118106

@@ -169,6 +157,7 @@
169157
position: sticky;
170158
inset-block-end: 0.5rem;
171159
margin: 0 0.5rem;
160+
z-index: 4;
172161
}
173162

174163
.footerSpacer {

packages/main/src/components/ObjectPage/ObjectPage.stories.tsx

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,68 @@ export const SectionWithCustomHeader: Story = {
345345
aria-label="Personal"
346346
header={<MessageStrip hideCloseButton>Custom Header Section Two</MessageStrip>}
347347
>
348-
<div style={{ width: '100%', height: '500px', background: 'cadetblue' }} />
348+
<ObjectPageSubSection
349+
titleText="Connect"
350+
id="personal-connect"
351+
aria-label="Connect"
352+
actions={
353+
<>
354+
<Button design={ButtonDesign.Emphasized} style={{ minWidth: '120px' }}>
355+
Custom Action
356+
</Button>
357+
<Button design={ButtonDesign.Transparent} icon="action-settings" tooltip="settings" />
358+
<Button design={ButtonDesign.Transparent} icon="download" tooltip="download" />
359+
</>
360+
}
361+
>
362+
<Form style={{ alignItems: 'baseline' }}>
363+
<FormGroup headerText="Phone Numbers">
364+
<FormItem labelContent={<Label showColon>Home</Label>}>
365+
<Text>+1 234-567-8901</Text>
366+
<Text>+1 234-567-5555</Text>
367+
</FormItem>
368+
</FormGroup>
369+
<FormGroup headerText="Social Accounts">
370+
<FormItem labelContent={<Label showColon>LinkedIn</Label>}>
371+
<Text>/DeniseSmith</Text>
372+
</FormItem>
373+
<FormItem labelContent={<Label showColon>Twitter</Label>}>
374+
<Text>@DeniseSmith</Text>
375+
</FormItem>
376+
</FormGroup>
377+
<FormGroup headerText="Addresses">
378+
<FormItem labelContent={<Label showColon>Home Address</Label>}>
379+
<Text>2096 Mission Street</Text>
380+
</FormItem>
381+
<FormItem labelContent={<Label showColon>Mailing Address</Label>}>
382+
<Text>PO Box 32114</Text>
383+
</FormItem>
384+
</FormGroup>
385+
<FormGroup headerText="Mailing Address">
386+
<FormItem labelContent={<Label showColon>Work</Label>}>
387+
<Text>DeniseSmith@sap.com</Text>
388+
</FormItem>
389+
</FormGroup>
390+
</Form>
391+
</ObjectPageSubSection>
392+
<ObjectPageSubSection
393+
titleText="Payment Information"
394+
id="personal-payment-information"
395+
aria-label="Payment Information"
396+
>
397+
<Form>
398+
<FormGroup headerText="Salary">
399+
<FormItem labelContent={<Label showColon>Bank Transfer</Label>}>
400+
<Text>Money Bank, Inc.</Text>
401+
</FormItem>
402+
</FormGroup>
403+
<FormGroup headerText="Payment method for Expenses">
404+
<FormItem labelContent={<Label showColon>Extra Travel Expenses</Label>}>
405+
<Text>Cash 100 USD</Text>
406+
</FormItem>
407+
</FormGroup>
408+
</Form>
409+
</ObjectPageSubSection>
349410
</ObjectPageSection>
350411
<ObjectPageSection
351412
titleText="Employment"

packages/main/src/components/ObjectPage/index.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { useOnScrollEnd } from './useOnScrollEnd.js';
3838
const ObjectPageCssVariables = {
3939
headerDisplay: '--_ui5wcr_ObjectPage_header_display',
4040
titleFontSize: '--_ui5wcr_ObjectPage_title_fontsize',
41+
fullHeaderHeight: '--_ui5wcr_ObjectPage_header_height',
4142
};
4243

4344
const TAB_CONTAINER_HEADER_HEIGHT = 44 + 4; // tabbar height + custom 4px padding-block-start
@@ -611,7 +612,11 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
611612
});
612613
const objectPageStyles: CSSProperties = {
613614
...style,
614-
};
615+
[ObjectPageCssVariables.fullHeaderHeight]:
616+
headerPinned || scrolledHeaderExpanded
617+
? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight) + TAB_CONTAINER_HEADER_HEIGHT}px`
618+
: `${topHeaderHeight + TAB_CONTAINER_HEADER_HEIGHT}px`,
619+
} as CSSProperties;
615620
if (headerCollapsed === true && headerArea) {
616621
objectPageStyles[ObjectPageCssVariables.titleFontSize] = ThemingParameters.sapObjectHeader_Title_SnappedFontSize;
617622
}

packages/main/src/components/ObjectPageSection/ObjectPageSection.module.css

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
.section {
22
box-sizing: border-box;
3+
background: var(--sapBackgroundColor);
34

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

20+
.outlineSpacerDiv {
21+
height: 2px;
22+
}
23+
1924
.headerContainer {
2025
padding-block: 0.5rem;
2126
color: var(--sapGroup_TitleTextColor);
@@ -28,6 +33,14 @@
2833
height: 2.25rem;
2934
}
3035

36+
.sticky {
37+
position: sticky;
38+
background: var(--sapBackgroundColor);
39+
/*-1 -> subpixel rounding errors */
40+
inset-block-start: calc(var(--_ui5wcr_ObjectPage_header_height) - 1px);
41+
z-index: 3;
42+
}
43+
3144
.title {
3245
height: 2.25rem;
3346
line-height: 2.25rem;

packages/main/src/components/ObjectPageSection/index.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,12 +246,15 @@ const ObjectPageSection = forwardRef<HTMLElement, ObjectPageSectionPropTypes>((p
246246
onBlur={objectPageMode === ObjectPageMode.Default ? handleBlur : props.onBlur}
247247
onKeyDown={objectPageMode === ObjectPageMode.Default ? handleKeyDown : props.onKeyDown}
248248
>
249-
{!!header && <div className={classNames.headerContainer}>{header}</div>}
249+
<div className={classNames.outlineSpacerDiv} aria-hidden="true" />
250+
{!!header && (
251+
<div className={clsx(classNames.headerContainer, !hasSubSection ? classNames.sticky : undefined)}>{header}</div>
252+
)}
250253
{!hideTitleText && (
251254
<div
252255
role="heading"
253256
aria-level={parseInt(titleTextLevel.slice(1))}
254-
className={classNames.titleContainer}
257+
className={clsx(classNames.titleContainer, !header && !hasSubSection ? classNames.sticky : undefined)}
255258
data-component-name="ObjectPageSectionTitleText"
256259
>
257260
<div className={titleClasses}>{titleText}</div>

0 commit comments

Comments
 (0)