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

SAP / ui5-webcomponents-react / 14438963724

14 Apr 2025 06:28AM CUT coverage: 87.698% (-0.05%) from 87.749%
14438963724

Pull #7229

github

web-flow
Merge c8b6ad678 into b824e0a7d
Pull Request #7229: chore(deps): update all non-major dependencies (main) (patch)

2981 of 3936 branches covered (75.74%)

5204 of 5934 relevant lines covered (87.7%)

94378.94 hits per line

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

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

3
import AvatarSize from '@ui5/webcomponents/dist/types/AvatarSize.js';
4
import {
5
  debounce,
6
  enrichEventWithDetails,
7
  ThemingParameters,
8
  useStylesheet,
9
  useSyncRef
10
} from '@ui5/webcomponents-react-base';
11
import { clsx } from 'clsx';
12
import type { CSSProperties, MouseEventHandler, ReactElement, UIEventHandler } from 'react';
13
import { cloneElement, forwardRef, isValidElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
14
import { ObjectPageMode } from '../../enums/ObjectPageMode.js';
15
import { safeGetChildrenArray } from '../../internal/safeGetChildrenArray.js';
16
import { useObserveHeights } from '../../internal/useObserveHeights.js';
17
import type { AvatarPropTypes } from '../../webComponents/Avatar/index.js';
18
import { Tab } from '../../webComponents/Tab/index.js';
19
import { TabContainer } from '../../webComponents/TabContainer/index.js';
20
import { ObjectPageAnchorBar } from '../ObjectPageAnchorBar/index.js';
21
import type { InternalProps as ObjectPageHeaderPropTypesWithInternals } from '../ObjectPageHeader/index.js';
22
import type { ObjectPageSectionPropTypes } from '../ObjectPageSection/index.js';
23
import type { ObjectPageSubSectionPropTypes } from '../ObjectPageSubSection/index.js';
24
import { CollapsedAvatar } from './CollapsedAvatar.js';
25
import { classNames, styleData } from './ObjectPage.module.css.js';
26
import { getSectionById, getSectionElementById } from './ObjectPageUtils.js';
27
import type { ObjectPageDomRef, ObjectPagePropTypes, ObjectPageTitlePropsWithDataAttributes } from './types/index.js';
28
import { useHandleTabSelect } from './useHandleTabSelect.js';
29

30
const ObjectPageCssVariables = {
442✔
31
  headerDisplay: '--_ui5wcr_ObjectPage_header_display',
32
  titleFontSize: '--_ui5wcr_ObjectPage_title_fontsize'
33
};
34

35
const TAB_CONTAINER_HEADER_HEIGHT = 44;
442✔
36

37
/**
38
 * A component that allows apps to easily display information related to a business object.
39
 *
40
 * The `ObjectPage` is composed of a header (title and content) and block content wrapped in sections and subsections that structure the information.
41
 */
42
const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref) => {
442✔
43
  const {
44
    titleArea,
45
    image,
46
    footerArea,
47
    mode = ObjectPageMode.Default,
6,384✔
48
    imageShapeCircle,
49
    className,
50
    style,
51
    slot,
52
    children,
53
    selectedSectionId,
54
    headerPinned: headerPinnedProp,
55
    headerArea,
56
    hidePinButton,
57
    preserveHeaderStateOnClick,
58
    accessibilityAttributes,
59
    placeholder,
60
    onSelectedSectionChange,
61
    onToggleHeaderArea,
62
    onPinButtonToggle,
63
    onBeforeNavigate,
64
    ...rest
65
  } = props;
37,072✔
66

67
  useStylesheet(styleData, ObjectPage.displayName);
37,072✔
68

69
  // memo necessary due to side effects triggered on each update
70
  const childrenArray = useMemo(
37,072✔
71
    () => safeGetChildrenArray<ReactElement<ObjectPageSectionPropTypes>>(children),
7,632✔
72
    [children]
73
  );
74
  const firstSectionId: string | undefined = childrenArray[0]?.props?.id;
37,072✔
75
  const [internalSelectedSectionId, setInternalSelectedSectionId] = useState<string | undefined>(
37,072✔
76
    selectedSectionId ?? firstSectionId
73,528✔
77
  );
78

79
  const isProgrammaticallyScrolled = useRef(false);
37,072✔
80
  const [componentRef, objectPageRef] = useSyncRef(ref);
37,072✔
81
  const topHeaderRef = useRef<HTMLDivElement>(null);
37,072✔
82
  const prevTopHeaderHeight = useRef(0);
37,072✔
83
  // @ts-expect-error: useSyncRef will create a ref if not present
84
  const [componentRefHeaderContent, headerContentRef] = useSyncRef(headerArea?.ref);
37,072✔
85
  const anchorBarRef = useRef<HTMLDivElement>(null);
37,072✔
86
  const scrollEvent = useRef(undefined);
37,072✔
87
  const objectPageContentRef = useRef<HTMLDivElement>(null);
37,072✔
88
  const selectionScrollTimeout = useRef(null);
37,072✔
89
  const isToggledRef = useRef(false);
37,072✔
90
  const scrollTimeout = useRef(0);
37,072✔
91

92
  const [selectedSubSectionId, setSelectedSubSectionId] = useState<undefined | string>(undefined);
37,072✔
93
  const [headerPinned, setHeaderPinned] = useState(headerPinnedProp);
37,072✔
94
  const [isMounted, setIsMounted] = useState(false);
37,072✔
95
  const [headerCollapsedInternal, setHeaderCollapsedInternal] = useState<undefined | boolean>(undefined);
37,072✔
96
  const [scrolledHeaderExpanded, setScrolledHeaderExpanded] = useState(false);
37,072✔
97
  const [sectionSpacer, setSectionSpacer] = useState(0);
37,072✔
98
  const [currentTabModeSection, setCurrentTabModeSection] = useState(null);
37,072✔
99
  const [toggledCollapsedHeaderWasVisible, setToggledCollapsedHeaderWasVisible] = useState(false);
37,072✔
100
  const sections = mode === ObjectPageMode.IconTabBar ? currentTabModeSection : children;
37,072✔
101

102
  useEffect(() => {
37,072✔
103
    const currentSection =
104
      mode === ObjectPageMode.IconTabBar ? getSectionById(children, internalSelectedSectionId) : null;
5,785✔
105
    setCurrentTabModeSection(currentSection);
5,785✔
106
  }, [mode, children, internalSelectedSectionId]);
107

108
  const prevInternalSelectedSectionId = useRef(internalSelectedSectionId);
37,072✔
109
  const fireOnSelectedChangedEvent = (targetEvent, index, id, section) => {
37,072✔
110
    if (typeof onSelectedSectionChange === 'function' && targetEvent && prevInternalSelectedSectionId.current !== id) {
895✔
111
      onSelectedSectionChange(
147✔
112
        enrichEventWithDetails(targetEvent, {
113
          selectedSectionIndex: parseInt(index, 10),
114
          selectedSectionId: id,
115
          section
116
        })
117
      );
118
      prevInternalSelectedSectionId.current = id;
147✔
119
    }
120
  };
121
  const debouncedOnSectionChange = useRef(debounce(fireOnSelectedChangedEvent, 500)).current;
37,072✔
122
  useEffect(() => {
37,072✔
123
    return () => {
2,242✔
124
      debouncedOnSectionChange.cancel();
2,204✔
125
      clearTimeout(selectionScrollTimeout.current);
2,204✔
126
    };
127
  }, []);
128

129
  // observe heights of header parts
130
  const { topHeaderHeight, headerContentHeight, anchorBarHeight, totalHeaderHeight, headerCollapsed } =
131
    useObserveHeights(
37,072✔
132
      objectPageRef,
133
      topHeaderRef,
134
      headerContentRef,
135
      anchorBarRef,
136
      [headerCollapsedInternal, setHeaderCollapsedInternal],
137
      {
138
        noHeader: !titleArea && !headerArea,
38,090✔
139
        fixedHeader: headerPinned,
140
        scrollTimeout
141
      }
142
    );
143

144
  useEffect(() => {
37,072✔
145
    if (typeof onToggleHeaderArea === 'function' && isToggledRef.current) {
3,652✔
146
      onToggleHeaderArea(headerCollapsed !== true);
252✔
147
    }
148
  }, [headerCollapsed]);
149

150
  useEffect(() => {
37,072✔
151
    const objectPageNode = objectPageRef.current;
3,652✔
152
    if (objectPageNode) {
3,652✔
153
      Object.assign(objectPageNode, {
3,652✔
154
        toggleHeaderArea(snapped?: boolean) {
155
          if (typeof snapped === 'boolean') {
×
156
            onToggleHeaderContentVisibility({ detail: { visible: !snapped } });
×
157
          } else {
158
            onToggleHeaderContentVisibility({ detail: { visible: !!headerCollapsed } });
×
159
          }
160
        }
161
      });
162
    }
163
  }, [headerCollapsed]);
164

165
  const avatar = useMemo(() => {
37,072✔
166
    if (!image) {
2,242✔
167
      return null;
2,156✔
168
    }
169

170
    if (typeof image === 'string') {
86✔
171
      return (
52✔
172
        <span
173
          className={classNames.headerImage}
174
          style={{ borderRadius: imageShapeCircle ? '50%' : 0, overflow: 'hidden' }}
52✔
175
          data-component-name="ObjectPageHeaderImage"
176
        >
177
          <img src={image} className={classNames.image} alt="Company Logo" />
178
        </span>
179
      );
180
    } else {
181
      return cloneElement(image, {
34✔
182
        size: AvatarSize.L,
183
        className: clsx(classNames.headerImage, image.props?.className),
184
        'data-component-name': 'ObjectPageHeaderImage'
185
      } as AvatarPropTypes);
186
    }
187
  }, [image, imageShapeCircle]);
188

189
  const scrollToSectionById = (id: string | undefined, isSubSection = false) => {
37,072✔
190
    const scroll = () => {
686✔
191
      const section = getSectionElementById(objectPageRef.current, isSubSection, id);
685✔
192
      scrollTimeout.current = performance.now() + 500;
685✔
193
      if (section) {
685✔
194
        const safeTopHeaderHeight = topHeaderHeight || prevTopHeaderHeight.current;
667✔
195

196
        const scrollMargin =
197
          -1 /* reduce margin-block so that intersection observer detects correct section*/ +
667✔
198
          safeTopHeaderHeight +
199
          anchorBarHeight +
200
          TAB_CONTAINER_HEADER_HEIGHT +
201
          (headerPinned && !headerCollapsed ? headerContentHeight : 0);
1,334!
202
        section.style.scrollMarginBlockStart = scrollMargin + 'px';
667✔
203
        if (isSubSection) {
667✔
204
          section.focus();
216✔
205
        }
206

207
        const sectionRect = section.getBoundingClientRect();
667✔
208
        const objectPageElement = objectPageRef.current;
667✔
209
        const objectPageRect = objectPageElement.getBoundingClientRect();
667✔
210

211
        // Calculate the top position of the section relative to the container
212
        objectPageElement.scrollTop = sectionRect.top - objectPageRect.top + objectPageElement.scrollTop - scrollMargin;
667✔
213

214
        section.style.scrollMarginBlockStart = '';
667✔
215
      }
216
    };
217
    // In TabBar mode the section is only rendered when selected: delay scroll for subsection
218
    if (mode === ObjectPageMode.IconTabBar && isSubSection) {
686✔
219
      setTimeout(scroll, 300);
77✔
220
    } else {
221
      scroll();
609✔
222
    }
223
  };
224

225
  const scrollToSection = (sectionId?: string) => {
37,072✔
226
    if (!sectionId) {
595!
227
      return;
×
228
    }
229
    if (firstSectionId === sectionId) {
595✔
230
      objectPageRef.current?.scrollTo({ top: 0 });
144✔
231
    } else {
232
      scrollToSectionById(sectionId);
451✔
233
    }
234
    isProgrammaticallyScrolled.current = false;
595✔
235
  };
236

237
  // section was selected by clicking on the tab bar buttons
238
  const handleOnSectionSelected = (targetEvent, newSelectionSectionId, index: number | string, section) => {
37,072✔
239
    isProgrammaticallyScrolled.current = true;
632✔
240
    debouncedOnSectionChange.cancel();
632✔
241
    setSelectedSubSectionId(undefined);
632✔
242
    setInternalSelectedSectionId((prevSelectedSection) => {
632✔
243
      if (prevSelectedSection === newSelectionSectionId) {
1,264✔
244
        scrollToSection(newSelectionSectionId);
256✔
245
      }
246
      return newSelectionSectionId;
1,264✔
247
    });
248
    scrollEvent.current = targetEvent;
632✔
249
    fireOnSelectedChangedEvent(targetEvent, index, newSelectionSectionId, section);
632✔
250
  };
251

252
  useEffect(() => {
37,072✔
253
    if (selectedSectionId) {
2,270✔
254
      if (mode === ObjectPageMode.IconTabBar) {
84!
255
        setInternalSelectedSectionId(selectedSectionId);
×
256
        return;
×
257
      }
258
      const selectedSection = getSectionElementById(objectPageRef.current, false, selectedSectionId);
84✔
259
      if (selectedSection) {
84✔
260
        const selectedSectionIndex = Array.from(
84✔
261
          selectedSection.parentElement.querySelectorAll(':scope > [data-component-name="ObjectPageSection"]')
262
        ).indexOf(selectedSection);
263
        handleOnSectionSelected({}, selectedSectionId, selectedSectionIndex, selectedSection);
84✔
264
      }
265
    }
266
  }, [selectedSectionId, mode]);
267

268
  // do internal scrolling
269
  useEffect(() => {
37,072✔
270
    if (mode === ObjectPageMode.Default && isProgrammaticallyScrolled.current === true && !selectedSubSectionId) {
3,395✔
271
      scrollToSection(internalSelectedSectionId);
339✔
272
    }
273
  }, [internalSelectedSectionId, mode, selectedSubSectionId]);
274

275
  // Scrolling for Sub Section Selection
276
  useEffect(() => {
37,072✔
277
    if (selectedSubSectionId && isProgrammaticallyScrolled.current === true) {
4,678✔
278
      scrollToSectionById(selectedSubSectionId, true);
235✔
279
      isProgrammaticallyScrolled.current = false;
235✔
280
    }
281
  }, [selectedSubSectionId, isProgrammaticallyScrolled.current, sectionSpacer]);
282

283
  useEffect(() => {
37,072✔
284
    if (headerPinnedProp !== undefined) {
2,627✔
285
      setHeaderPinned(headerPinnedProp);
495✔
286
    }
287
    if (headerPinnedProp) {
2,627✔
288
      onToggleHeaderContentVisibility({ detail: { visible: true } });
220✔
289
    }
290
  }, [headerPinnedProp]);
291

292
  const prevHeaderPinned = useRef(headerPinned);
37,072✔
293
  useEffect(() => {
37,072✔
294
    if (prevHeaderPinned.current && !headerPinned && objectPageRef.current.scrollTop > topHeaderHeight) {
4,632✔
295
      onToggleHeaderContentVisibility({ detail: { visible: false } });
224✔
296
      prevHeaderPinned.current = false;
224✔
297
    }
298
    if (!prevHeaderPinned.current && headerPinned) {
4,632✔
299
      prevHeaderPinned.current = true;
279✔
300
    }
301
  }, [headerPinned, topHeaderHeight]);
302

303
  useEffect(() => {
37,072✔
304
    if (!isMounted) {
3,378✔
305
      requestAnimationFrame(() => setIsMounted(true));
2,242✔
306
      return;
2,242✔
307
    }
308

309
    setSelectedSubSectionId(props.selectedSubSectionId);
1,136✔
310
    if (props.selectedSubSectionId) {
1,136✔
311
      isProgrammaticallyScrolled.current = true;
56✔
312
      if (mode === ObjectPageMode.IconTabBar) {
56✔
313
        let sectionId: string;
314
        childrenArray.forEach((section) => {
12✔
315
          if (isValidElement(section) && section.props && section.props.children) {
48✔
316
            safeGetChildrenArray(section.props.children).forEach((subSection) => {
48✔
317
              if (
84✔
318
                isValidElement(subSection) &&
252✔
319
                subSection.props &&
320
                (subSection as ReactElement<ObjectPageSubSectionPropTypes>).props.id === props.selectedSubSectionId
321
              ) {
322
                sectionId = section.props?.id;
12✔
323
              }
324
            });
325
          }
326
        });
327
        if (sectionId) {
12✔
328
          setInternalSelectedSectionId(sectionId);
12✔
329
        }
330
      }
331
    }
332
  }, [props.selectedSubSectionId, isMounted]);
333

334
  const tabContainerContainerRef = useRef(null);
37,072✔
335
  const isHeaderPinnedAndExpanded = headerPinned && !headerCollapsed;
37,072✔
336
  useEffect(() => {
37,072✔
337
    const objectPage = objectPageRef.current;
8,335✔
338
    const tabContainerContainer = tabContainerContainerRef.current;
8,335✔
339

340
    if (!objectPage || !tabContainerContainer) {
8,335✔
341
      return;
160✔
342
    }
343

344
    const footerElement = objectPage.querySelector<HTMLDivElement>('[data-component-name="ObjectPageFooter"]');
8,175✔
345
    const topHeaderElement = objectPage.querySelector('[data-component-name="ObjectPageTopHeader"]');
8,175✔
346

347
    const calculateSpacer = ([lastSectionNodeEntry]: ResizeObserverEntry[]) => {
8,175✔
348
      const lastSectionNode = lastSectionNodeEntry?.target;
6,557✔
349

350
      if (!lastSectionNode) {
6,557!
351
        setSectionSpacer(0);
×
352
        return;
×
353
      }
354

355
      const subSections = lastSectionNode.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSubSection"]');
6,557✔
356
      const lastSubSection = subSections[subSections.length - 1];
6,557✔
357
      const lastSubSectionOrSection = lastSubSection ?? lastSectionNode;
6,557✔
358

359
      if ((currentTabModeSection && !lastSubSection) || (sectionNodes.length === 1 && !lastSubSection)) {
6,557✔
360
        setSectionSpacer(0);
3,630✔
361
        return;
3,630✔
362
      }
363

364
      // batching DOM-reads together minimizes reflow
365
      const footerHeight = footerElement?.offsetHeight ?? 0;
2,927✔
366
      const objectPageRect = objectPage.getBoundingClientRect();
2,927✔
367
      const tabContainerContainerRect = tabContainerContainer.getBoundingClientRect();
2,927✔
368
      const lastSubSectionOrSectionRect = lastSubSectionOrSection.getBoundingClientRect();
2,927✔
369

370
      let stickyHeaderBottom = 0;
2,927✔
371
      if (!isHeaderPinnedAndExpanded) {
2,927!
372
        const topHeaderBottom = topHeaderElement?.getBoundingClientRect().bottom ?? 0;
2,927!
373
        stickyHeaderBottom = topHeaderBottom + tabContainerContainerRect.height;
2,927✔
374
      } else {
375
        stickyHeaderBottom = tabContainerContainerRect.bottom;
×
376
      }
377

378
      const spacer = Math.ceil(
2,927✔
379
        objectPageRect.bottom - stickyHeaderBottom - lastSubSectionOrSectionRect.height - footerHeight // section padding (8px) not included, so that the intersection observer is triggered correctly
380
      );
381
      setSectionSpacer(Math.max(spacer, 0));
2,927✔
382
    };
383

384
    const observer = new ResizeObserver(calculateSpacer);
8,175✔
385
    const sectionNodes = objectPage.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSection"]');
8,175✔
386
    const lastSectionNode = sectionNodes[sectionNodes.length - 1];
8,175✔
387

388
    if (lastSectionNode) {
8,175✔
389
      observer.observe(lastSectionNode, { box: 'border-box' });
7,156✔
390
    }
391

392
    return () => {
8,175✔
393
      observer.disconnect();
8,139✔
394
    };
395
  }, [topHeaderHeight, headerContentHeight, currentTabModeSection, children, mode, isHeaderPinnedAndExpanded]);
396

397
  const onToggleHeaderContentVisibility = (e) => {
37,072✔
398
    isToggledRef.current = true;
974✔
399
    scrollTimeout.current = performance.now() + 500;
974✔
400
    setToggledCollapsedHeaderWasVisible(false);
974✔
401
    if (!e.detail.visible) {
974✔
402
      if (objectPageRef.current.scrollTop <= headerContentHeight) {
530✔
403
        setToggledCollapsedHeaderWasVisible(true);
263✔
404
        if (firstSectionId === internalSelectedSectionId || mode === ObjectPageMode.IconTabBar) {
263!
405
          objectPageRef.current.scrollTop = 0;
263✔
406
        }
407
      }
408
      setHeaderCollapsedInternal(true);
530✔
409
      setScrolledHeaderExpanded(false);
530✔
410
    } else {
411
      setHeaderCollapsedInternal(false);
444✔
412
      if (objectPageRef.current.scrollTop >= headerContentHeight) {
444✔
413
        setScrolledHeaderExpanded(true);
235✔
414
      }
415
    }
416
  };
417

418
  const { onScroll: _0, selectedSubSectionId: _1, ...propsWithoutOmitted } = rest;
37,072✔
419

420
  const visibleSectionIds = useRef<Set<string>>(new Set());
37,072✔
421
  useEffect(() => {
37,072✔
422
    // section observers are not required in TabBar mode
423
    if (mode === ObjectPageMode.IconTabBar) {
7,461✔
424
      return;
2,744✔
425
    }
426
    const sectionNodes = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
4,717✔
427
    // only the sticky part of the header must be added as margin
428
    const rootMargin = `-${((headerPinned && !headerCollapsed) || scrolledHeaderExpanded ? totalHeaderHeight : topHeaderHeight) + TAB_CONTAINER_HEADER_HEIGHT}px 0px 0px 0px`;
4,717✔
429

430
    const observer = new IntersectionObserver(
4,717✔
431
      (entries) => {
432
        entries.forEach((entry) => {
4,067✔
433
          const sectionId = entry.target.id;
18,846✔
434
          if (entry.isIntersecting) {
18,846✔
435
            visibleSectionIds.current.add(sectionId);
5,881✔
436
          } else {
437
            visibleSectionIds.current.delete(sectionId);
12,965✔
438
          }
439

440
          let currentIndex: undefined | number;
441
          const sortedVisibleSections = Array.from(sectionNodes).filter((section, index) => {
18,846✔
442
            const isVisibleSection = visibleSectionIds.current.has(section.id);
199,908✔
443
            if (currentIndex === undefined && isVisibleSection) {
199,908✔
444
              currentIndex = index;
18,035✔
445
            }
446
            return visibleSectionIds.current.has(section.id);
199,908✔
447
          });
448

449
          if (sortedVisibleSections.length > 0) {
18,846✔
450
            const section = sortedVisibleSections[0];
18,035✔
451
            const id = sortedVisibleSections[0].id.slice(18);
18,035✔
452
            setInternalSelectedSectionId(id);
18,035✔
453
            debouncedOnSectionChange(scrollEvent.current, currentIndex, id, section);
18,035✔
454
          }
455
        });
456
      },
457
      {
458
        root: objectPageRef.current,
459
        rootMargin,
460
        threshold: [0]
461
      }
462
    );
463
    sectionNodes.forEach((el) => {
4,717✔
464
      observer.observe(el);
19,617✔
465
    });
466

467
    return () => {
4,717✔
468
      observer.disconnect();
4,691✔
469
    };
470
  }, [
471
    totalHeaderHeight,
472
    headerPinned,
473
    headerCollapsed,
474
    topHeaderHeight,
475
    childrenArray.length,
476
    scrolledHeaderExpanded,
477
    mode
478
  ]);
479

480
  const onTitleClick = (e) => {
37,072✔
481
    e.stopPropagation();
252✔
482
    if (!preserveHeaderStateOnClick) {
252✔
483
      onToggleHeaderContentVisibility(enrichEventWithDetails(e, { visible: headerCollapsed }));
126✔
484
    }
485
  };
486

487
  const renderHeaderContentSection = () => {
37,072✔
488
    if (headerArea?.props) {
37,072✔
489
      return cloneElement(headerArea as ReactElement<ObjectPageHeaderPropTypesWithInternals>, {
35,964✔
490
        ...headerArea.props,
491
        topHeaderHeight,
492
        style:
493
          headerCollapsed === true
35,964✔
494
            ? { ...headerArea.props.style, position: 'absolute', visibility: 'hidden', flexShrink: 0, insetInline: 0 }
495
            : { ...headerArea.props.style, flexShrink: 0 },
496
        headerPinned: headerPinned || scrolledHeaderExpanded,
69,602✔
497
        //@ts-expect-error: todo remove me when forwardref has been replaced
498
        ref: componentRefHeaderContent,
499
        children: (
500
          <div
501
            className={clsx(classNames.headerContainer, avatar && classNames.hasAvatar)}
36,230✔
502
            data-component-name="ObjectPageHeaderContainer"
503
          >
504
            {avatar}
505
            {headerArea.props.children && (
71,928✔
506
              <div data-component-name="ObjectPageHeaderContent">{headerArea.props.children}</div>
507
            )}
508
          </div>
509
        )
510
      });
511
    }
512
  };
513

514
  const prevScrollTop = useRef(undefined);
37,072✔
515
  const onObjectPageScroll: UIEventHandler<HTMLDivElement> = useCallback(
37,072✔
516
    (e) => {
517
      const target = e.target as HTMLDivElement;
10,648✔
518
      if (!isToggledRef.current) {
10,648✔
519
        isToggledRef.current = true;
561✔
520
      }
521
      if (scrollTimeout.current >= performance.now()) {
10,648✔
522
        return;
6,245✔
523
      }
524
      setToggledCollapsedHeaderWasVisible(false);
4,403✔
525
      scrollEvent.current = e;
4,403✔
526
      if (typeof props.onScroll === 'function') {
4,403!
527
        props.onScroll(e);
×
528
      }
529
      if (selectedSubSectionId) {
4,403!
530
        setSelectedSubSectionId(undefined);
×
531
      }
532
      if (selectionScrollTimeout.current) {
4,403!
533
        clearTimeout(selectionScrollTimeout.current);
×
534
      }
535
      if (!headerPinned || target.scrollTop === 0) {
4,403✔
536
        objectPageRef.current?.classList.remove(classNames.headerCollapsed);
3,885✔
537
      }
538
      if (scrolledHeaderExpanded && target.scrollTop !== prevScrollTop.current) {
4,403✔
539
        if (target.scrollHeight - target.scrollTop === target.clientHeight) {
110!
540
          return;
×
541
        }
542
        prevScrollTop.current = target.scrollTop;
110✔
543
        if (!headerPinned) {
110✔
544
          setHeaderCollapsedInternal(true);
55✔
545
        }
546
        setScrolledHeaderExpanded(false);
110✔
547
      }
548
    },
549
    [topHeaderHeight, headerPinned, props.onScroll, scrolledHeaderExpanded, selectedSubSectionId]
550
  );
551

552
  const onHoverToggleButton: MouseEventHandler<HTMLHeadElement> = useCallback((e) => {
37,072✔
553
    if (e.type === 'mouseover') {
787✔
554
      topHeaderRef.current?.classList.add(classNames.headerHoverStyles);
583✔
555
    } else {
556
      topHeaderRef.current?.classList.remove(classNames.headerHoverStyles);
204✔
557
    }
558
  }, []);
559

560
  const handleTabSelect = useHandleTabSelect({
37,072✔
561
    onBeforeNavigate,
562
    headerPinned,
563
    mode,
564
    setHeaderCollapsedInternal,
565
    setScrolledHeaderExpanded,
566
    childrenArray,
567
    handleOnSectionSelected,
568
    isProgrammaticallyScrolled,
569
    setInternalSelectedSectionId,
570
    objectPageRef,
571
    debouncedOnSectionChange,
572
    scrollTimeout,
573
    setSelectedSubSectionId
574
  });
575

576
  const objectPageStyles: CSSProperties = {
37,072✔
577
    ...style
578
  };
579
  if (headerCollapsed === true && headerArea) {
37,072✔
580
    objectPageStyles[ObjectPageCssVariables.titleFontSize] = ThemingParameters.sapObjectHeader_Title_SnappedFontSize;
11,504✔
581
  }
582

583
  return (
37,072✔
584
    <div
585
      ref={componentRef}
586
      data-component-name="ObjectPage"
587
      slot={slot}
588
      className={clsx(
589
        classNames.objectPage,
590
        className,
591
        mode === ObjectPageMode.IconTabBar && classNames.iconTabBarMode
51,610✔
592
      )}
593
      style={objectPageStyles}
594
      onScroll={onObjectPageScroll}
595
      data-in-iframe={window && window.self !== window.top}
74,144✔
596
      {...propsWithoutOmitted}
597
    >
598
      <header
599
        onMouseOver={onHoverToggleButton}
600
        onMouseLeave={onHoverToggleButton}
601
        data-component-name="ObjectPageTopHeader"
602
        ref={topHeaderRef}
603
        role={accessibilityAttributes?.objectPageTopHeader?.role}
604
        data-not-clickable={!!preserveHeaderStateOnClick}
605
        aria-roledescription={accessibilityAttributes?.objectPageTopHeader?.ariaRoledescription ?? 'Object Page header'}
74,144✔
606
        className={classNames.header}
607
      >
608
        <span
609
          className={classNames.clickArea}
610
          onClick={onTitleClick}
611
          data-component-name="ObjectPageTitleAreaClickElement"
612
        />
613
        {titleArea &&
73,126✔
614
          cloneElement(titleArea as ReactElement<ObjectPageTitlePropsWithDataAttributes>, {
615
            className: clsx(titleArea?.props?.className),
616
            onToggleHeaderContentVisibility: onTitleClick,
617
            'data-not-clickable': !!preserveHeaderStateOnClick,
618
            'data-header-content-visible': headerArea && headerCollapsed !== true,
71,768✔
619
            _snappedAvatar:
620
              !headerArea || (titleArea && image && headerCollapsed === true) ? (
143,802✔
621
                <CollapsedAvatar image={image} imageShapeCircle={imageShapeCircle} />
622
              ) : null
623
          })}
624
      </header>
625
      {renderHeaderContentSection()}
626
      {headerArea && titleArea && (
108,750✔
627
        <div
628
          data-component-name="ObjectPageAnchorBar"
629
          ref={anchorBarRef}
630
          className={classNames.anchorBar}
631
          style={{
632
            top:
633
              scrolledHeaderExpanded || headerPinned
105,434✔
634
                ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight)}px`
2,992!
635
                : `${topHeaderHeight}px`
636
          }}
637
        >
638
          <ObjectPageAnchorBar
639
            headerContentVisible={headerArea && headerCollapsed !== true}
71,428✔
640
            hidePinButton={!!hidePinButton}
641
            headerPinned={headerPinned}
642
            accessibilityAttributes={accessibilityAttributes}
643
            onToggleHeaderContentVisibility={onToggleHeaderContentVisibility}
644
            setHeaderPinned={setHeaderPinned}
645
            onHoverToggleButton={onHoverToggleButton}
646
            onPinButtonToggle={onPinButtonToggle}
647
          />
648
        </div>
649
      )}
650
      {!placeholder && (
73,822✔
651
        <div
652
          ref={tabContainerContainerRef}
653
          className={classNames.tabContainer}
654
          data-component-name="ObjectPageTabContainer"
655
          style={{
656
            top:
657
              headerPinned || scrolledHeaderExpanded
107,924✔
658
                ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight)}px`
2,992!
659
                : `${topHeaderHeight}px`
660
          }}
661
        >
662
          <TabContainer
663
            collapsed
664
            onTabSelect={handleTabSelect}
665
            data-component-name="ObjectPageTabContainer"
666
            className={classNames.tabContainerComponent}
667
          >
668
            {childrenArray.map((section, index) => {
669
              if (!isValidElement(section) || !section.props) return null;
160,198!
670
              const subTabs = safeGetChildrenArray<ReactElement<ObjectPageSubSectionPropTypes>>(
160,198✔
671
                section.props.children
672
              ).filter(
673
                (subSection) =>
674
                  // @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.
675
                  isValidElement(subSection) && subSection?.type?.displayName === 'ObjectPageSubSection'
214,462✔
676
              );
677
              return (
160,198✔
678
                <Tab
679
                  key={`Anchor-${section.props?.id}`}
680
                  data-index={index}
681
                  data-section-id={section.props.id}
682
                  text={section.props.titleText}
683
                  selected={internalSelectedSectionId === section.props?.id || undefined}
284,442✔
684
                  items={subTabs.map((item) => {
685
                    if (!isValidElement(item)) {
84,104!
686
                      return null;
×
687
                    }
688
                    return (
84,104✔
689
                      <Tab
690
                        data-parent-id={section.props.id}
691
                        key={item.props.id}
692
                        data-is-sub-tab
693
                        data-section-id={item.props.id}
694
                        text={item.props.titleText}
695
                        selected={item.props.id === selectedSubSectionId || undefined}
166,408✔
696
                        data-index={index}
697
                      >
698
                        {/*ToDo: workaround for nested tab selection*/}
699
                        <span style={{ display: 'none' }} />
700
                      </Tab>
701
                    );
702
                  })}
703
                >
704
                  {/*ToDo: workaround for nested tab selection*/}
705
                  <span style={{ display: 'none' }} />
706
                </Tab>
707
              );
708
            })}
709
          </TabContainer>
710
        </div>
711
      )}
712
      <div
713
        data-component-name="ObjectPageContent"
714
        className={classNames.content}
715
        ref={objectPageContentRef}
716
        // prevent content scroll when elements outside the content are focused
717
        onFocus={() => {
718
          objectPageRef.current.style.scrollPaddingBlockStart = `${Math.ceil(topHeaderHeight + TAB_CONTAINER_HEADER_HEIGHT + (!headerCollapsed && headerPinned ? headerContentHeight : 0))}px`;
268!
719
          if (footerArea) {
268✔
720
            objectPageRef.current.style.scrollPaddingBlockEnd = 'calc(var(--_ui5wcr-BarHeight) + 0.5rem)';
117✔
721
          }
722
        }}
723
        onBlur={(e) => {
724
          if (!e.currentTarget.contains(e.relatedTarget as Node)) {
×
725
            objectPageRef.current.style.scrollPaddingBlockStart = '0px';
×
726
            objectPageRef.current.style.scrollPaddingBlockEnd = '0px';
×
727
          }
728
        }}
729
      >
730
        <div
731
          style={{
732
            height:
124,470✔
733
              ((headerCollapsed && !headerPinned) || scrolledHeaderExpanded) && !toggledCollapsedHeaderWasVisible
734
                ? `${headerContentHeight}px`
735
                : 0
736
          }}
737
          aria-hidden="true"
738
        />
739
        {placeholder ? placeholder : sections}
37,072✔
740
        <div style={{ height: `${sectionSpacer}px` }} aria-hidden="true" />
741
      </div>
742
      {footerArea && mode === ObjectPageMode.IconTabBar && !sectionSpacer && (
45,914✔
743
        <div className={classNames.footerSpacer} data-component-name="ObjectPageFooterSpacer" aria-hidden="true" />
744
      )}
745
      {footerArea && (
42,036✔
746
        <footer
747
          role={accessibilityAttributes?.objectPageFooterArea?.role}
748
          className={classNames.footer}
749
          data-component-name="ObjectPageFooter"
750
        >
751
          {footerArea}
752
        </footer>
753
      )}
754
    </div>
755
  );
756
});
757

758
ObjectPage.displayName = 'ObjectPage';
442✔
759

760
export { ObjectPage };
761
export type { ObjectPageDomRef, ObjectPagePropTypes };
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