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

SAP / ui5-webcomponents-react / 6689350535

30 Oct 2023 06:57AM CUT coverage: 87.625% (-0.03%) from 87.659%
6689350535

Pull #5193

github

web-flow
Merge 818f00fc4 into 99ae5d6b1
Pull Request #5193: chore(deps): update all non-major dependencies (minor)

2779 of 3733 branches covered (0.0%)

5084 of 5802 relevant lines covered (87.62%)

31099.11 hits per line

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

86.14
/packages/main/src/components/ObjectPage/index.tsx
1
'use client';
2

3
import { debounce, enrichEventWithDetails, ThemingParameters, useSyncRef } from '@ui5/webcomponents-react-base';
4
import { clsx } from 'clsx';
5
import type { ReactElement, ReactNode } from 'react';
6
import React, {
7
  cloneElement,
8
  forwardRef,
9
  isValidElement,
10
  useCallback,
11
  useEffect,
12
  useMemo,
13
  useRef,
14
  useState
15
} from 'react';
16
import { createUseStyles } from 'react-jss';
17
import { AvatarSize, GlobalStyleClasses, ObjectPageMode } from '../../enums/index.js';
18
import type { CommonProps } from '../../interfaces/index.js';
19
import { addCustomCSSWithScoping } from '../../internal/addCustomCSSWithScoping.js';
20
import { safeGetChildrenArray } from '../../internal/safeGetChildrenArray.js';
21
import { useObserveHeights } from '../../internal/useObserveHeights.js';
22
import type { AvatarPropTypes } from '../../webComponents/index.js';
23
import { Tab, TabContainer } from '../../webComponents/index.js';
24
import { DynamicPageCssVariables } from '../DynamicPage/DynamicPage.jss.js';
25
import { DynamicPageAnchorBar } from '../DynamicPageAnchorBar/index.js';
26
import { DynamicPageHeader } from '../DynamicPageHeader/index.js';
27
import type { ObjectPageSectionPropTypes } from '../ObjectPageSection/index.js';
28
import { CollapsedAvatar } from './CollapsedAvatar.js';
29
import { styles } from './ObjectPage.jss.js';
30
import { extractSectionIdFromHtmlId, getSectionById } from './ObjectPageUtils.js';
31

32
addCustomCSSWithScoping(
406✔
33
  'ui5-tabcontainer',
34
  // padding-inline is used here to ensure the same responsive padding behavior as for the rest of the component
35
  // todo: the additional text span adds 3px to the container - needs to be investigated why
36
  `
37
  :host([data-component-name="ObjectPageTabContainer"]) .ui5-tc__header {
38
    padding: 0;
39
    padding-inline: var(--_ui5wcr_ObjectPage_tab_bar_inline_padding);
40
    box-shadow: inset 0 -0.0625rem ${ThemingParameters.sapPageHeader_BorderColor}, 0 0.125rem 0.25rem 0 rgb(0 0 0 / 8%);
41
  }
42
  :host([data-component-name="ObjectPageTabContainer"]) [id$="additionalText"] {
43
    display: none;
44
  }
45
  `
46
);
47

48
const TAB_CONTAINER_HEADER_HEIGHT = 48;
406✔
49

50
export interface ObjectPagePropTypes extends Omit<CommonProps, 'placeholder'> {
51
  /**
52
   * Defines the upper, always static, title section of the `ObjectPage`.
53
   *
54
   * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `DynamicPageTitle` in order to preserve the intended design.
55
   * __Note:__ If not defined otherwise the prop `showSubHeaderRight` of the `DynamicPageTitle` is set to `true` by default.
56
   */
57
  headerTitle?: ReactElement;
58
  /**
59
   * Defines the dynamic header section of the `ObjectPage`.
60
   *
61
   * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `DynamicPageHeader` in order to preserve the intended design.
62
   */
63
  headerContent?: ReactElement;
64
  /**
65
   * React element which defines the footer content.
66
   *
67
   * __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.
68
   */
69
  footer?: ReactElement;
70
  /**
71
   * Defines the image of the `ObjectPage`. You can pass a path to an image or an `Avatar` component.
72
   */
73
  image?: string | ReactElement;
74
  /**
75
   * Defines the content area of the `ObjectPage`. It consists of sections and subsections.
76
   *
77
   * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `ObjectPageSection` and `ObjectPageSubSection` in order to preserve the intended design.
78
   */
79
  children?: ReactElement<ObjectPageSectionPropTypes> | ReactElement<ObjectPageSectionPropTypes>[];
80
  /**
81
   * Defines the ID of the currently `ObjectPageSection` section.
82
   */
83
  selectedSectionId?: string;
84
  /**
85
   * Defines the ID of the currently `ObjectPageSubSection` section.
86
   */
87
  selectedSubSectionId?: string;
88
  /**
89
   * Defines whether the `headerContent` is hidden by scrolling down.
90
   */
91
  alwaysShowContentHeader?: boolean;
92
  /**
93
   * Defines whether the title is displayed in the content section of the header or above the image.
94
   */
95
  showTitleInHeaderContent?: boolean;
96
  /**
97
   * Defines whether the image should be displayed in a circle or in a square.<br />
98
   * __Note:__ If the `image` is not a `string`, this prop has no effect.
99
   */
100
  imageShapeCircle?: boolean;
101
  /**
102
   * Defines the `ObjectPage` mode.
103
   *
104
   * - "Default": All `ObjectPageSections` and `ObjectPageSubSections` are displayed on one page. Selecting tabs will scroll to the corresponding section.
105
   * - "IconTabBar": All `ObjectPageSections` are displayed on separate pages. Selecting tabs will lead to the corresponding page.
106
   */
107
  mode?: ObjectPageMode | keyof typeof ObjectPageMode;
108
  /**
109
   * Defines whether the pin button of the header is displayed.
110
   */
111
  showHideHeaderButton?: boolean;
112
  /**
113
   * Defines whether the `headerContent` is pinnable.
114
   */
115
  headerContentPinnable?: boolean;
116
  /**
117
   * Defines internally used a11y properties.
118
   */
119
  a11yConfig?: {
120
    objectPageTopHeader?: {
121
      role?: string;
122
      ariaRoledescription?: string;
123
    };
124
    dynamicPageAnchorBar?: {
125
      role?: string;
126
    };
127
  };
128
  /**
129
   * If set, only the specified placeholder will be displayed as content of the `ObjectPage`, no sections or sub-sections will be rendered.
130
   *
131
   * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use placeholder components like the `IllustratedMessage` or custom skeletons pages in order to preserve the intended design.
132
   */
133
  placeholder?: ReactNode;
134
  /**
135
   * Fired when the selected section changes.
136
   */
137
  onSelectedSectionChange?: (
138
    event: CustomEvent<{ selectedSectionIndex: number; selectedSectionId: string; section: HTMLDivElement }>
139
  ) => void;
140
  /**
141
   * Fired when the `headerContent` is expanded or collapsed.
142
   */
143
  onToggleHeaderContent?: (visible: boolean) => void;
144
  /**
145
   * Fired when the `headerContent` changes its pinned state.
146
   */
147
  onPinnedStateChange?: (pinned: boolean) => void;
148
}
149

150
const useStyles = createUseStyles(styles, { name: 'ObjectPage' });
406✔
151

152
/**
153
 * A component that allows apps to easily display information related to a business object.
154
 *
155
 * The `ObjectPage` is composed of a header (title and content) and block content wrapped in sections and subsections that structure the information.
156
 */
157
const ObjectPage = forwardRef<HTMLDivElement, ObjectPagePropTypes>((props, ref) => {
406✔
158
  const {
159
    headerTitle,
160
    image,
161
    footer,
162
    mode,
163
    imageShapeCircle,
164
    className,
165
    style,
166
    slot,
167
    showHideHeaderButton,
168
    children,
169
    selectedSectionId,
170
    alwaysShowContentHeader,
171
    showTitleInHeaderContent,
172
    headerContent,
173
    headerContentPinnable,
174
    a11yConfig,
175
    placeholder,
176
    onSelectedSectionChange,
177
    onToggleHeaderContent,
178
    onPinnedStateChange,
179
    ...rest
180
  } = props;
3,262✔
181

182
  const classes = useStyles();
3,262✔
183

184
  const firstSectionId = safeGetChildrenArray<ReactElement>(children)[0]?.props?.id;
3,262✔
185

186
  const [internalSelectedSectionId, setInternalSelectedSectionId] = useState(selectedSectionId ?? firstSectionId);
3,262✔
187
  const [selectedSubSectionId, setSelectedSubSectionId] = useState(props.selectedSubSectionId);
3,262✔
188
  const [headerPinned, setHeaderPinned] = useState(alwaysShowContentHeader);
3,262✔
189
  const isProgrammaticallyScrolled = useRef(false);
3,262✔
190

191
  const [componentRef, objectPageRef] = useSyncRef(ref);
3,262✔
192
  const topHeaderRef = useRef<HTMLDivElement>(null);
3,262✔
193
  const scrollEvent = useRef();
3,262✔
194
  // @ts-expect-error: useSyncRef will create a ref if not present
195
  const [componentRefHeaderContent, headerContentRef] = useSyncRef(headerContent?.ref);
3,262✔
196
  const anchorBarRef = useRef<HTMLDivElement>(null);
3,262✔
197
  const selectionScrollTimeout = useRef(null);
3,262✔
198
  const [isAfterScroll, setIsAfterScroll] = useState(false);
3,262✔
199
  const isToggledRef = useRef(false);
3,262✔
200
  const [headerCollapsedInternal, setHeaderCollapsedInternal] = useState<undefined | boolean>(undefined);
3,262✔
201
  const [scrolledHeaderExpanded, setScrolledHeaderExpanded] = useState(false);
3,262✔
202
  const scrollTimeout = useRef(0);
3,262✔
203
  const [spacerBottomHeight, setSpacerBottomHeight] = useState('0px');
3,262✔
204
  const titleInHeader = headerTitle && showTitleInHeaderContent;
3,262✔
205

206
  const prevInternalSelectedSectionId = useRef(internalSelectedSectionId);
3,262✔
207
  const fireOnSelectedChangedEvent = (targetEvent, index, id, section) => {
3,262✔
208
    if (typeof onSelectedSectionChange === 'function' && prevInternalSelectedSectionId.current !== id) {
167!
209
      onSelectedSectionChange(
×
210
        enrichEventWithDetails(targetEvent, {
211
          selectedSectionIndex: parseInt(index, 10),
212
          selectedSectionId: id,
213
          section
214
        })
215
      );
216
      prevInternalSelectedSectionId.current = id;
×
217
    }
218
  };
219
  const debouncedOnSectionChange = useRef(debounce(fireOnSelectedChangedEvent, 500)).current;
3,262✔
220
  useEffect(() => {
3,262✔
221
    return () => {
274✔
222
      debouncedOnSectionChange.cancel();
258✔
223
      clearTimeout(selectionScrollTimeout.current);
258✔
224
    };
225
  }, []);
226

227
  // observe heights of header parts
228
  const { topHeaderHeight, headerContentHeight, anchorBarHeight, totalHeaderHeight, headerCollapsed } =
229
    useObserveHeights(
3,262✔
230
      objectPageRef,
231
      topHeaderRef,
232
      headerContentRef,
233
      anchorBarRef,
234
      [headerCollapsedInternal, setHeaderCollapsedInternal],
235
      {
236
        noHeader: !headerTitle && !headerContent,
3,266✔
237
        fixedHeader: headerPinned,
238
        scrollTimeout
239
      }
240
    );
241

242
  useEffect(() => {
3,262✔
243
    if (typeof onToggleHeaderContent === 'function' && isToggledRef.current) {
698✔
244
      onToggleHeaderContent(headerCollapsed !== true);
60✔
245
    }
246
  }, [headerCollapsed]);
247

248
  const avatar = useMemo(() => {
3,262✔
249
    if (!image) {
274✔
250
      return null;
261✔
251
    }
252

253
    if (typeof image === 'string') {
13✔
254
      return (
6✔
255
        <span
256
          className={classes.headerImage}
257
          style={{ borderRadius: imageShapeCircle ? '50%' : 0, overflow: 'hidden' }}
6✔
258
        >
259
          <img src={image} className={classes.image} alt="Company Logo" />
260
        </span>
261
      );
262
    } else {
263
      return cloneElement(image, {
7✔
264
        size: AvatarSize.L,
265
        className: clsx(classes.headerImage, image.props?.className)
266
      } as AvatarPropTypes);
267
    }
268
  }, [image, classes.headerImage, classes.image, imageShapeCircle]);
269

270
  const prevTopHeaderHeight = useRef(0);
3,262✔
271
  const scrollToSection = useCallback(
3,262✔
272
    (sectionId) => {
273
      if (!sectionId) {
68!
274
        return;
×
275
      }
276
      if (firstSectionId === sectionId) {
68✔
277
        objectPageRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
30✔
278
      } else {
279
        const childOffset = objectPageRef.current?.querySelector<HTMLElement>(`#ObjectPageSection-${sectionId}`)
38✔
280
          ?.offsetTop;
281
        if (!isNaN(childOffset)) {
38✔
282
          const safeTopHeaderHeight = topHeaderHeight || prevTopHeaderHeight.current;
38!
283
          if (topHeaderHeight) {
38✔
284
            prevTopHeaderHeight.current = topHeaderHeight;
38✔
285
          }
286
          objectPageRef.current?.scrollTo({
38✔
287
            top:
288
              childOffset -
289
              safeTopHeaderHeight -
290
              anchorBarHeight -
291
              TAB_CONTAINER_HEADER_HEIGHT /*tabBar*/ -
292
              (headerPinned ? (headerCollapsed === true ? 0 : headerContentHeight) : 0),
38!
293
            behavior: 'smooth'
294
          });
295
        }
296
      }
297
      isProgrammaticallyScrolled.current = false;
68✔
298
    },
299
    [
300
      firstSectionId,
301
      objectPageRef,
302
      topHeaderHeight,
303
      anchorBarHeight,
304
      headerPinned,
305
      headerContentHeight,
306
      headerCollapsed,
307
      prevTopHeaderHeight.current
308
    ]
309
  );
310

311
  const programmaticallySetSection = () => {
3,262✔
312
    const currentId = selectedSectionId ?? firstSectionId;
×
313
    if (currentId !== prevSelectedSectionId.current) {
×
314
      debouncedOnSectionChange.cancel();
×
315
      isProgrammaticallyScrolled.current = true;
×
316
      setInternalSelectedSectionId(currentId);
×
317
      prevSelectedSectionId.current = currentId;
×
318
      const sections = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
×
319
      const currentIndex = safeGetChildrenArray(children).findIndex((objectPageSection) => {
×
320
        return isValidElement(objectPageSection) && objectPageSection.props?.id === currentId;
×
321
      });
322
      fireOnSelectedChangedEvent({}, currentIndex, currentId, sections[0]);
×
323
    }
324
  };
325

326
  // change selected section when prop is changed (external change)
327
  const prevSelectedSectionId = useRef();
3,262✔
328
  const [timeStamp, setTimeStamp] = useState(0);
3,262✔
329
  const requestAnimationFrameRef = useRef<undefined | number>();
3,262✔
330
  useEffect(() => {
3,262✔
331
    if (selectedSectionId) {
274!
332
      if (mode === ObjectPageMode.Default) {
×
333
        // wait for DOM draw, otherwise initial scroll won't work as intended
334
        if (timeStamp < 750 && timeStamp !== undefined) {
×
335
          requestAnimationFrameRef.current = requestAnimationFrame((internalTimestamp) => {
×
336
            setTimeStamp(internalTimestamp);
×
337
          });
338
        } else {
339
          setTimeStamp(undefined);
×
340
          programmaticallySetSection();
×
341
        }
342
      } else {
343
        programmaticallySetSection();
×
344
      }
345
    }
346
    return () => {
274✔
347
      cancelAnimationFrame(requestAnimationFrameRef.current);
258✔
348
    };
349
  }, [timeStamp, selectedSectionId, firstSectionId, debouncedOnSectionChange]);
350

351
  // section was selected by clicking on the tab bar buttons
352
  const handleOnSectionSelected = useCallback(
3,262✔
353
    (targetEvent, newSelectionSectionId, index, section) => {
354
      isProgrammaticallyScrolled.current = true;
132✔
355
      debouncedOnSectionChange.cancel();
132✔
356
      setInternalSelectedSectionId((oldSelectedSection) => {
132✔
357
        if (oldSelectedSection === newSelectionSectionId) {
132!
358
          scrollToSection(newSelectionSectionId);
×
359
        }
360
        return newSelectionSectionId;
132✔
361
      });
362
      scrollEvent.current = targetEvent;
132✔
363
      fireOnSelectedChangedEvent(targetEvent, index, newSelectionSectionId, section);
132✔
364
    },
365
    [onSelectedSectionChange, setInternalSelectedSectionId, isProgrammaticallyScrolled, scrollToSection]
366
  );
367

368
  // do internal scrolling
369
  useEffect(() => {
3,262✔
370
    if (mode === ObjectPageMode.Default && isProgrammaticallyScrolled.current === true && !selectedSubSectionId) {
1,757✔
371
      scrollToSection(internalSelectedSectionId);
68✔
372
    }
373
  }, [internalSelectedSectionId, mode, isProgrammaticallyScrolled, scrollToSection, selectedSubSectionId]);
374

375
  // Scrolling for Sub Section Selection
376
  useEffect(() => {
3,262✔
377
    if (selectedSubSectionId && isProgrammaticallyScrolled.current === true) {
1,297✔
378
      const currentSubSection = objectPageRef.current?.querySelector<HTMLDivElement>(
58✔
379
        `div[id="ObjectPageSubSection-${selectedSubSectionId}"]`
380
      );
381
      const childOffset = currentSubSection?.offsetTop;
58✔
382
      if (!isNaN(childOffset)) {
58✔
383
        currentSubSection.focus({ preventScroll: true });
58✔
384
        objectPageRef.current?.scrollTo({
58✔
385
          top:
386
            childOffset -
387
            topHeaderHeight -
388
            anchorBarHeight -
389
            TAB_CONTAINER_HEADER_HEIGHT /*tabBar*/ -
390
            (headerPinned ? headerContentHeight : 0) -
58!
391
            16,
392
          behavior: 'smooth'
393
        });
394
      }
395
      isProgrammaticallyScrolled.current = false;
58✔
396
    }
397
  }, [
398
    selectedSubSectionId,
399
    isProgrammaticallyScrolled.current,
400
    topHeaderHeight,
401
    anchorBarHeight,
402
    headerPinned,
403
    headerContentHeight
404
  ]);
405

406
  useEffect(() => {
3,262✔
407
    if (alwaysShowContentHeader !== undefined) {
365✔
408
      setHeaderPinned(alwaysShowContentHeader);
104✔
409
    }
410
    if (alwaysShowContentHeader) {
365✔
411
      onToggleHeaderContentVisibility({ detail: { visible: true } });
52✔
412
    }
413
  }, [alwaysShowContentHeader]);
414

415
  const prevHeaderPinned = useRef(headerPinned);
3,262✔
416
  useEffect(() => {
3,262✔
417
    if (prevHeaderPinned.current && !headerPinned && objectPageRef.current.scrollTop > topHeaderHeight) {
659✔
418
      onToggleHeaderContentVisibility({ detail: { visible: false } });
53✔
419
      prevHeaderPinned.current = false;
53✔
420
    }
421
    if (!prevHeaderPinned.current && headerPinned) {
659✔
422
      prevHeaderPinned.current = true;
66✔
423
    }
424
  }, [headerPinned, topHeaderHeight]);
425

426
  useEffect(() => {
3,262✔
427
    setSelectedSubSectionId(props.selectedSubSectionId);
628✔
428
    if (props.selectedSubSectionId) {
628!
429
      isProgrammaticallyScrolled.current = true;
×
430
      if (mode === ObjectPageMode.IconTabBar) {
×
431
        let sectionId;
432
        safeGetChildrenArray<ReactElement<ObjectPageSectionPropTypes>>(children).forEach((section) => {
×
433
          if (isValidElement(section) && section.props && section.props.children) {
×
434
            safeGetChildrenArray(section.props.children).forEach((subSection) => {
×
435
              if (
×
436
                isValidElement(subSection) &&
×
437
                subSection.props &&
438
                subSection.props.id === props.selectedSubSectionId
439
              ) {
440
                sectionId = section.props?.id;
×
441
              }
442
            });
443
          }
444
        });
445
        if (sectionId) {
×
446
          setInternalSelectedSectionId(sectionId);
×
447
        }
448
      }
449
    }
450
  }, [props.selectedSubSectionId, setInternalSelectedSectionId, setSelectedSubSectionId, children, mode]);
451

452
  useEffect(() => {
3,262✔
453
    const objectPage = objectPageRef.current;
1,322✔
454

455
    const sections = objectPage.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSection"]');
1,322✔
456
    const section = sections[sections.length - 1];
1,322✔
457

458
    const observer = new ResizeObserver(([sectionElement]) => {
1,322✔
459
      let heightDiff = 0;
1,070✔
460
      if (objectPage.scrollHeight === objectPage.offsetHeight) {
1,070✔
461
        heightDiff = Math.max(
389✔
462
          objectPage.getBoundingClientRect().bottom - sectionElement.target.getBoundingClientRect().bottom,
463
          0
464
        );
465
      }
466
      const subSections = section.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSubSection"]');
1,070✔
467
      const lastSubSection = subSections[subSections.length - 1];
1,070✔
468
      if (lastSubSection) {
1,070✔
469
        heightDiff +=
180✔
470
          objectPage.getBoundingClientRect().height -
471
          topHeaderHeight -
472
          TAB_CONTAINER_HEADER_HEIGHT /*tabBar*/ -
473
          (!headerCollapsed ? headerContentHeight : 0) -
180✔
474
          lastSubSection.getBoundingClientRect().height -
475
          32;
476
      }
477
      // heightDiff - footer - tabbar
478
      setSpacerBottomHeight(
1,070✔
479
        footer ? `calc(${heightDiff}px - 1rem - ${TAB_CONTAINER_HEADER_HEIGHT}px)` : `${heightDiff}px`
1,070✔
480
      );
481
    });
482

483
    if (objectPage && section) {
1,322✔
484
      observer.observe(section, { box: 'border-box' });
1,238✔
485
    }
486

487
    return () => {
1,322✔
488
      observer.disconnect();
1,306✔
489
    };
490
  }, [footer, headerCollapsed, topHeaderHeight, headerContentHeight]);
491

492
  const onToggleHeaderContentVisibility = useCallback((e) => {
3,262✔
493
    isToggledRef.current = true;
262✔
494
    scrollTimeout.current = performance.now() + 500;
262✔
495
    if (!e.detail.visible) {
262✔
496
      setHeaderCollapsedInternal(true);
149✔
497
      objectPageRef.current?.classList.add(classes.headerCollapsed);
149✔
498
    } else {
499
      setHeaderCollapsedInternal(false);
113✔
500
      setScrolledHeaderExpanded(true);
113✔
501
      objectPageRef.current?.classList.remove(classes.headerCollapsed);
113✔
502
    }
503
  }, []);
504

505
  const handleOnSubSectionSelected = useCallback(
3,262✔
506
    (e) => {
507
      isProgrammaticallyScrolled.current = true;
58✔
508
      if (mode === ObjectPageMode.IconTabBar) {
58✔
509
        const sectionId = e.detail.sectionId;
28✔
510
        setInternalSelectedSectionId(sectionId);
28✔
511

512
        const sections = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
28✔
513
        const currentIndex = safeGetChildrenArray(children).findIndex((objectPageSection) => {
28✔
514
          return isValidElement(objectPageSection) && objectPageSection.props?.id === sectionId;
88✔
515
        });
516
        debouncedOnSectionChange(e, currentIndex, sectionId, sections[currentIndex]);
28✔
517
      }
518
      const subSectionId = e.detail.subSectionId;
58✔
519
      setSelectedSubSectionId(subSectionId);
58✔
520
    },
521
    [mode, setInternalSelectedSectionId, setSelectedSubSectionId, isProgrammaticallyScrolled, children]
522
  );
523

524
  const objectPageClasses = clsx(
3,262✔
525
    classes.objectPage,
526
    GlobalStyleClasses.sapScrollBar,
527
    className,
528
    mode === ObjectPageMode.IconTabBar && classes.iconTabBarMode
4,160✔
529
  );
530

531
  const { onScroll: _0, selectedSubSectionId: _1, ...propsWithoutOmitted } = rest;
3,262✔
532

533
  useEffect(() => {
3,262✔
534
    const sections = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
1,266✔
535
    const objectPageHeight = objectPageRef.current?.clientHeight ?? 1000;
1,266!
536
    const marginBottom = objectPageHeight - totalHeaderHeight - /*TabContainer*/ TAB_CONTAINER_HEADER_HEIGHT;
1,266✔
537
    const rootMargin = `-${totalHeaderHeight}px 0px -${marginBottom < 0 ? 0 : marginBottom}px 0px`;
1,266✔
538
    const observer = new IntersectionObserver(
1,266✔
539
      ([section]) => {
540
        if (section.isIntersecting && isProgrammaticallyScrolled.current === false) {
1,100✔
541
          if (
886✔
542
            objectPageRef.current.getBoundingClientRect().top + totalHeaderHeight + TAB_CONTAINER_HEADER_HEIGHT <=
543
            section.target.getBoundingClientRect().bottom
544
          ) {
545
            const currentId = extractSectionIdFromHtmlId(section.target.id);
834✔
546
            setInternalSelectedSectionId(currentId);
834✔
547
            const currentIndex = safeGetChildrenArray(children).findIndex((objectPageSection) => {
834✔
548
              return isValidElement(objectPageSection) && objectPageSection.props?.id === currentId;
980✔
549
            });
550
            debouncedOnSectionChange(scrollEvent.current, currentIndex, currentId, section.target);
834✔
551
          }
552
        }
553
      },
554
      {
555
        root: objectPageRef.current,
556
        rootMargin,
557
        threshold: [0]
558
      }
559
    );
560

561
    sections.forEach((el) => {
1,266✔
562
      observer.observe(el);
1,579✔
563
    });
564

565
    return () => {
1,266✔
566
      observer.disconnect();
1,250✔
567
    };
568
  }, [children, totalHeaderHeight, setInternalSelectedSectionId, isProgrammaticallyScrolled]);
569

570
  // Fallback when scrolling faster than the IntersectionObserver can observe (in most cases faster than 60fps)
571
  useEffect(() => {
3,262✔
572
    const sections = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
468✔
573
    if (isAfterScroll) {
468✔
574
      let currentSection = sections[sections.length - 1];
97✔
575
      let currentIndex;
576
      for (let i = 0; i <= sections.length - 1; i++) {
97✔
577
        const section = sections[i];
97✔
578
        if (
97✔
579
          objectPageRef.current.getBoundingClientRect().top + totalHeaderHeight + TAB_CONTAINER_HEADER_HEIGHT <=
580
          section.getBoundingClientRect().bottom
581
        ) {
582
          currentSection = section;
97✔
583
          currentIndex = i;
97✔
584
          break;
97✔
585
        }
586
      }
587
      const currentSectionId = extractSectionIdFromHtmlId(currentSection?.id);
97✔
588
      if (currentSectionId !== internalSelectedSectionId) {
97!
589
        setInternalSelectedSectionId(currentSectionId);
×
590
        debouncedOnSectionChange(
×
591
          scrollEvent.current,
592
          currentIndex ?? sections.length - 1,
×
593
          currentSectionId,
594
          currentSection
595
        );
596
      }
597
      setIsAfterScroll(false);
97✔
598
    }
599
  }, [isAfterScroll]);
600

601
  const titleHeaderNotClickable =
602
    (alwaysShowContentHeader && !headerContentPinnable) ||
3,262✔
603
    !headerContent ||
604
    (!showHideHeaderButton && !headerContentPinnable);
605

606
  const onTitleClick = useCallback(
3,262✔
607
    (e) => {
608
      e.stopPropagation();
30✔
609
      if (!titleHeaderNotClickable) {
30✔
610
        onToggleHeaderContentVisibility(enrichEventWithDetails(e, { visible: headerCollapsed }));
30✔
611
      }
612
    },
613
    [onToggleHeaderContentVisibility, headerCollapsed, titleHeaderNotClickable]
614
  );
615

616
  const snappedHeaderInObjPage = headerTitle && headerTitle.props.snappedContent && headerCollapsed === true && !!image;
3,262✔
617

618
  const renderTitleSection = useCallback(
3,262✔
619
    (inHeader = false) => {
3,258✔
620
      const titleInHeaderClass = inHeader ? classes.titleInHeader : undefined;
3,258!
621

622
      if (headerTitle?.props && headerTitle.props?.showSubHeaderRight === undefined) {
3,258✔
623
        return cloneElement(headerTitle, {
3,258✔
624
          showSubHeaderRight: true,
625
          className: clsx(titleInHeaderClass, headerTitle?.props?.className),
626
          'data-not-clickable': titleHeaderNotClickable,
627
          onToggleHeaderContentVisibility: onTitleClick,
628
          'data-header-content-visible': headerContent && headerCollapsed !== true,
6,423✔
629
          'data-is-snapped-rendered-outside': snappedHeaderInObjPage
630
        });
631
      }
632
      return cloneElement(headerTitle, {
×
633
        className: clsx(titleInHeaderClass, headerTitle?.props?.className),
634
        'data-not-clickable': titleHeaderNotClickable,
635
        onToggleHeaderContentVisibility: onTitleClick,
636
        'data-header-content-visible': headerContent && headerCollapsed !== true,
×
637
        'data-is-snapped-rendered-outside': snappedHeaderInObjPage
638
      });
639
    },
640
    [headerTitle, titleHeaderNotClickable, onTitleClick, headerCollapsed, snappedHeaderInObjPage, !!headerContent]
641
  );
642

643
  const isInitial = useRef(true);
3,262✔
644
  useEffect(() => {
3,262✔
645
    if (!isInitial.current) {
276✔
646
      scrollTimeout.current = performance.now() + 200;
2✔
647
    } else {
648
      isInitial.current = false;
274✔
649
    }
650
  }, [snappedHeaderInObjPage]);
651

652
  const renderHeaderContentSection = useCallback(() => {
3,262✔
653
    if (headerContent?.props) {
3,262✔
654
      return cloneElement(headerContent, {
3,165✔
655
        ...headerContent.props,
656
        topHeaderHeight,
657
        style: headerCollapsed === true ? { position: 'absolute', visibility: 'hidden' } : headerContent.props.style,
3,165✔
658
        headerPinned: headerPinned || scrolledHeaderExpanded,
6,081✔
659
        ref: componentRefHeaderContent,
660
        children: (
661
          <div className={classes.headerContainer} data-component-name="ObjectPageHeaderContainer">
662
            {avatar}
6,330!
663
            {(headerContent.props.children || titleInHeader) && (
664
              <div data-component-name="ObjectPageHeaderContent">
665
                {titleInHeader && renderTitleSection(true)}
3,165!
666
                {headerContent.props.children}
667
              </div>
668
            )}
669
          </div>
670
        )
671
      });
672
    } else if (titleInHeader) {
97!
673
      return (
×
674
        <DynamicPageHeader
675
          topHeaderHeight={topHeaderHeight}
676
          style={headerCollapsed === true ? { position: 'absolute', visibility: 'hidden' } : undefined}
×
677
          headerPinned={headerPinned || scrolledHeaderExpanded}
×
678
          ref={componentRefHeaderContent}
679
        >
680
          <div className={classes.headerContainer} data-component-name="ObjectPageHeaderContainer">
681
            {avatar}
682
            <div data-component-name="ObjectPageHeaderContent">{titleInHeader && renderTitleSection(true)}</div>
×
683
          </div>
684
        </DynamicPageHeader>
685
      );
686
    }
687
  }, [
688
    headerContent,
689
    topHeaderHeight,
690
    headerPinned,
691
    scrolledHeaderExpanded,
692
    titleInHeader,
693
    avatar,
694
    headerContentRef,
695
    renderTitleSection
696
  ]);
697

698
  const onTabItemSelect = (event) => {
3,262✔
699
    event.preventDefault();
190✔
700
    const { sectionId, index, isSubTab, parentId } = event.detail.tab.dataset;
190✔
701
    if (isSubTab) {
190✔
702
      handleOnSubSectionSelected(enrichEventWithDetails(event, { sectionId: parentId, subSectionId: sectionId }));
58✔
703
    } else {
704
      const section = safeGetChildrenArray<ReactElement>(children).find((el) => {
132✔
705
        return el.props.id == sectionId;
306✔
706
      });
707
      handleOnSectionSelected(event, section?.props?.id, index, section);
132✔
708
    }
709
  };
710

711
  const prevScrollTop = useRef();
3,262✔
712
  const onObjectPageScroll = useCallback(
3,262✔
713
    (e) => {
714
      if (!isToggledRef.current) {
3,294✔
715
        isToggledRef.current = true;
147✔
716
      }
717
      if (scrollTimeout.current >= performance.now()) {
3,294✔
718
        return;
122✔
719
      }
720
      scrollEvent.current = e;
3,172✔
721
      if (typeof props.onScroll === 'function') {
3,172!
722
        props.onScroll(e);
×
723
      }
724
      if (selectedSubSectionId) {
3,172✔
725
        setSelectedSubSectionId(undefined);
50✔
726
      }
727
      if (selectionScrollTimeout.current) {
3,172✔
728
        clearTimeout(selectionScrollTimeout.current);
3,025✔
729
      }
730
      selectionScrollTimeout.current = setTimeout(() => {
3,172✔
731
        setIsAfterScroll(true);
97✔
732
      }, 100);
733
      if (!headerPinned || e.target.scrollTop === 0) {
3,172✔
734
        objectPageRef.current?.classList.remove(classes.headerCollapsed);
3,158✔
735
      }
736
      if (scrolledHeaderExpanded && e.target.scrollTop !== prevScrollTop.current) {
3,172✔
737
        if (e.target.scrollHeight - e.target.scrollTop === e.target.clientHeight) {
13!
738
          return;
×
739
        }
740
        prevScrollTop.current = e.target.scrollTop;
13✔
741
        if (!headerPinned) {
13✔
742
          setHeaderCollapsedInternal(true);
13✔
743
        }
744
        setScrolledHeaderExpanded(false);
13✔
745
      }
746
    },
747
    [topHeaderHeight, headerPinned, props.onScroll, scrolledHeaderExpanded, selectedSubSectionId]
748
  );
749

750
  const onHoverToggleButton = useCallback(
3,262✔
751
    (e) => {
752
      if (e?.type === 'mouseover') {
188✔
753
        topHeaderRef.current?.classList.add(classes.headerHoverStyles);
124✔
754
      } else {
755
        topHeaderRef.current?.classList.remove(classes.headerHoverStyles);
64✔
756
      }
757
    },
758
    [classes.headerHoverStyles]
759
  );
760

761
  const objectPageStyles = { ...style };
3,262✔
762
  if (headerCollapsed === true && (headerContent || titleInHeader)) {
3,262!
763
    objectPageStyles[DynamicPageCssVariables.titleFontSize] = ThemingParameters.sapObjectHeader_Title_SnappedFontSize;
1,266✔
764
  }
765

766
  return (
3,262✔
767
    <div
768
      data-component-name="ObjectPage"
769
      slot={slot}
770
      className={objectPageClasses}
771
      style={objectPageStyles}
772
      ref={componentRef}
773
      onScroll={onObjectPageScroll}
774
      {...propsWithoutOmitted}
775
    >
776
      <header
777
        onMouseOver={onHoverToggleButton}
778
        onMouseLeave={onHoverToggleButton}
779
        data-component-name="ObjectPageTopHeader"
780
        ref={topHeaderRef}
781
        role={a11yConfig?.objectPageTopHeader?.role}
782
        data-not-clickable={titleHeaderNotClickable}
783
        aria-roledescription={a11yConfig?.objectPageTopHeader?.ariaRoledescription ?? 'Object Page header'}
6,524✔
784
        className={classes.header}
785
        onClick={onTitleClick}
786
        style={{
787
          gridAutoColumns: `min-content ${
788
            headerTitle && image && headerCollapsed === true ? `calc(100% - 3rem - 1rem)` : '100%'
9,808✔
789
          }`,
790
          display: !showTitleInHeaderContent || headerCollapsed === true ? 'grid' : 'none'
6,524!
791
        }}
792
      >
793
        {headerTitle && image && headerCollapsed === true && (
6,550✔
794
          <CollapsedAvatar image={image} imageShapeCircle={imageShapeCircle} />
795
        )}
796
        {headerTitle && renderTitleSection()}
6,520✔
797
        {snappedHeaderInObjPage && (
3,266✔
798
          <div className={classes.snappedContent} data-component-name="ATwithImageSnappedContentContainer">
799
            {headerTitle.props.snappedContent}
800
          </div>
801
        )}
802
      </header>
803
      {renderHeaderContentSection()}
804
      {headerContent && headerTitle && (
9,592✔
805
        <div
806
          data-component-name="ObjectPageAnchorBar"
807
          ref={anchorBarRef}
808
          className={classes.anchorBar}
809
          style={{
810
            top:
811
              scrolledHeaderExpanded || headerPinned
8,800✔
812
                ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight)}px`
749✔
813
                : `${topHeaderHeight + 5}px`
814
          }}
815
        >
816
          <DynamicPageAnchorBar
817
            headerContentVisible={headerContent && headerCollapsed !== true}
6,330✔
818
            headerContentPinnable={headerContentPinnable}
819
            showHideHeaderButton={showHideHeaderButton}
820
            headerPinned={headerPinned}
821
            a11yConfig={a11yConfig}
822
            onToggleHeaderContentVisibility={onToggleHeaderContentVisibility}
823
            setHeaderPinned={setHeaderPinned}
824
            onHoverToggleButton={onHoverToggleButton}
825
            onPinnedStateChange={onPinnedStateChange}
826
          />
827
        </div>
828
      )}
829
      {!placeholder && (
6,518✔
830
        <div
831
          className={classes.tabContainer}
832
          data-component-name="ObjectPageTabContainer"
833
          style={{
834
            top:
835
              headerPinned || scrolledHeaderExpanded
9,519✔
836
                ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight)}px`
749✔
837
                : `${topHeaderHeight}px`
838
          }}
839
        >
840
          <TabContainer
841
            collapsed
842
            fixed
843
            onTabSelect={onTabItemSelect}
844
            data-component-name="ObjectPageTabContainer"
845
            className={classes.tabContainerComponent}
846
          >
847
            {safeGetChildrenArray(children).map((section, index) => {
848
              if (!isValidElement(section) || !section.props) return null;
5,764!
849
              const subTabs = safeGetChildrenArray(section.props.children).filter(
5,764✔
850
                (subSection) =>
851
                  // @ts-expect-error: if the `ObjectPageSubSection` component is passed as children, the `displayName` is available. Otherwise, the default children should be rendered w/o additional logic.
852
                  isValidElement(subSection) && subSection?.type?.displayName === 'ObjectPageSubSection'
8,661✔
853
              );
854
              return (
5,764✔
855
                <Tab
856
                  key={`Anchor-${section.props?.id}`}
857
                  data-index={index}
858
                  data-section-id={section.props.id}
859
                  text={section.props.titleText}
860
                  selected={internalSelectedSectionId === section.props?.id || undefined}
8,380✔
861
                  subTabs={subTabs.map((item) => {
862
                    if (!isValidElement(item)) {
3,436!
863
                      return null;
×
864
                    }
865
                    return (
3,436✔
866
                      <Tab
867
                        data-parent-id={section.props.id}
868
                        key={item.props.id}
869
                        data-is-sub-tab
870
                        data-section-id={item.props.id}
871
                        text={item.props.titleText}
872
                        selected={item.props.id === selectedSubSectionId || undefined}
6,798✔
873
                      >
874
                        {/*ToDo: workaround for nested tab selection*/}
875
                        <span style={{ display: 'none' }} />
876
                      </Tab>
877
                    );
878
                  })}
879
                >
880
                  {/*ToDo: workaround for nested tab selection*/}
881
                  <span style={{ display: 'none' }} />
882
                </Tab>
883
              );
884
            })}
885
          </TabContainer>
886
        </div>
887
      )}
888
      <div data-component-name="ObjectPageContent" className={classes.content}>
889
        <div style={{ height: headerCollapsed ? `${headerContentHeight}px` : 0 }} aria-hidden="true" />
3,262✔
890
        {placeholder
3,262✔
891
          ? placeholder
892
          : mode === ObjectPageMode.IconTabBar
3,256✔
893
          ? getSectionById(children, internalSelectedSectionId)
894
          : children}
895
        <div style={{ height: spacerBottomHeight }} aria-hidden="true" />
896
      </div>
897
      {footer && <div style={{ height: '1rem' }} data-component-name="ObjectPageFooterSpacer" />}
3,924✔
898
      {footer && (
3,924✔
899
        <footer className={classes.footer} data-component-name="ObjectPageFooter">
900
          {footer}
901
        </footer>
902
      )}
903
    </div>
904
  );
905
});
906

907
ObjectPage.displayName = 'ObjectPage';
406✔
908

909
ObjectPage.defaultProps = {
406✔
910
  image: null,
911
  mode: ObjectPageMode.Default,
912
  imageShapeCircle: false,
913
  showHideHeaderButton: false
914
};
915

916
export { ObjectPage };
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