• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

SAP / ui5-webcomponents-react / 7087169420

04 Dec 2023 01:17PM CUT coverage: 87.827% (-0.3%) from 88.098%
7087169420

Pull #5306

github

web-flow
Merge 121a4ae20 into 26029f0d1
Pull Request #5306: feat: update to `@ui5/webcomponents@1.20.0`

2869 of 3833 branches covered (0.0%)

2 of 2 new or added lines in 1 file covered. (100.0%)

10 existing lines in 2 files now uncovered.

5166 of 5882 relevant lines covered (87.83%)

22883.25 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.19
/packages/main/src/components/DynamicPage/index.tsx
1
'use client';
2

3
import { debounce, ThemingParameters, useSyncRef } from '@ui5/webcomponents-react-base';
4
import { clsx } from 'clsx';
5
import type { ReactElement, ReactNode } from 'react';
6
import React, { cloneElement, forwardRef, useEffect, useRef, useState } from 'react';
7
import { createUseStyles } from 'react-jss';
8
import { GlobalStyleClasses, PageBackgroundDesign } from '../../enums/index.js';
9
import type { CommonProps } from '../../interfaces/index.js';
10
import { useObserveHeights } from '../../internal/useObserveHeights.js';
11
import { DynamicPageAnchorBar } from '../DynamicPageAnchorBar/index.js';
12
import { FlexBox } from '../FlexBox/index.js';
13
import { DynamicPageCssVariables, styles } from './DynamicPage.jss.js';
14

15
export interface DynamicPagePropTypes extends Omit<CommonProps, 'title' | 'children'> {
16
  /**
17
   * Determines the background color of DynamicPage.
18
   */
19
  backgroundDesign?: PageBackgroundDesign | keyof typeof PageBackgroundDesign;
20
  /**
21
   * Defines whether the `headerContent` can be hidden by scrolling down.
22
   */
23
  alwaysShowContentHeader?: boolean;
24
  /**
25
   * Determines whether the expand/collapse `headerContent` button is shown.
26
   */
27
  showHideHeaderButton?: boolean;
28
  /**
29
   * Determines whether the pin button is shown.
30
   */
31
  headerContentPinnable?: boolean;
32
  /**
33
   * Defines the upper, always static, title section of the `DynamicPage`.
34
   *
35
   * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `DynamicPageTitle` in order to preserve the intended design.
36
   */
37
  headerTitle?: ReactElement;
38
  /**
39
   * Defines the dynamic header section of the `DynamicPage`.
40
   *
41
   * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `DynamicPageHeader` in order to preserve the intended design.
42
   */
43
  headerContent?: ReactElement;
44
  /**
45
   * React element which defines the footer content.
46
   *
47
   * __Note:__ To preserve the intended design, please use only non-content based `height` values (`px`, `rem`, `vh`, etc.) as height of the `DynamicPage`.
48
   * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `Bar` with `design={BarDesign.FloatingFooter}` in order to preserve the intended design.
49
   */
50
  footer?: ReactElement;
51
  /**
52
   * React element or node array which defines the content.
53
   *
54
   * __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).
55
   */
56
  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;
71
  /**
72
   * Defines internally used a11y properties.
73
   */
74
  a11yConfig?: {
75
    dynamicPageAnchorBar?: {
76
      role?: string;
77
    };
78
    dynamicPageFooter?: {
79
      role?: string;
80
      'aria-label'?: string;
81
      'aria-labelledby'?: string;
82
    };
83
  };
84
  /**
85
   * Fired when the `headerContent` is expanded or collapsed.
86
   */
87
  onToggleHeaderContent?: (visible: boolean) => void;
88
  /**
89
   * Fired when the `headerContent` changes its `pinned` state.
90
   */
91
  onPinnedStateChange?: (pinned: boolean) => void;
92
}
93

94
/**
95
 * Defines the current state of the component.
96
 */
97
enum HEADER_STATES {
420✔
98
  AUTO = 'AUTO',
420✔
99
  VISIBLE_PINNED = 'VISIBLE_PINNED',
420✔
100
  HIDDEN_PINNED = 'HIDDEN_PINNED',
420✔
101
  VISIBLE = 'VISIBLE',
420✔
102
  HIDDEN = 'HIDDEN'
420✔
103
}
104

105
const useStyles = createUseStyles(styles, { name: 'DynamicPage' });
420✔
106
/**
107
 * The dynamic page is a generic layout control designed to support various floorplans and use cases.
108
 * The content of both the header and the page can differ from floorplan to floorplan.
109
 *
110
 * The header of the dynamic page is collapsible, which helps users to focus on the actual page content, but still ensures that important header information
111
 * and actions are readily available.
112
 */
113
const DynamicPage = forwardRef<HTMLDivElement, DynamicPagePropTypes>((props, ref) => {
420✔
114
  const {
115
    headerTitle,
116
    headerContent,
117
    style,
118
    backgroundDesign,
119
    showHideHeaderButton,
120
    headerContentPinnable,
121
    alwaysShowContentHeader,
122
    children,
123
    className,
124
    footer,
125
    a11yConfig,
126
    onToggleHeaderContent,
127
    onPinnedStateChange,
128
    headerCollapsed: headerCollapsedProp,
129
    preserveHeaderStateOnScroll,
130
    ...rest
131
  } = props;
1,862✔
132
  const { onScroll: _1, ...propsWithoutOmitted } = rest;
1,860✔
133

134
  const anchorBarRef = useRef<HTMLDivElement>(null);
1,860✔
135
  const [componentRef, dynamicPageRef] = useSyncRef<HTMLDivElement>(ref);
1,860✔
136
  const contentRef = useRef<HTMLDivElement>(null);
1,860✔
137

138
  const [componentRefTopHeader, topHeaderRef] = useSyncRef<HTMLDivElement>((headerTitle as any)?.ref);
1,860✔
139
  const [componentRefHeaderContent, headerContentRef] = useSyncRef<HTMLDivElement>((headerContent as any)?.ref);
1,860✔
140
  const scrollTimeout = useRef(0);
1,860✔
141

142
  const [headerState, setHeaderState] = useState<HEADER_STATES>(
1,860✔
143
    alwaysShowContentHeader ? HEADER_STATES.VISIBLE_PINNED : HEADER_STATES.AUTO
1,860✔
144
  );
145
  const isToggledRef = useRef(false);
1,860✔
146
  const [isOverflowing, setIsOverflowing] = useState(false);
1,860✔
147

148
  const [headerCollapsedInternal, setHeaderCollapsedInternal] = useState<undefined | boolean>(headerCollapsedProp);
1,860✔
149
  // observe heights of header parts
150
  const { topHeaderHeight, headerCollapsed } = useObserveHeights(
1,860✔
151
    dynamicPageRef,
152
    topHeaderRef,
153
    headerContentRef,
154
    anchorBarRef,
155
    [headerCollapsedInternal, setHeaderCollapsedInternal],
156
    {
157
      noHeader: false,
158
      fixedHeader: headerState === HEADER_STATES.VISIBLE_PINNED || headerState === HEADER_STATES.HIDDEN_PINNED,
3,401✔
159
      scrollTimeout,
160
      preserveHeaderStateOnScroll
161
    }
162
  );
163

164
  useEffect(() => {
1,860✔
165
    if (preserveHeaderStateOnScroll && headerState === HEADER_STATES.AUTO) {
568✔
166
      if (
3!
167
        dynamicPageRef.current.scrollTop <=
168
        (topHeaderRef?.current.offsetHeight ?? 0) +
3!
169
          Math.max(0, headerContentRef.current.offsetHeight ?? 0 - topHeaderRef?.current.offsetHeight ?? 0)
3!
170
      ) {
171
        setHeaderState(HEADER_STATES.VISIBLE);
3✔
172
      } else {
UNCOV
173
        setHeaderState(HEADER_STATES.HIDDEN);
×
174
      }
175
    }
176
  }, [preserveHeaderStateOnScroll, headerState]);
177

178
  useEffect(() => {
1,860✔
179
    if (headerCollapsedProp != null) {
237✔
180
      setHeaderCollapsedInternal(headerCollapsedProp);
42✔
181
      onToggleHeaderContentInternal(undefined, headerCollapsedProp);
42✔
182
    }
183
  }, [headerCollapsedProp]);
184

185
  const classes = useStyles();
1,860✔
186
  const dynamicPageClasses = clsx(
1,860✔
187
    classes.dynamicPage,
188
    GlobalStyleClasses.sapScrollBar,
189
    classes[`background${backgroundDesign}`],
190
    className,
191
    [HEADER_STATES.HIDDEN, HEADER_STATES.HIDDEN_PINNED].includes(headerState) && classes.headerCollapsed
2,031✔
192
  );
193

194
  useEffect(() => {
1,860✔
195
    const debouncedObserverFn = debounce(([element]: IntersectionObserverEntry[]) => {
199✔
196
      setIsOverflowing(!element.isIntersecting);
74✔
197
    }, 250);
198
    const observer = new IntersectionObserver(debouncedObserverFn, {
199✔
199
      root: dynamicPageRef.current,
200
      threshold: 0.98,
201
      rootMargin: '0px 0px -60px 0px' // negative bottom margin for footer height
202
    });
203

204
    if (contentRef.current) {
199✔
205
      observer.observe(contentRef.current);
199✔
206
    }
207

208
    return () => {
199✔
209
      observer.disconnect();
180✔
210
      debouncedObserverFn.cancel();
180✔
211
    };
212
  }, []);
213

214
  const timeoutRef = useRef<undefined | ReturnType<typeof setTimeout>>(undefined);
1,860✔
215
  useEffect(() => {
1,860✔
216
    const dynamicPage = dynamicPageRef.current;
568✔
217
    const oneTimeScrollHandler = (e) => {
568✔
218
      setHeaderState(HEADER_STATES.AUTO);
109✔
219
      // only collapse the header after it was programmatically expanded, if the header shouldn't be visible
220
      if (
109✔
221
        e.target.scrollTop >
222
        (topHeaderRef?.current.offsetHeight ?? 0) +
109!
223
          Math.max(0, headerContentRef.current.offsetHeight ?? 0 - topHeaderRef?.current.offsetHeight ?? 0)
109!
224
      ) {
225
        setHeaderCollapsedInternal(true);
87✔
226
      }
227
    };
228
    if (
568✔
229
      !preserveHeaderStateOnScroll &&
1,527✔
230
      (headerState === HEADER_STATES.VISIBLE || headerState === HEADER_STATES.HIDDEN)
231
    ) {
232
      // only reset state after scroll if scroll isn't invoked by expanding the header
233
      const timeout = scrollTimeout.current - performance.now();
184✔
234
      clearTimeout(timeoutRef.current);
184✔
235
      if (timeout > 0) {
184✔
236
        timeoutRef.current = setTimeout(() => {
147✔
237
          dynamicPage?.addEventListener('scroll', oneTimeScrollHandler, {
88✔
238
            once: true
239
          });
240
        }, timeout + 50);
241
      } else {
242
        dynamicPage?.addEventListener('scroll', oneTimeScrollHandler, {
37✔
243
          once: true
244
        });
245
      }
246
    }
247
    return () => {
568✔
248
      dynamicPage?.removeEventListener('scroll', oneTimeScrollHandler);
549✔
249
    };
250
  }, [dynamicPageRef, headerState, preserveHeaderStateOnScroll]);
251

252
  const onToggleHeaderContentInternal = (e?, headerCollapsedProp?) => {
1,860✔
253
    e?.stopPropagation();
138✔
254
    if (!isToggledRef.current) {
138✔
255
      isToggledRef.current = true;
21✔
256
    }
257
    onToggleHeaderContentVisibility(headerCollapsedProp ?? !headerCollapsed);
138✔
258
  };
259

260
  const onToggleHeaderContentVisibility = (localHeaderCollapsed) => {
1,860✔
261
    scrollTimeout.current = performance.now() + 500;
138✔
262
    setHeaderState((oldState) => {
138✔
263
      if (oldState === HEADER_STATES.VISIBLE_PINNED || oldState === HEADER_STATES.HIDDEN_PINNED) {
138!
UNCOV
264
        return localHeaderCollapsed ? HEADER_STATES.HIDDEN_PINNED : HEADER_STATES.VISIBLE_PINNED;
×
265
      }
266
      return localHeaderCollapsed ? HEADER_STATES.HIDDEN : HEADER_STATES.VISIBLE;
138✔
267
    });
268
  };
269

270
  useEffect(() => {
1,860✔
271
    if (headerState === HEADER_STATES.VISIBLE_PINNED || headerState === HEADER_STATES.VISIBLE) {
565✔
272
      setHeaderCollapsedInternal(false);
221✔
273
    } else if (headerState === HEADER_STATES.HIDDEN_PINNED || headerState === HEADER_STATES.HIDDEN) {
344✔
274
      setHeaderCollapsedInternal(true);
49✔
275
    }
276
  }, [headerState]);
277

278
  const onHoverToggleButton = (e) => {
1,860✔
279
    if (topHeaderRef.current) {
69✔
280
      topHeaderRef.current.style.backgroundColor =
69✔
281
        e?.type === 'mouseover' ? ThemingParameters.sapObjectHeader_Hover_Background : null;
69✔
282
    }
283
  };
284

285
  const handleHeaderPinnedChange = (headerWillPin) => {
1,860✔
286
    if (headerWillPin) {
38✔
287
      setHeaderState(HEADER_STATES.VISIBLE_PINNED);
25✔
288
    } else {
289
      setHeaderState(HEADER_STATES.VISIBLE);
13✔
290
    }
291
  };
292

293
  useEffect(() => {
1,860✔
294
    if (alwaysShowContentHeader !== undefined) {
283✔
295
      if (alwaysShowContentHeader) {
109✔
296
        setHeaderState(HEADER_STATES.VISIBLE_PINNED);
61✔
297
      } else {
298
        setHeaderState(HEADER_STATES.VISIBLE);
48✔
299
      }
300
    }
301
  }, [alwaysShowContentHeader]);
302

303
  const onDynamicPageScroll = (e) => {
1,860✔
304
    if (preserveHeaderStateOnScroll) {
712✔
305
      return;
27✔
306
    }
307
    if (!isToggledRef.current) {
685✔
308
      isToggledRef.current = true;
76✔
309
    }
310
    if (typeof props?.onScroll === 'function') {
685!
UNCOV
311
      props.onScroll(e);
×
312
    }
313
    if (headerState === HEADER_STATES.HIDDEN_PINNED && e.target.scrollTop === 0) {
685!
UNCOV
314
      setHeaderState(HEADER_STATES.VISIBLE_PINNED);
×
315
    }
316
  };
317

318
  const dynamicPageStyles = { ...style };
1,860✔
319
  if (headerCollapsed === true && headerContent) {
1,860✔
320
    scrollTimeout.current = performance.now() + 200;
652✔
321
    dynamicPageStyles[DynamicPageCssVariables.titleFontSize] = ThemingParameters.sapObjectHeader_Title_SnappedFontSize;
652✔
322
  }
323

324
  useEffect(() => {
1,860✔
325
    if (typeof onToggleHeaderContent === 'function' && isToggledRef.current) {
548✔
326
      onToggleHeaderContent(headerCollapsed !== true);
98✔
327
    }
328
  }, [headerCollapsed]);
329

330
  const top =
331
    headerState === HEADER_STATES.VISIBLE_PINNED || headerState === HEADER_STATES.VISIBLE
1,860✔
332
      ? (headerContentRef?.current?.offsetHeight ?? 0) + topHeaderHeight
850✔
333
      : topHeaderHeight;
334

335
  return (
1,860✔
336
    <div
337
      ref={componentRef}
338
      className={dynamicPageClasses}
339
      style={dynamicPageStyles}
340
      onScroll={onDynamicPageScroll}
341
      {...propsWithoutOmitted}
342
    >
343
      {headerTitle &&
3,688✔
344
        cloneElement(headerTitle, {
345
          'data-not-clickable':
346
            (alwaysShowContentHeader && !headerContentPinnable) ||
5,618✔
347
            !headerContent ||
348
            (!showHideHeaderButton && !headerContentPinnable),
349
          ref: componentRefTopHeader,
350
          className: clsx(classes.title, headerTitle?.props?.className),
351
          onToggleHeaderContentVisibility: onToggleHeaderContentInternal,
352
          'data-header-content-visible': headerContent && headerCollapsed !== true
3,494✔
353
        })}
354
      {headerContent &&
3,526✔
355
        cloneElement(headerContent, {
356
          ref: componentRefHeaderContent,
357
          style:
358
            headerCollapsed === true
1,666✔
359
              ? { ...headerContent.props.style, position: 'relative', visibility: 'hidden' }
360
              : headerContent.props.style,
361
          className: clsx(classes.header, headerContent?.props?.className),
362
          headerPinned:
363
            preserveHeaderStateOnScroll ||
4,547✔
364
            headerState === HEADER_STATES.VISIBLE_PINNED ||
365
            headerState === HEADER_STATES.VISIBLE,
366
          topHeaderHeight
367
        })}
368
      <FlexBox
369
        data-component-name="DynamicPageAnchorBarContainer"
370
        className={classes.anchorBar}
371
        ref={anchorBarRef}
372
        style={{ top }}
373
      >
374
        <DynamicPageAnchorBar
375
          headerContentPinnable={headerContentPinnable}
376
          showHideHeaderButton={showHideHeaderButton}
377
          headerContentVisible={headerContent && headerCollapsed !== true}
3,526✔
378
          headerPinned={headerState === HEADER_STATES.VISIBLE_PINNED || headerState === HEADER_STATES.HIDDEN_PINNED}
3,401✔
379
          a11yConfig={a11yConfig}
380
          onHoverToggleButton={onHoverToggleButton}
381
          onToggleHeaderContentVisibility={onToggleHeaderContentInternal}
382
          onPinnedStateChange={onPinnedStateChange}
383
          setHeaderPinned={handleHeaderPinnedChange}
384
        />
385
      </FlexBox>
386
      <div
387
        ref={contentRef}
388
        data-component-name="DynamicPageContent"
389
        className={classes.contentContainer}
390
        style={{
391
          paddingBlockEnd: footer ? '1rem' : 0
1,860✔
392
        }}
393
      >
394
        {typeof children === 'function' ? children({ stickyHeaderHeight: top + 1 /*anchorBar height */ }) : children}
1,860✔
395
      </div>
396
      {footer && (
1,912✔
397
        <div
398
          className={classes.footer}
399
          style={{ position: isOverflowing ? 'sticky' : 'absolute' }}
52!
400
          data-component-name="DynamicPageFooter"
401
          role={a11yConfig?.dynamicPageFooter?.role ?? 'contentinfo'}
90✔
402
          aria-label={a11yConfig?.dynamicPageFooter?.['aria-label']}
403
          aria-labelledby={a11yConfig?.dynamicPageFooter?.['aria-labelledby']}
404
        >
405
          {footer}
406
        </div>
407
      )}
408
    </div>
409
  );
410
});
411

412
DynamicPage.displayName = 'DynamicPage';
420✔
413

414
DynamicPage.defaultProps = {
420✔
415
  backgroundDesign: PageBackgroundDesign.Solid,
416
  showHideHeaderButton: true,
417
  headerContentPinnable: true
418
};
419

420
export { DynamicPage };
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc