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

SAP / ui5-webcomponents-react / 4345260353

pending completion
4345260353

Pull #4254

github

GitHub
Merge c980cb79a into effa70e83
Pull Request #4254: feat: update @ui5/webcomponents to ~1.11.0

2724 of 5091 branches covered (53.51%)

4388 of 5252 relevant lines covered (83.55%)

15150.52 hits per line

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

67.5
/packages/main/src/components/ObjectPage/index.tsx
1
'use client';
2,889✔
2

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

31
addCustomCSSWithScoping(
323✔
32
  'ui5-tabcontainer',
33
  // padding-inline is used here to ensure the same responsive padding behavior as for the rest of the component
34
  `
35
  :host([data-component-name="ObjectPageTabContainer"]) .ui5-tc__header {
36
    padding: 0;
37
    padding-inline: var(--_ui5wcr_ObjectPage_tab_bar_inline_padding);
38
    box-shadow: inset 0 -0.0625rem ${ThemingParameters.sapPageHeader_BorderColor}, 0 0.125rem 0.25rem 0 rgb(0 0 0 / 8%);
39
  }
40
  `
41
);
42

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

143
const useStyles = createUseStyles(styles, { name: 'ObjectPage' });
323✔
144

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

175
  const classes = useStyles();
2,331✔
176

177
  const firstSectionId = safeGetChildrenArray<ReactElement>(children)[0]?.props?.id;
2,331✔
178

179
  const [internalSelectedSectionId, setInternalSelectedSectionId] = useState(selectedSectionId ?? firstSectionId);
2,331✔
180
  const [selectedSubSectionId, setSelectedSubSectionId] = useState(props.selectedSubSectionId);
2,331✔
181
  const [headerPinned, setHeaderPinned] = useState(alwaysShowContentHeader);
2,331✔
182
  const isProgrammaticallyScrolled = useRef(false);
2,331✔
183

184
  const [componentRef, objectPageRef] = useSyncRef(ref);
2,331✔
185
  const topHeaderRef = useRef<HTMLDivElement>(null);
2,331✔
186
  const scrollEvent = useRef();
2,331✔
187
  // @ts-expect-error: useSyncRef will create a ref if not present
188
  const [componentRefHeaderContent, headerContentRef] = useSyncRef(headerContent?.ref);
2,331✔
189
  const anchorBarRef = useRef<HTMLDivElement>(null);
2,331✔
190
  const selectionScrollTimeout = useRef(null);
2,331✔
191
  const [isAfterScroll, setIsAfterScroll] = useState(false);
2,331✔
192
  const isToggledRef = useRef(false);
2,331✔
193
  const [headerCollapsedInternal, setHeaderCollapsedInternal] = useState<undefined | boolean>(undefined);
2,331✔
194
  const [scrolledHeaderExpanded, setScrolledHeaderExpanded] = useState(false);
2,331✔
195
  const scrollTimeout = useRef(0);
2,331✔
196
  const [spacerBottomHeight, setSpacerBottomHeight] = useState('0px');
2,331✔
197

198
  const prevInternalSelectedSectionId = useRef(internalSelectedSectionId);
2,331✔
199
  const fireOnSelectedChangedEvent = (targetEvent, index, id, section) => {
2,331✔
200
    if (typeof onSelectedSectionChange === 'function' && prevInternalSelectedSectionId.current !== id) {
100!
201
      onSelectedSectionChange(
×
202
        enrichEventWithDetails(targetEvent, {
203
          selectedSectionIndex: parseInt(index, 10),
204
          selectedSectionId: id,
205
          section
206
        })
207
      );
208
      prevInternalSelectedSectionId.current = id;
×
209
    }
210
  };
211
  const debouncedOnSectionChange = useRef(debounce(fireOnSelectedChangedEvent, 500)).current;
2,331✔
212
  useEffect(() => {
2,331✔
213
    return () => {
166✔
214
      debouncedOnSectionChange.cancel();
152✔
215
      clearTimeout(selectionScrollTimeout.current);
152✔
216
    };
217
  }, []);
218

219
  // observe heights of header parts
220
  const { topHeaderHeight, headerContentHeight, anchorBarHeight, totalHeaderHeight, headerCollapsed } =
221
    useObserveHeights(
2,331✔
222
      objectPageRef,
223
      topHeaderRef,
224
      headerContentRef,
225
      anchorBarRef,
226
      [headerCollapsedInternal, setHeaderCollapsedInternal],
227
      {
228
        noHeader: !headerTitle && !headerContent,
2,335!
229
        fixedHeader: headerPinned,
230
        scrollTimeout
231
      }
232
    );
233

234
  useEffect(() => {
2,331✔
235
    if (typeof onToggleHeaderContent === 'function' && isToggledRef.current) {
472!
236
      onToggleHeaderContent(headerCollapsed !== true);
48✔
237
    }
238
  }, [headerCollapsed]);
239

240
  const avatar = useMemo(() => {
2,331✔
241
    if (!image) {
169✔
242
      return null;
166✔
243
    }
244

245
    if (typeof image === 'string') {
3!
246
      return (
2✔
247
        <span
248
          className={classes.headerImage}
249
          style={{ borderRadius: imageShapeCircle ? '50%' : 0, overflow: 'hidden' }}
2!
250
        >
251
          <img src={image} className={classes.image} alt="Company Logo" />
252
        </span>
253
      );
254
    } else {
255
      return cloneElement(image, {
1✔
256
        size: AvatarSize.L,
257
        className: clsx(classes.headerImage, image.props?.className)
258
      } as AvatarPropTypes);
259
    }
260
  }, [image, classes.headerImage, classes.image, imageShapeCircle]);
261

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

304
  const programmaticallySetSection = () => {
2,331✔
305
    const currentId = selectedSectionId ?? firstSectionId;
×
306
    if (currentId !== prevSelectedSectionId.current) {
×
307
      debouncedOnSectionChange.cancel();
×
308
      isProgrammaticallyScrolled.current = true;
×
309
      setInternalSelectedSectionId(currentId);
×
310
      prevSelectedSectionId.current = currentId;
×
311
      const sections = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
×
312
      const currentIndex = safeGetChildrenArray(children).findIndex((objectPageSection) => {
×
313
        return isValidElement(objectPageSection) && objectPageSection.props?.id === currentId;
×
314
      });
315
      fireOnSelectedChangedEvent({}, currentIndex, currentId, sections[0]);
×
316
    }
317
  };
318

319
  // change selected section when prop is changed (external change)
320
  const prevSelectedSectionId = useRef();
2,331✔
321
  const [timeStamp, setTimeStamp] = useState(0);
2,331✔
322
  const requestAnimationFrameRef = useRef<undefined | number>();
2,331✔
323
  useEffect(() => {
2,331✔
324
    if (selectedSectionId) {
166!
325
      if (mode === ObjectPageMode.Default) {
×
326
        // wait for DOM draw, otherwise initial scroll won't work as intended
327
        if (timeStamp < 750 && timeStamp !== undefined) {
×
328
          requestAnimationFrameRef.current = requestAnimationFrame((internalTimestamp) => {
×
329
            setTimeStamp(internalTimestamp);
×
330
          });
331
        } else {
332
          setTimeStamp(undefined);
×
333
          programmaticallySetSection();
×
334
        }
335
      } else {
336
        programmaticallySetSection();
×
337
      }
338
    }
339
    return () => {
166✔
340
      cancelAnimationFrame(requestAnimationFrameRef.current);
152✔
341
    };
342
  }, [timeStamp, selectedSectionId, firstSectionId, debouncedOnSectionChange]);
343

344
  // section was selected by clicking on the tab bar buttons
345
  const handleOnSectionSelected = useCallback(
2,331✔
346
    (targetEvent, newSelectionSectionId, index, section) => {
347
      isProgrammaticallyScrolled.current = true;
90✔
348
      debouncedOnSectionChange.cancel();
90✔
349
      setInternalSelectedSectionId((oldSelectedSection) => {
90✔
350
        if (oldSelectedSection === newSelectionSectionId) {
90!
351
          scrollToSection(newSelectionSectionId);
×
352
        }
353
        return newSelectionSectionId;
90✔
354
      });
355
      scrollEvent.current = targetEvent;
90✔
356
      fireOnSelectedChangedEvent(targetEvent, index, newSelectionSectionId, section);
90✔
357
    },
358
    [onSelectedSectionChange, setInternalSelectedSectionId, isProgrammaticallyScrolled, scrollToSection]
359
  );
360

361
  // do internal scrolling
362
  useEffect(() => {
2,331✔
363
    if (mode === ObjectPageMode.Default && isProgrammaticallyScrolled.current === true && !selectedSubSectionId) {
1,187!
364
      scrollToSection(internalSelectedSectionId);
47✔
365
    }
366
  }, [internalSelectedSectionId, mode, isProgrammaticallyScrolled, scrollToSection, selectedSubSectionId]);
367

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

399
  useEffect(() => {
2,331✔
400
    if (alwaysShowContentHeader !== undefined) {
236!
401
      setHeaderPinned(alwaysShowContentHeader);
80✔
402
    }
403
    if (alwaysShowContentHeader) {
236!
404
      onToggleHeaderContentVisibility({ detail: { visible: true } });
40✔
405
    }
406
  }, [alwaysShowContentHeader]);
407

408
  const prevHeaderPinned = useRef(headerPinned);
2,331✔
409
  useEffect(() => {
2,331✔
410
    if (prevHeaderPinned.current && !headerPinned && objectPageRef.current.scrollTop > topHeaderHeight) {
417!
411
      onToggleHeaderContentVisibility({ detail: { visible: false } });
41✔
412
      prevHeaderPinned.current = false;
41✔
413
    }
414
    if (!prevHeaderPinned.current && headerPinned) {
417!
415
      prevHeaderPinned.current = true;
51✔
416
    }
417
  }, [headerPinned, topHeaderHeight]);
418

419
  useEffect(() => {
2,331✔
420
    setSelectedSubSectionId(props.selectedSubSectionId);
408✔
421
    if (props.selectedSubSectionId) {
408!
422
      isProgrammaticallyScrolled.current = true;
×
423
      if (mode === ObjectPageMode.IconTabBar) {
×
424
        let sectionId;
425
        safeGetChildrenArray<ReactElement<ObjectPageSectionPropTypes>>(children).forEach((section) => {
×
426
          if (isValidElement(section) && section.props && section.props.children) {
×
427
            safeGetChildrenArray(section.props.children).forEach((subSection) => {
×
428
              if (
×
429
                isValidElement(subSection) &&
×
430
                subSection.props &&
431
                subSection.props.id === props.selectedSubSectionId
432
              ) {
433
                sectionId = section.props?.id;
×
434
              }
435
            });
436
          }
437
        });
438
        if (sectionId) {
×
439
          setInternalSelectedSectionId(sectionId);
×
440
        }
441
      }
442
    }
443
  }, [props.selectedSubSectionId, setInternalSelectedSectionId, setSelectedSubSectionId, children, mode]);
444

445
  useEffect(() => {
2,331✔
446
    const objectPage = objectPageRef.current;
897✔
447

448
    const sections = objectPage.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSection"]');
897✔
449
    const section = sections[sections.length - 1];
897✔
450

451
    const observer = new ResizeObserver(([sectionElement]) => {
897✔
452
      let heightDiff = 0;
698✔
453
      if (objectPage.scrollHeight === objectPage.offsetHeight) {
698!
454
        heightDiff = Math.max(
242✔
455
          objectPage.getBoundingClientRect().bottom - sectionElement.target.getBoundingClientRect().bottom,
456
          0
457
        );
458
      }
459
      const subSections = section.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSubSection"]');
698✔
460
      const lastSubSection = subSections[subSections.length - 1];
698✔
461
      if (lastSubSection) {
698!
462
        heightDiff +=
124✔
463
          objectPage.getBoundingClientRect().height -
464
          topHeaderHeight -
465
          48 /*tabBar*/ -
466
          (!headerCollapsed ? headerContentHeight : 0) -
124!
467
          lastSubSection.getBoundingClientRect().height -
468
          32;
469
      }
470
      // heightDiff - footer - tabbar
471
      setSpacerBottomHeight(footer ? `calc(${heightDiff}px - 1rem - 48px)` : `${heightDiff}px`);
698!
472
    });
473

474
    if (objectPage && section) {
897✔
475
      observer.observe(section, { box: 'border-box' });
881✔
476
    }
477

478
    return () => {
897✔
479
      observer.disconnect();
883✔
480
    };
481
  }, [footer, headerCollapsed, topHeaderHeight, headerContentHeight]);
482

483
  const onToggleHeaderContentVisibility = useCallback((e) => {
2,331✔
484
    isToggledRef.current = true;
196✔
485
    scrollTimeout.current = performance.now() + 500;
196✔
486
    if (!e.detail.visible) {
196!
487
      setHeaderCollapsedInternal(true);
110✔
488
      objectPageRef.current?.classList.add(classes.headerCollapsed);
110✔
489
    } else {
490
      setHeaderCollapsedInternal(false);
86✔
491
      setScrolledHeaderExpanded(true);
86✔
492
      objectPageRef.current?.classList.remove(classes.headerCollapsed);
86✔
493
    }
494
  }, []);
495

496
  const handleOnSubSectionSelected = useCallback(
2,331✔
497
    (e) => {
498
      isProgrammaticallyScrolled.current = true;
40✔
499
      if (mode === ObjectPageMode.IconTabBar) {
40!
500
        const sectionId = e.detail.sectionId;
19✔
501
        setInternalSelectedSectionId(sectionId);
19✔
502

503
        const sections = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
19✔
504
        const currentIndex = safeGetChildrenArray(children).findIndex((objectPageSection) => {
19✔
505
          return isValidElement(objectPageSection) && objectPageSection.props?.id === sectionId;
61!
506
        });
507
        debouncedOnSectionChange(e, currentIndex, sectionId, sections[currentIndex]);
19✔
508
      }
509
      const subSectionId = e.detail.subSectionId;
40✔
510
      setSelectedSubSectionId(subSectionId);
40✔
511
    },
512
    [mode, setInternalSelectedSectionId, setSelectedSubSectionId, isProgrammaticallyScrolled, children]
513
  );
514

515
  const objectPageClasses = clsx(
2,331✔
516
    classes.objectPage,
517
    GlobalStyleClasses.sapScrollBar,
518
    className,
519
    mode === ObjectPageMode.IconTabBar && classes.iconTabBarMode
2,973✔
520
  );
521

522
  const { onScroll: _0, selectedSubSectionId: _1, ...propsWithoutOmitted } = rest;
2,331✔
523

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

579
    sections.forEach((el) => {
963✔
580
      observer.observe(el);
1,239✔
581
    });
582

583
    return () => {
963✔
584
      observer.disconnect();
949✔
585
    };
586
  }, [
587
    objectPageRef.current,
588
    children,
589
    totalHeaderHeight,
590
    setInternalSelectedSectionId,
591
    isProgrammaticallyScrolled,
592
    isAfterScroll
593
  ]);
594

595
  const titleHeaderNotClickable =
596
    (alwaysShowContentHeader && !headerContentPinnable) ||
2,331!
597
    !headerContent ||
598
    (!showHideHeaderButton && !headerContentPinnable);
599

600
  const onTitleClick = useCallback(
2,331✔
601
    (e) => {
602
      e.stopPropagation();
24✔
603
      if (!titleHeaderNotClickable) {
24!
604
        onToggleHeaderContentVisibility(enrichEventWithDetails(e, { visible: headerCollapsed }));
24✔
605
      }
606
    },
607
    [onToggleHeaderContentVisibility, headerCollapsed, titleHeaderNotClickable]
608
  );
609

610
  const renderTitleSection = useCallback(
2,331✔
611
    (inHeader = false) => {
2,327✔
612
      const titleStyles = { ...(inHeader ? { padding: 0 } : {}), ...(headerTitle?.props?.style ?? {}) };
2,327!
613

614
      if (headerTitle?.props && headerTitle.props?.showSubHeaderRight === undefined) {
2,327!
615
        return cloneElement(headerTitle, {
2,327✔
616
          showSubHeaderRight: true,
617
          style: titleStyles,
618
          'data-not-clickable': titleHeaderNotClickable,
619
          onToggleHeaderContentVisibility: onTitleClick
620
        });
621
      }
622
      return cloneElement(headerTitle, {
×
623
        style: titleStyles,
624
        'data-not-clickable': titleHeaderNotClickable,
625
        onToggleHeaderContentVisibility: onTitleClick
626
      });
627
    },
628
    [headerTitle, titleHeaderNotClickable, onTitleClick]
629
  );
630

631
  const renderHeaderContentSection = useCallback(() => {
2,331✔
632
    if (headerContent?.props) {
2,331✔
633
      return cloneElement(headerContent, {
2,301✔
634
        ...headerContent.props,
635
        topHeaderHeight,
636
        style: headerCollapsed === true ? { position: 'absolute', visibility: 'hidden' } : headerContent.props.style,
2,301!
637
        headerPinned: headerPinned || scrolledHeaderExpanded,
4,380✔
638
        ref: componentRefHeaderContent,
639
        children: (
640
          <div className={classes.headerContainer} data-component-name="ObjectPageHeaderContainer">
641
            {avatar}
642
            {headerContent.props.children && (
4,602✔
643
              <div data-component-name="ObjectPageHeaderContent">
644
                {headerTitle && showTitleInHeaderContent && renderTitleSection(true)}
4,601!
645
                {headerContent.props.children}
646
              </div>
647
            )}
648
          </div>
649
        )
650
      });
651
    }
652
  }, [
653
    headerContent,
654
    topHeaderHeight,
655
    headerPinned,
656
    scrolledHeaderExpanded,
657
    showTitleInHeaderContent,
658
    avatar,
659
    headerContentRef,
660
    renderTitleSection
661
  ]);
662

663
  const onTabItemSelect = (event) => {
2,331✔
664
    event.preventDefault();
130✔
665
    const { sectionId, index, isSubTab, parentId } = event.detail.tab.dataset;
130✔
666
    if (isSubTab) {
130!
667
      handleOnSubSectionSelected(enrichEventWithDetails(event, { sectionId: parentId, subSectionId: sectionId }));
40✔
668
    } else {
669
      const section = safeGetChildrenArray<ReactElement>(children).find((el) => {
90✔
670
        return el.props.id == sectionId;
210✔
671
      });
672
      handleOnSectionSelected(event, section?.props?.id, index, section);
90✔
673
    }
674
  };
675

676
  const prevScrollTop = useRef();
2,331✔
677
  const onObjectPageScroll = useCallback(
2,331✔
678
    (e) => {
679
      if (!isToggledRef.current) {
2,359!
680
        isToggledRef.current = true;
101✔
681
      }
682
      if (scrollTimeout.current >= performance.now()) {
2,359!
683
        return;
92✔
684
      }
685
      scrollEvent.current = e;
2,267✔
686
      if (typeof props.onScroll === 'function') {
2,267!
687
        props.onScroll(e);
×
688
      }
689
      if (selectedSubSectionId) {
2,267!
690
        setSelectedSubSectionId(undefined);
35✔
691
      }
692
      if (selectionScrollTimeout.current) {
2,267!
693
        clearTimeout(selectionScrollTimeout.current);
2,166✔
694
      }
695
      selectionScrollTimeout.current = setTimeout(() => {
2,267✔
696
        setIsAfterScroll(true);
65✔
697
      }, 100);
698
      if (!headerPinned || e.target.scrollTop === 0) {
2,267!
699
        objectPageRef.current?.classList.remove(classes.headerCollapsed);
2,256✔
700
      }
701
      if (scrolledHeaderExpanded && e.target.scrollTop !== prevScrollTop.current) {
2,267!
702
        if (e.target.scrollHeight - e.target.scrollTop === e.target.clientHeight) {
10!
703
          return;
×
704
        }
705
        prevScrollTop.current = e.target.scrollTop;
10✔
706
        if (!headerPinned) {
10!
707
          setHeaderCollapsedInternal(true);
10✔
708
        }
709
        setScrolledHeaderExpanded(false);
10✔
710
      }
711
    },
712
    [topHeaderHeight, headerPinned, props.onScroll, scrolledHeaderExpanded, selectedSubSectionId]
713
  );
714

715
  const onHoverToggleButton = useCallback(
2,331✔
716
    (e) => {
717
      if (e?.type === 'mouseover') {
137!
718
        topHeaderRef.current?.classList.add(classes.headerHoverStyles);
91✔
719
      } else {
720
        topHeaderRef.current?.classList.remove(classes.headerHoverStyles);
46✔
721
      }
722
    },
723
    [classes.headerHoverStyles]
724
  );
725

726
  const objectPageStyles = { ...style };
2,331✔
727
  if (headerCollapsed === true && headerContent) {
2,331!
728
    objectPageStyles[DynamicPageCssVariables.titleFontSize] = ThemingParameters.sapObjectHeader_Title_SnappedFontSize;
911✔
729
  }
730

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

867
ObjectPage.displayName = 'ObjectPage';
323✔
868

869
ObjectPage.defaultProps = {
323✔
870
  image: null,
871
  mode: ObjectPageMode.Default,
872
  imageShapeCircle: false,
873
  showHideHeaderButton: false
874
};
875

876
export { ObjectPage };
642✔
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