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

SAP / ui5-webcomponents-react / 4022332151

pending completion
4022332151

Pull #4046

github

GitHub
Merge 7817a40f7 into d0725c1f6
Pull Request #4046: fix: restrict base package dependency to be on same minor version as main package

3423 of 6251 branches covered (54.76%)

4472 of 5327 relevant lines covered (83.95%)

5841.98 hits per line

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

70.14
/packages/main/src/components/ObjectPage/index.tsx
1
'use client';
342✔
2

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

38
addCustomCSSWithScoping(
38✔
39
  'ui5-tabcontainer',
40
  // padding-inline is used here to ensure the same responsive padding behavior as for the rest of the component
41
  `
42
  :host([data-component-name="ObjectPageTabContainer"]) .ui5-tc__header {
43
    padding: 0;
44
    padding-inline: var(--_ui5wcr_ObjectPage_tab_bar_inline_padding);
45
    box-shadow: inset 0 -0.0625rem ${ThemingParameters.sapPageHeader_BorderColor}, 0 0.125rem 0.25rem 0 rgb(0 0 0 / 8%);
46
  }
47
  `
48
);
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' });
38✔
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) => {
2,387✔
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;
2,387✔
181

182
  const classes = useStyles();
2,387✔
183

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

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

191
  const [componentRef, objectPageRef] = useSyncRef(ref);
2,387✔
192
  const topHeaderRef = useRef<HTMLDivElement>(null);
2,387✔
193
  const scrollEvent = useRef();
2,387✔
194
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
195
  //@ts-ignore
196
  const [componentRefHeaderContent, headerContentRef] = useSyncRef(headerContent?.ref);
2,387✔
197
  const anchorBarRef = useRef<HTMLDivElement>(null);
2,387✔
198
  const selectionScrollTimeout = useRef(null);
2,387✔
199
  const [isAfterScroll, setIsAfterScroll] = useState(false);
2,387✔
200
  const isToggledRef = useRef(false);
2,387✔
201
  const isRTL = useIsRTL(objectPageRef);
2,387✔
202
  const [responsivePaddingClass, responsiveRange] = useResponsiveContentPadding(objectPageRef.current, true);
2,387✔
203
  const [headerCollapsedInternal, setHeaderCollapsedInternal] = useState<undefined | boolean>(undefined);
2,387✔
204
  const [scrolledHeaderExpanded, setScrolledHeaderExpanded] = useState(false);
2,387✔
205
  const scrollTimeout = useRef(0);
2,387✔
206
  const [spacerBottomHeight, setSpacerBottomHeight] = useState('0px');
2,387✔
207

208
  const prevInternalSelectedSectionId = useRef(internalSelectedSectionId);
2,387✔
209
  const fireOnSelectedChangedEvent = (targetEvent, index, id, section) => {
2,387✔
210
    if (typeof onSelectedSectionChange === 'function' && prevInternalSelectedSectionId.current !== id) {
107!
211
      onSelectedSectionChange(
1✔
212
        enrichEventWithDetails(targetEvent, {
213
          selectedSectionIndex: parseInt(index, 10),
214
          selectedSectionId: id,
215
          section
216
        })
217
      );
218
      prevInternalSelectedSectionId.current = id;
1✔
219
    }
220
  };
221
  const debouncedOnSectionChange = useRef(debounce(fireOnSelectedChangedEvent, 500)).current;
2,387✔
222
  useEffect(() => {
2,387✔
223
    return () => {
157✔
224
      debouncedOnSectionChange.cancel();
143✔
225
      clearTimeout(selectionScrollTimeout.current);
143✔
226
    };
227
  }, []);
228

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

244
  useEffect(() => {
2,387✔
245
    if (typeof onToggleHeaderContent === 'function' && isToggledRef.current) {
463!
246
      onToggleHeaderContent(headerCollapsed !== true);
48✔
247
    }
248
  }, [headerCollapsed]);
249

250
  const avatar = useMemo(() => {
2,387✔
251
    if (!image) {
157✔
252
      return null;
157✔
253
    }
254
    const headerImageClasses = clsx(classes.headerImage, isRTL && classes.headerImageRtl);
3!
255

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

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

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

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

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

369
  // do internal scrolling
370
  useEffect(() => {
2,387✔
371
    if (mode === ObjectPageMode.Default && isProgrammaticallyScrolled.current === true && !selectedSubSectionId) {
1,195!
372
      scrollToSection(internalSelectedSectionId);
47✔
373
    }
374
  }, [internalSelectedSectionId, mode, isProgrammaticallyScrolled, scrollToSection, selectedSubSectionId]);
375

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

407
  useEffect(() => {
2,387✔
408
    if (alwaysShowContentHeader !== undefined) {
227!
409
      setHeaderPinned(alwaysShowContentHeader);
80✔
410
    }
411
    if (alwaysShowContentHeader) {
227!
412
      onToggleHeaderContentVisibility({ detail: { visible: true } });
40✔
413
    }
414
  }, [alwaysShowContentHeader]);
415

416
  const prevHeaderPinned = useRef(headerPinned);
2,387✔
417
  useEffect(() => {
2,387✔
418
    if (prevHeaderPinned.current && !headerPinned && objectPageRef.current.scrollTop > topHeaderHeight) {
408!
419
      onToggleHeaderContentVisibility({ detail: { visible: false } });
41✔
420
      prevHeaderPinned.current = false;
41✔
421
    }
422
    if (!prevHeaderPinned.current && headerPinned) {
408!
423
      prevHeaderPinned.current = true;
51✔
424
    }
425
  }, [headerPinned, topHeaderHeight]);
426

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

453
  useEffect(() => {
2,387✔
454
    const objectPage = objectPageRef.current;
888✔
455

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

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

482
    if (objectPage && section) {
888✔
483
      observer.observe(section, { box: 'border-box' });
874✔
484
    }
485

486
    return () => {
888✔
487
      observer.disconnect();
874✔
488
    };
489
  }, [footer, headerCollapsed, topHeaderHeight, headerContentHeight]);
490

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

504
  const handleOnSubSectionSelected = useCallback(
2,387✔
505
    (e) => {
506
      isProgrammaticallyScrolled.current = true;
40✔
507
      if (mode === ObjectPageMode.IconTabBar) {
40!
508
        const sectionId = e.detail.sectionId;
19✔
509
        setInternalSelectedSectionId(sectionId);
19✔
510

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

523
  const objectPageClasses = clsx(
2,387✔
524
    classes.objectPage,
525
    GlobalStyleClasses.sapScrollBar,
526
    classes[responsiveRange],
527
    className,
528
    mode === ObjectPageMode.IconTabBar && classes.iconTabBarMode
3,057✔
529
  );
530

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

533
  useEffect(() => {
2,387✔
534
    const sections = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
949✔
535
    const objectPageHeight = objectPageRef.current?.clientHeight ?? 1000;
949!
536
    const marginBottom = objectPageHeight - totalHeaderHeight - /*TabContainer*/ 48;
949✔
537
    const rootMargin = `-${totalHeaderHeight}px 0px -${marginBottom < 0 ? 0 : marginBottom}px 0px`;
949!
538
    const observer = new IntersectionObserver(
949✔
539
      ([section]) => {
540
        if (section.isIntersecting && isProgrammaticallyScrolled.current === false) {
818!
541
          if (
666!
542
            objectPageRef.current.getBoundingClientRect().top + totalHeaderHeight + 48 <=
543
            section.target.getBoundingClientRect().bottom
544
          ) {
545
            const currentId = extractSectionIdFromHtmlId(section.target.id);
629✔
546
            setInternalSelectedSectionId(currentId);
629✔
547
            const currentIndex = safeGetChildrenArray(children).findIndex((objectPageSection) => {
629✔
548
              return isValidElement(objectPageSection) && objectPageSection.props?.id === currentId;
745!
549
            });
550
            debouncedOnSectionChange(scrollEvent.current, currentIndex, currentId, section.target);
629✔
551
          }
552
        }
553
      },
554
      {
555
        root: objectPageRef.current,
556
        rootMargin,
557
        threshold: [0]
558
      }
559
    );
560
    // Fallback when scrolling faster than the IntersectionObserver can observe (in most cases faster than 60fps)
561
    if (isAfterScroll) {
949!
562
      let currentSection = sections[sections.length - 1];
65✔
563
      let currentIndex;
564
      for (let i = 0; i <= sections.length - 1; i++) {
65✔
565
        const section = sections[i];
65✔
566
        if (
65!
567
          objectPageRef.current.getBoundingClientRect().top + totalHeaderHeight + 48 <=
568
          section.getBoundingClientRect().bottom
569
        ) {
570
          currentSection = section;
65✔
571
          currentIndex = i;
65✔
572
          break;
65✔
573
        }
574
      }
575
      const currentSectionId = extractSectionIdFromHtmlId(currentSection?.id);
65✔
576
      if (currentSectionId !== internalSelectedSectionId) {
65!
577
        setInternalSelectedSectionId(currentSectionId);
×
578
        debouncedOnSectionChange(
×
579
          scrollEvent.current,
580
          currentIndex ?? sections.length - 1,
×
581
          currentSectionId,
582
          currentSection
583
        );
584
      }
585
      setIsAfterScroll(false);
65✔
586
    }
587

588
    sections.forEach((el) => {
949✔
589
      observer.observe(el);
1,194✔
590
    });
591

592
    return () => {
949✔
593
      observer.disconnect();
935✔
594
    };
595
  }, [
596
    objectPageRef.current,
597
    children,
598
    totalHeaderHeight,
599
    setInternalSelectedSectionId,
600
    isProgrammaticallyScrolled,
601
    isAfterScroll
602
  ]);
603

604
  const titleHeaderNotClickable =
605
    (alwaysShowContentHeader && !headerContentPinnable) ||
2,387!
606
    !headerContent ||
607
    (!showHideHeaderButton && !headerContentPinnable);
608

609
  const onTitleClick = useCallback(
2,387✔
610
    (e) => {
611
      e.stopPropagation();
24✔
612
      if (!titleHeaderNotClickable) {
24!
613
        onToggleHeaderContentVisibility(enrichEventWithDetails(e, { visible: headerCollapsed }));
24✔
614
      }
615
    },
616
    [onToggleHeaderContentVisibility, headerCollapsed, titleHeaderNotClickable]
617
  );
618

619
  const renderTitleSection = useCallback(
2,387✔
620
    (inHeader = false) => {
2,397✔
621
      const titleStyles = { ...(inHeader ? { padding: 0 } : {}), ...(headerTitle?.props?.style ?? {}) };
2,387!
622

623
      if (headerTitle?.props && headerTitle.props?.showSubHeaderRight === undefined) {
2,387!
624
        return cloneElement(headerTitle, {
2,387✔
625
          showSubHeaderRight: true,
626
          style: titleStyles,
627
          'data-not-clickable': titleHeaderNotClickable,
628
          onToggleHeaderContentVisibility: onTitleClick
629
        });
630
      }
631
      return cloneElement(headerTitle, {
×
632
        style: titleStyles,
633
        'data-not-clickable': titleHeaderNotClickable,
634
        onToggleHeaderContentVisibility: onTitleClick
635
      });
636
    },
637
    [headerTitle, titleHeaderNotClickable, onTitleClick]
638
  );
639

640
  const renderHeaderContentSection = useCallback(() => {
2,387✔
641
    if (headerContent?.props) {
2,387✔
642
      return cloneElement(headerContent, {
2,349✔
643
        ...headerContent.props,
644
        topHeaderHeight,
645
        style: headerCollapsed === true ? { position: 'absolute', visibility: 'hidden' } : headerContent.props.style,
2,359!
646
        headerPinned: headerPinned || scrolledHeaderExpanded,
4,496✔
647
        ref: componentRefHeaderContent,
648
        children: (
649
          <div
650
            className={`${classes.headerContainer} ${responsivePaddingClass}`}
651
            data-component-name="ObjectPageHeaderContainer"
652
          >
653
            {avatar}
654
            {headerContent.props.children && (
4,718✔
655
              <div data-component-name="ObjectPageHeaderContent">
656
                {headerTitle && showTitleInHeaderContent && renderTitleSection(true)}
4,717!
657
                {headerContent.props.children}
658
              </div>
659
            )}
660
          </div>
661
        )
662
      });
663
    }
664
  }, [
665
    headerContent,
666
    topHeaderHeight,
667
    headerPinned,
668
    scrolledHeaderExpanded,
669
    showTitleInHeaderContent,
670
    avatar,
671
    headerContentRef,
672
    renderTitleSection,
673
    responsivePaddingClass
674
  ]);
675

676
  const paddingLeftRtl = isRTL ? 'paddingLeft' : 'paddingRight';
2,387!
677

678
  const onTabItemSelect = (event) => {
2,387✔
679
    event.preventDefault();
130✔
680
    const { sectionId, index, isSubTab, parentId } = event.detail.tab.dataset;
130✔
681
    if (isSubTab) {
130!
682
      handleOnSubSectionSelected(enrichEventWithDetails(event, { sectionId: parentId, subSectionId: sectionId }));
40✔
683
    } else {
684
      const section = safeGetChildrenArray<ReactElement>(children).find((el) => {
90✔
685
        return el.props.id == sectionId;
210✔
686
      });
687
      handleOnSectionSelected(event, section?.props?.id, index, section);
90✔
688
    }
689
  };
690

691
  const prevScrollTop = useRef();
2,387✔
692
  const onObjectPageScroll = useCallback(
2,387✔
693
    (e) => {
694
      if (!isToggledRef.current) {
2,385!
695
        isToggledRef.current = true;
101✔
696
      }
697
      if (scrollTimeout.current >= performance.now()) {
2,385!
698
        return;
92✔
699
      }
700
      scrollEvent.current = e;
2,293✔
701
      if (typeof props.onScroll === 'function') {
2,293!
702
        props.onScroll(e);
×
703
      }
704
      if (selectedSubSectionId) {
2,293!
705
        setSelectedSubSectionId(undefined);
35✔
706
      }
707
      if (selectionScrollTimeout.current) {
2,293!
708
        clearTimeout(selectionScrollTimeout.current);
2,192✔
709
      }
710
      selectionScrollTimeout.current = setTimeout(() => {
2,293✔
711
        setIsAfterScroll(true);
65✔
712
      }, 100);
713
      if (!headerPinned || e.target.scrollTop === 0) {
2,293!
714
        objectPageRef.current?.classList.remove(classes.headerCollapsed);
2,282✔
715
      }
716
      if (scrolledHeaderExpanded && e.target.scrollTop !== prevScrollTop.current) {
2,293!
717
        if (e.target.scrollHeight - e.target.scrollTop === e.target.clientHeight) {
10!
718
          return;
×
719
        }
720
        prevScrollTop.current = e.target.scrollTop;
10✔
721
        if (!headerPinned) {
10!
722
          setHeaderCollapsedInternal(true);
10✔
723
        }
724
        setScrolledHeaderExpanded(false);
10✔
725
      }
726
    },
727
    [topHeaderHeight, headerPinned, props.onScroll, scrolledHeaderExpanded, selectedSubSectionId]
728
  );
729

730
  const onHoverToggleButton = useCallback(
2,387✔
731
    (e) => {
732
      if (e?.type === 'mouseover') {
137!
733
        topHeaderRef.current?.classList.add(classes.headerHoverStyles);
91✔
734
      } else {
735
        topHeaderRef.current?.classList.remove(classes.headerHoverStyles);
46✔
736
      }
737
    },
738
    [classes.headerHoverStyles]
739
  );
740

741
  const objectPageStyles = { ...style };
2,387✔
742
  if (headerCollapsed === true && headerContent) {
2,387!
743
    objectPageStyles[DynamicPageCssVariables.titleFontSize] = ThemingParameters.sapObjectHeader_Title_SnappedFontSize;
914✔
744
  }
745

746
  return (
2,387✔
747
    <div
748
      data-component-name="ObjectPage"
749
      slot={slot}
750
      className={objectPageClasses}
751
      style={objectPageStyles}
752
      ref={componentRef}
753
      onScroll={onObjectPageScroll}
754
      {...propsWithoutOmitted}
755
    >
756
      <header
757
        onMouseOver={onHoverToggleButton}
758
        onMouseLeave={onHoverToggleButton}
759
        data-component-name="ObjectPageTopHeader"
760
        ref={topHeaderRef}
761
        role={a11yConfig?.objectPageTopHeader?.role ?? 'banner'}
4,816✔
762
        data-not-clickable={titleHeaderNotClickable}
763
        aria-roledescription={a11yConfig?.objectPageTopHeader?.ariaRoledescription ?? 'Object Page header'}
4,816✔
764
        className={`${classes.header} ${responsivePaddingClass}`}
765
        onClick={onTitleClick}
766
        style={{
767
          gridAutoColumns: `min-content ${
768
            headerTitle && image && headerCollapsed === true ? `calc(100% - 3rem - 1rem)` : '100%'
7,216!
769
          }`,
770
          display: !showTitleInHeaderContent || headerCollapsed === true ? 'grid' : 'none'
4,816!
771
        }}
772
      >
773
        {headerTitle && image && headerCollapsed === true && (
4,808!
774
          <CollapsedAvatar image={image} imageShapeCircle={imageShapeCircle} style={{ [paddingLeftRtl]: '1rem' }} />
775
        )}
776
        {headerTitle && renderTitleSection()}
4,805✔
777
      </header>
778
      {renderHeaderContentSection()}
779
      {headerContent && headerTitle && (
7,125✔
780
        <div
781
          data-component-name="ObjectPageAnchorBar"
782
          ref={anchorBarRef}
783
          className={classes.anchorBar}
784
          style={{
785
            top:
786
              scrolledHeaderExpanded || headerPinned
6,514!
787
                ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight)}px`
602!
788
                : `${topHeaderHeight + 5}px`
789
          }}
790
        >
791
          <DynamicPageAnchorBar
792
            headerContentVisible={headerContent && headerCollapsed !== true}
4,716✔
793
            headerContentPinnable={headerContentPinnable}
794
            showHideHeaderButton={showHideHeaderButton}
795
            headerPinned={headerPinned}
796
            a11yConfig={a11yConfig}
797
            onToggleHeaderContentVisibility={onToggleHeaderContentVisibility}
798
            setHeaderPinned={setHeaderPinned}
799
            onHoverToggleButton={onHoverToggleButton}
800
            onPinnedStateChange={onPinnedStateChange}
801
          />
802
        </div>
803
      )}
804
      {!placeholder && (
4,815✔
805
        <div
806
          className={classes.tabContainer}
807
          data-component-name="ObjectPageTabContainer"
808
          style={{
809
            top:
810
              headerPinned || scrolledHeaderExpanded
6,999!
811
                ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight)}px`
602!
812
                : `${topHeaderHeight}px`
813
          }}
814
        >
815
          <TabContainer
816
            collapsed
817
            fixed
818
            onTabSelect={onTabItemSelect}
819
            data-component-name="ObjectPageTabContainer"
820
            className={classes.tabContainerComponent}
821
          >
822
            {safeGetChildrenArray(children).map((section, index) => {
823
              if (!isValidElement(section) || !section.props) return null;
4,227!
824
              const subTabs = safeGetChildrenArray(section.props.children).filter(
4,227✔
825
                (subSection) =>
826
                  // @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.
827
                  isValidElement(subSection) && subSection?.type?.displayName === 'ObjectPageSubSection'
6,370✔
828
              );
829
              return (
4,227✔
830
                <Tab
831
                  key={`Anchor-${section.props?.id}`}
832
                  data-index={index}
833
                  data-section-id={section.props.id}
834
                  text={section.props.titleText}
835
                  selected={internalSelectedSectionId === section.props?.id || undefined}
6,218✔
836
                  subTabs={subTabs.map((item) => {
837
                    if (!isValidElement(item)) {
2,523!
838
                      return null;
×
839
                    }
840
                    return (
2,523✔
841
                      <Tab
842
                        data-parent-id={section.props.id}
843
                        key={item.props.id}
844
                        data-is-sub-tab
845
                        data-section-id={item.props.id}
846
                        text={item.props.titleText}
847
                        selected={item.props.id === selectedSubSectionId || undefined}
5,166✔
848
                      >
849
                        {/*ToDo: workaround for nested tab selection*/}
850
                        <span style={{ display: 'none' }} />
851
                      </Tab>
852
                    );
853
                  })}
854
                >
855
                  {/*ToDo: workaround for nested tab selection*/}
856
                  <span style={{ display: 'none' }} />
857
                </Tab>
858
              );
859
            })}
860
          </TabContainer>
861
        </div>
862
      )}
863
      <div data-component-name="ObjectPageContent" className={responsivePaddingClass}>
864
        <div style={{ height: headerCollapsed ? `${headerContentHeight}px` : 0 }} aria-hidden="true" />
2,408!
865
        {placeholder
2,408!
866
          ? placeholder
867
          : mode === ObjectPageMode.IconTabBar
2,407✔
868
          ? getSectionById(children, internalSelectedSectionId)
869
          : children}
870
        <div style={{ height: spacerBottomHeight }} aria-hidden="true" />
871
      </div>
872
      {footer && <div style={{ height: '1rem' }} data-component-name="ObjectPageFooterSpacer" />}
2,885✔
873
      {footer && (
2,885✔
874
        <footer className={classes.footer} data-component-name="ObjectPageFooter">
875
          {footer}
876
        </footer>
877
      )}
878
    </div>
879
  );
880
});
38✔
881

882
ObjectPage.displayName = 'ObjectPage';
38✔
883

884
ObjectPage.defaultProps = {
38✔
885
  image: null,
886
  mode: ObjectPageMode.Default,
887
  imageShapeCircle: false,
888
  showHideHeaderButton: false
889
};
890

891
export { ObjectPage };
76✔
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