Skip to content

Commit 26029f0

Browse files
authored
feat(DynamicPage): add headerCollapsed & preserveHeaderStateOnScroll props (#5279)
Closes #4206
1 parent 4fd2673 commit 26029f0

File tree

3 files changed

+216
-24
lines changed

3 files changed

+216
-24
lines changed

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

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,5 +486,144 @@ describe('DynamicPage', () => {
486486

487487
checksWithScroll();
488488
});
489+
490+
it('prop: headerCollapsed', () => {
491+
document.body.style.margin = '0px';
492+
const TestComp = () => {
493+
const [headerCollapsed, setHeaderCollapsed] = useState(true);
494+
const handleToggle = (visible) => {
495+
setHeaderCollapsed(!visible);
496+
};
497+
return (
498+
<>
499+
<button
500+
style={{ height: '40px' }}
501+
data-testid="toggle"
502+
onClick={() => {
503+
setHeaderCollapsed((prev) => !prev);
504+
}}
505+
>
506+
Toggle headerCollapsed
507+
</button>
508+
<DynamicPage
509+
data-testid="dp"
510+
style={{ height: 'calc(100vh - 40px)' }}
511+
headerTitle={<DynamicPageTitle header="Heading" subHeader="SubHeading" />}
512+
headerContent={<DynamicPageHeader>DynamicPageHeader</DynamicPageHeader>}
513+
headerCollapsed={headerCollapsed}
514+
onToggleHeaderContent={handleToggle}
515+
>
516+
<div style={{ height: '2000px' }}>Content</div>
517+
</DynamicPage>
518+
</>
519+
);
520+
};
521+
cy.mount(<TestComp />);
522+
523+
//collapsed
524+
cy.get('[data-component-name="DynamicPageAnchorBarPinBtn"]').should('not.exist');
525+
526+
cy.findByTestId('toggle').click();
527+
cy.get('[data-component-name="DynamicPageAnchorBarPinBtn"]').should('be.visible');
528+
cy.wait(200);
529+
530+
cy.findByTestId('dp').scrollTo(0, 800, { duration: 300 });
531+
cy.get('[data-component-name="DynamicPageAnchorBarPinBtn"]').should('not.exist');
532+
533+
cy.findByTestId('toggle').click();
534+
cy.get('[data-component-name="DynamicPageAnchorBarPinBtn"]').should('be.visible');
535+
cy.wait(200);
536+
537+
cy.findByTestId('dp').scrollTo(0, 750, { duration: 300 });
538+
cy.get('[data-component-name="DynamicPageAnchorBarPinBtn"]').should('not.exist');
539+
cy.wait(200);
540+
541+
cy.findByTestId('dp').scrollTo(0, 0, { duration: 300 });
542+
cy.get('[data-component-name="DynamicPageAnchorBarPinBtn"]').should('be.visible');
543+
});
544+
545+
it('prop: preserveHeaderStateOnScroll', () => {
546+
document.body.style.margin = '0px';
547+
const TestComp = () => {
548+
const [headerCollapsed, setHeaderCollapsed] = useState<boolean | undefined>(undefined);
549+
const [preserveHeaderStateOnScroll, setPreserveHeaderStateOnScroll] = useState(true);
550+
const handleToggle = (visible) => {
551+
setHeaderCollapsed(!visible);
552+
};
553+
return (
554+
<>
555+
<button
556+
style={{ height: '40px' }}
557+
data-testid="col"
558+
onClick={() => {
559+
setHeaderCollapsed((prev) => !prev);
560+
}}
561+
>
562+
Toggle headerCollapsed
563+
</button>
564+
<button
565+
style={{ height: '40px' }}
566+
data-testid="pres"
567+
onClick={() => {
568+
setPreserveHeaderStateOnScroll((prev) => !prev);
569+
}}
570+
>
571+
Toggle preserveHeaderStateOnScroll
572+
</button>
573+
<DynamicPage
574+
data-testid="dp"
575+
style={{ height: 'calc(100vh - 40px)' }}
576+
headerTitle={<DynamicPageTitle header="Heading" subHeader="SubHeading" />}
577+
headerContent={<DynamicPageHeader>DynamicPageHeader</DynamicPageHeader>}
578+
headerCollapsed={headerCollapsed}
579+
onToggleHeaderContent={handleToggle}
580+
preserveHeaderStateOnScroll={preserveHeaderStateOnScroll}
581+
>
582+
<div style={{ height: '2000px' }}>Content</div>
583+
</DynamicPage>
584+
</>
585+
);
586+
};
587+
cy.mount(<TestComp />);
588+
589+
cy.findByTestId('dp').scrollTo(0, 800);
590+
cy.get('[data-component-name="DynamicPageAnchorBarPinBtn"]').should('be.visible');
591+
592+
cy.findByTestId('dp').scrollTo(0, 0);
593+
cy.get('[data-component-name="DynamicPageAnchorBarPinBtn"]').should('be.visible');
594+
595+
cy.wait(300);
596+
cy.findByTestId('dp').scrollTo(0, 800);
597+
cy.get('[data-component-name="DynamicPageAnchorBarExpandBtn"]').click();
598+
cy.get('[data-component-name="DynamicPageAnchorBarPinBtn"]').should('not.exist');
599+
600+
cy.findByTestId('dp').scrollTo(0, 0);
601+
cy.get('[data-component-name="DynamicPageAnchorBarPinBtn"]').should('not.exist');
602+
603+
cy.wait(300);
604+
cy.findByTestId('dp').scrollTo(0, 800);
605+
cy.get('[data-component-name="DynamicPageAnchorBarExpandBtn"]').click();
606+
cy.get('[data-component-name="DynamicPageAnchorBarPinBtn"]').should('be.visible');
607+
608+
cy.findByTestId('col').click();
609+
cy.get('[data-component-name="DynamicPageAnchorBarPinBtn"]').should('not.exist');
610+
611+
cy.findByTestId('dp').scrollTo(0, 0);
612+
cy.get('[data-component-name="DynamicPageAnchorBarPinBtn"]').should('not.exist');
613+
614+
cy.findByTestId('col').click();
615+
cy.get('[data-component-name="DynamicPageAnchorBarPinBtn"]').should('be.visible');
616+
617+
cy.findByTestId('dp').scrollTo(0, 800);
618+
cy.get('[data-component-name="DynamicPageAnchorBarPinBtn"]').should('be.visible');
619+
620+
cy.findByTestId('pres').click();
621+
cy.wait(300);
622+
cy.findByTestId('dp').scrollTo(0, 750, { duration: 300 });
623+
cy.get('[data-component-name="DynamicPageAnchorBarPinBtn"]').should('not.exist');
624+
625+
cy.findByTestId('dp').scrollTo(0, 0, { duration: 300 });
626+
cy.get('[data-component-name="DynamicPageAnchorBarPinBtn"]').should('be.visible');
627+
});
489628
cypressPassThroughTestsFactory(DynamicPage);
490629
});

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

Lines changed: 67 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { debounce, enrichEventWithDetails, ThemingParameters, useSyncRef } from '@ui5/webcomponents-react-base';
3+
import { debounce, ThemingParameters, useSyncRef } from '@ui5/webcomponents-react-base';
44
import { clsx } from 'clsx';
55
import type { ReactElement, ReactNode } from 'react';
66
import React, { cloneElement, forwardRef, useEffect, useRef, useState } from 'react';
@@ -54,6 +54,20 @@ export interface DynamicPagePropTypes extends Omit<CommonProps, 'title' | 'child
5454
* __Note:__ Assigning `children` as function is recommended when implementing sticky sub-headers. You can find out more about this [here](https://sap.github.io/ui5-webcomponents-react/?path=/story/layouts-floorplans-dynamicpage--sticky-sub-headers).
5555
*/
5656
children?: ReactNode | ReactNode[] | ((payload: { stickyHeaderHeight: number }) => ReactElement);
57+
/**
58+
* Determines whether the header is expanded. You can use this to initialize the component with a collapsed header.
59+
*
60+
* __Note:__ Changes through user interaction (scrolling, manually expanding/collapsing the header, etc.) are still applied.
61+
*
62+
* @since 1.23.0
63+
*/
64+
headerCollapsed?: boolean;
65+
/**
66+
* Preserves the current header state when scrolling. For example, if the user expands the header by clicking on the title and then scrolls down the page, the header will remain expanded.
67+
*
68+
* @since 1.23.0
69+
*/
70+
preserveHeaderStateOnScroll?: boolean;
5771
/**
5872
* Defines internally used a11y properties.
5973
*/
@@ -111,6 +125,8 @@ const DynamicPage = forwardRef<HTMLDivElement, DynamicPagePropTypes>((props, ref
111125
a11yConfig,
112126
onToggleHeaderContent,
113127
onPinnedStateChange,
128+
headerCollapsed: headerCollapsedProp,
129+
preserveHeaderStateOnScroll,
114130
...rest
115131
} = props;
116132
const { onScroll: _1, ...propsWithoutOmitted } = rest;
@@ -129,7 +145,7 @@ const DynamicPage = forwardRef<HTMLDivElement, DynamicPagePropTypes>((props, ref
129145
const isToggledRef = useRef(false);
130146
const [isOverflowing, setIsOverflowing] = useState(false);
131147

132-
const [headerCollapsedInternal, setHeaderCollapsedInternal] = useState<undefined | boolean>(undefined);
148+
const [headerCollapsedInternal, setHeaderCollapsedInternal] = useState<undefined | boolean>(headerCollapsedProp);
133149
// observe heights of header parts
134150
const { topHeaderHeight, headerCollapsed } = useObserveHeights(
135151
dynamicPageRef,
@@ -140,10 +156,32 @@ const DynamicPage = forwardRef<HTMLDivElement, DynamicPagePropTypes>((props, ref
140156
{
141157
noHeader: false,
142158
fixedHeader: headerState === HEADER_STATES.VISIBLE_PINNED || headerState === HEADER_STATES.HIDDEN_PINNED,
143-
scrollTimeout
159+
scrollTimeout,
160+
preserveHeaderStateOnScroll
144161
}
145162
);
146163

164+
useEffect(() => {
165+
if (preserveHeaderStateOnScroll && headerState === HEADER_STATES.AUTO) {
166+
if (
167+
dynamicPageRef.current.scrollTop <=
168+
(topHeaderRef?.current.offsetHeight ?? 0) +
169+
Math.max(0, headerContentRef.current.offsetHeight ?? 0 - topHeaderRef?.current.offsetHeight ?? 0)
170+
) {
171+
setHeaderState(HEADER_STATES.VISIBLE);
172+
} else {
173+
setHeaderState(HEADER_STATES.HIDDEN);
174+
}
175+
}
176+
}, [preserveHeaderStateOnScroll, headerState]);
177+
178+
useEffect(() => {
179+
if (headerCollapsedProp != null) {
180+
setHeaderCollapsedInternal(headerCollapsedProp);
181+
onToggleHeaderContentInternal(undefined, headerCollapsedProp);
182+
}
183+
}, [headerCollapsedProp]);
184+
147185
const classes = useStyles();
148186
const dynamicPageClasses = clsx(
149187
classes.dynamicPage,
@@ -173,6 +211,7 @@ const DynamicPage = forwardRef<HTMLDivElement, DynamicPagePropTypes>((props, ref
173211
};
174212
}, []);
175213

214+
const timeoutRef = useRef<undefined | ReturnType<typeof setTimeout>>(undefined);
176215
useEffect(() => {
177216
const dynamicPage = dynamicPageRef.current;
178217
const oneTimeScrollHandler = (e) => {
@@ -186,11 +225,15 @@ const DynamicPage = forwardRef<HTMLDivElement, DynamicPagePropTypes>((props, ref
186225
setHeaderCollapsedInternal(true);
187226
}
188227
};
189-
if (headerState === HEADER_STATES.VISIBLE || headerState === HEADER_STATES.HIDDEN) {
228+
if (
229+
!preserveHeaderStateOnScroll &&
230+
(headerState === HEADER_STATES.VISIBLE || headerState === HEADER_STATES.HIDDEN)
231+
) {
190232
// only reset state after scroll if scroll isn't invoked by expanding the header
191233
const timeout = scrollTimeout.current - performance.now();
234+
clearTimeout(timeoutRef.current);
192235
if (timeout > 0) {
193-
setTimeout(() => {
236+
timeoutRef.current = setTimeout(() => {
194237
dynamicPage?.addEventListener('scroll', oneTimeScrollHandler, {
195238
once: true
196239
});
@@ -204,16 +247,23 @@ const DynamicPage = forwardRef<HTMLDivElement, DynamicPagePropTypes>((props, ref
204247
return () => {
205248
dynamicPage?.removeEventListener('scroll', oneTimeScrollHandler);
206249
};
207-
}, [dynamicPageRef, headerState]);
250+
}, [dynamicPageRef, headerState, preserveHeaderStateOnScroll]);
208251

209-
const onToggleHeaderContentVisibility = (e) => {
252+
const onToggleHeaderContentInternal = (e?, headerCollapsedProp?) => {
253+
e?.stopPropagation();
254+
if (!isToggledRef.current) {
255+
isToggledRef.current = true;
256+
}
257+
onToggleHeaderContentVisibility(headerCollapsedProp ?? !headerCollapsed);
258+
};
259+
260+
const onToggleHeaderContentVisibility = (localHeaderCollapsed) => {
210261
scrollTimeout.current = performance.now() + 500;
211-
const shouldHideHeader = !e.detail.visible;
212262
setHeaderState((oldState) => {
213263
if (oldState === HEADER_STATES.VISIBLE_PINNED || oldState === HEADER_STATES.HIDDEN_PINNED) {
214-
return shouldHideHeader ? HEADER_STATES.HIDDEN_PINNED : HEADER_STATES.VISIBLE_PINNED;
264+
return localHeaderCollapsed ? HEADER_STATES.HIDDEN_PINNED : HEADER_STATES.VISIBLE_PINNED;
215265
}
216-
return shouldHideHeader ? HEADER_STATES.HIDDEN : HEADER_STATES.VISIBLE;
266+
return localHeaderCollapsed ? HEADER_STATES.HIDDEN : HEADER_STATES.VISIBLE;
217267
});
218268
};
219269

@@ -232,14 +282,6 @@ const DynamicPage = forwardRef<HTMLDivElement, DynamicPagePropTypes>((props, ref
232282
}
233283
};
234284

235-
const onToggleHeaderContentInternal = (e) => {
236-
e.stopPropagation();
237-
if (!isToggledRef.current) {
238-
isToggledRef.current = true;
239-
}
240-
onToggleHeaderContentVisibility(enrichEventWithDetails(e, { visible: headerCollapsed }));
241-
};
242-
243285
const handleHeaderPinnedChange = (headerWillPin) => {
244286
if (headerWillPin) {
245287
setHeaderState(HEADER_STATES.VISIBLE_PINNED);
@@ -259,6 +301,9 @@ const DynamicPage = forwardRef<HTMLDivElement, DynamicPagePropTypes>((props, ref
259301
}, [alwaysShowContentHeader]);
260302

261303
const onDynamicPageScroll = (e) => {
304+
if (preserveHeaderStateOnScroll) {
305+
return;
306+
}
262307
if (!isToggledRef.current) {
263308
isToggledRef.current = true;
264309
}
@@ -314,7 +359,10 @@ const DynamicPage = forwardRef<HTMLDivElement, DynamicPagePropTypes>((props, ref
314359
? { ...headerContent.props.style, position: 'relative', visibility: 'hidden' }
315360
: headerContent.props.style,
316361
className: clsx(classes.header, headerContent?.props?.className),
317-
headerPinned: headerState === HEADER_STATES.VISIBLE_PINNED || headerState === HEADER_STATES.VISIBLE,
362+
headerPinned:
363+
preserveHeaderStateOnScroll ||
364+
headerState === HEADER_STATES.VISIBLE_PINNED ||
365+
headerState === HEADER_STATES.VISIBLE,
318366
topHeaderHeight
319367
})}
320368
<FlexBox

packages/main/src/internal/useObserveHeights.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,14 @@ export const useObserveHeights = (
1212
{
1313
noHeader,
1414
fixedHeader = false,
15-
scrollTimeout = { current: 0 }
16-
}: { noHeader: boolean; fixedHeader?: boolean; scrollTimeout?: MutableRefObject<number> }
15+
scrollTimeout = { current: 0 },
16+
preserveHeaderStateOnScroll
17+
}: {
18+
noHeader: boolean;
19+
fixedHeader?: boolean;
20+
scrollTimeout?: MutableRefObject<number>;
21+
preserveHeaderStateOnScroll?: boolean;
22+
}
1723
) => {
1824
const [topHeaderHeight, setTopHeaderHeight] = useState(0);
1925
const [headerContentHeight, setHeaderContentHeight] = useState(0);
@@ -24,7 +30,6 @@ export const useObserveHeights = (
2430
(e) => {
2531
const scrollDown = prevScrollTop.current <= e.target.scrollTop;
2632
prevScrollTop.current = e.target.scrollTop;
27-
2833
if (scrollTimeout.current >= performance.now()) {
2934
return;
3035
}
@@ -52,13 +57,13 @@ export const useObserveHeights = (
5257

5358
useEffect(() => {
5459
const page = pageRef.current;
55-
if (!fixedHeader) {
60+
if (!fixedHeader && !preserveHeaderStateOnScroll) {
5661
page.addEventListener('scroll', onScroll);
5762
}
5863
return () => {
5964
page.removeEventListener('scroll', onScroll);
6065
};
61-
}, [onScroll, fixedHeader]);
66+
}, [onScroll, fixedHeader, preserveHeaderStateOnScroll]);
6267

6368
// top header
6469
useEffect(() => {

0 commit comments

Comments
 (0)