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

SAP / ui5-webcomponents-react / 15816603772

23 Jun 2025 06:08AM CUT coverage: 89.092% (-0.05%) from 89.142%
15816603772

Pull #7463

github

web-flow
Merge 9898f0c74 into 19036700d
Pull Request #7463: chore(deps): update all non-major dependencies (examples, templates & patterns) (main) (minor)

3045 of 3941 branches covered (77.26%)

5325 of 5977 relevant lines covered (89.09%)

134105.97 hits per line

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

95.04
/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
  useIsomorphicLayoutEffect,
9
  useStylesheet,
10
  useSyncRef,
11
} from '@ui5/webcomponents-react-base';
12
import { clsx } from 'clsx';
13
import type { CSSProperties, MouseEventHandler, ReactElement, UIEventHandler } from 'react';
14
import { cloneElement, forwardRef, isValidElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
import { ObjectPageMode } from '../../enums/ObjectPageMode.js';
16
import { safeGetChildrenArray } from '../../internal/safeGetChildrenArray.js';
17
import { useObserveHeights } from '../../internal/useObserveHeights.js';
18
import type { AvatarPropTypes } from '../../webComponents/Avatar/index.js';
19
import { Tab } from '../../webComponents/Tab/index.js';
20
import { TabContainer } from '../../webComponents/TabContainer/index.js';
21
import { ObjectPageAnchorBar } from '../ObjectPageAnchorBar/index.js';
22
import type { InternalProps as ObjectPageHeaderPropTypesWithInternals } from '../ObjectPageHeader/index.js';
23
import type { ObjectPageSectionPropTypes } from '../ObjectPageSection/index.js';
24
import type { ObjectPageSubSectionPropTypes } from '../ObjectPageSubSection/index.js';
25
import { CollapsedAvatar } from './CollapsedAvatar.js';
26
import { classNames, styleData } from './ObjectPage.module.css.js';
27
import { getSectionById, getSectionElementById } from './ObjectPageUtils.js';
28
import type {
29
  HandleOnSectionSelectedType,
30
  ObjectPageDomRef,
31
  ObjectPagePropTypes,
32
  ObjectPageTitlePropsWithDataAttributes,
33
} from './types/index.js';
34
import { useHandleTabSelect } from './useHandleTabSelect.js';
35
import { useOnScrollEnd } from './useOnScrollEnd.js';
36

37
const ObjectPageCssVariables = {
446✔
38
  headerDisplay: '--_ui5wcr_ObjectPage_header_display',
39
  titleFontSize: '--_ui5wcr_ObjectPage_title_fontsize',
40
};
41

42
const TAB_CONTAINER_HEADER_HEIGHT = 44 + 4; // tabbar height + custom 4px padding-block-start
446✔
43

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

74
  useStylesheet(styleData, ObjectPage.displayName);
42,088✔
75

76
  // memo necessary due to side effects triggered on each update
77
  const childrenArray = useMemo(
42,088✔
78
    () => safeGetChildrenArray<ReactElement<ObjectPageSectionPropTypes>>(children),
7,974✔
79
    [children],
80
  );
81
  const firstSectionId: string | undefined = childrenArray[0]?.props?.id;
42,088✔
82
  const [internalSelectedSectionId, setInternalSelectedSectionId] = useState<string | undefined>(
42,088✔
83
    selectedSectionId ?? firstSectionId,
82,396✔
84
  );
85
  const [tabSelectId, setTabSelectId] = useState<null | string>(null);
42,088✔
86

87
  const isProgrammaticallyScrolled = useRef(false);
42,088✔
88
  const [componentRef, objectPageRef] = useSyncRef(ref);
42,088✔
89
  const topHeaderRef = useRef<HTMLDivElement>(null);
42,088✔
90
  const prevTopHeaderHeight = useRef(0);
42,088✔
91
  // @ts-expect-error: useSyncRef will create a ref if not present
92
  const [componentRefHeaderContent, headerContentRef] = useSyncRef(headerArea?.ref);
42,088✔
93
  const scrollEvent = useRef(undefined);
42,088✔
94
  const objectPageContentRef = useRef<HTMLDivElement>(null);
42,088✔
95
  const selectionScrollTimeout = useRef(null);
42,088✔
96
  const isToggledRef = useRef(false);
42,088✔
97
  const scrollTimeout = useRef(0);
42,088✔
98

99
  const [selectedSubSectionId, setSelectedSubSectionId] = useState<undefined | string>(undefined);
42,088✔
100
  const [headerPinned, setHeaderPinned] = useState(headerPinnedProp);
42,088✔
101
  const [isMounted, setIsMounted] = useState(false);
42,088✔
102
  const [headerCollapsedInternal, setHeaderCollapsedInternal] = useState<undefined | boolean>(undefined);
42,088✔
103
  const [scrolledHeaderExpanded, setScrolledHeaderExpanded] = useState(false);
42,088✔
104
  const [sectionSpacer, setSectionSpacer] = useState(0);
42,088✔
105
  const [currentTabModeSection, setCurrentTabModeSection] = useState(null);
42,088✔
106
  const [toggledCollapsedHeaderWasVisible, setToggledCollapsedHeaderWasVisible] = useState(false);
42,088✔
107
  const sections = mode === ObjectPageMode.IconTabBar ? currentTabModeSection : children;
42,088✔
108
  const scrollEndHandler = useOnScrollEnd({ objectPageRef, setTabSelectId });
42,088✔
109

110
  useEffect(() => {
42,088✔
111
    const currentSection =
112
      mode === ObjectPageMode.IconTabBar ? getSectionById(children, internalSelectedSectionId) : null;
6,559✔
113
    setCurrentTabModeSection(currentSection);
6,559✔
114
  }, [mode, children, internalSelectedSectionId]);
115

116
  const prevInternalSelectedSectionId = useRef(internalSelectedSectionId);
42,088✔
117
  const fireOnSelectedChangedEvent = (targetEvent, index, id, section) => {
42,088✔
118
    if (typeof onSelectedSectionChange === 'function' && targetEvent && prevInternalSelectedSectionId.current !== id) {
1,038✔
119
      onSelectedSectionChange(
297✔
120
        enrichEventWithDetails(targetEvent, {
121
          selectedSectionIndex: parseInt(index, 10),
122
          selectedSectionId: id,
123
          section,
124
        }),
125
      );
126
      prevInternalSelectedSectionId.current = id;
297✔
127
    }
128
  };
129
  const debouncedOnSectionChange = useRef(debounce(fireOnSelectedChangedEvent, 500)).current;
42,088✔
130
  useEffect(() => {
42,088✔
131
    return () => {
2,390✔
132
      debouncedOnSectionChange.cancel();
2,351✔
133
      clearTimeout(selectionScrollTimeout.current);
2,351✔
134
    };
135
  }, []);
136

137
  // observe heights of header parts
138
  const { topHeaderHeight, headerContentHeight, totalHeaderHeight, headerCollapsed } = useObserveHeights(
42,088✔
139
    objectPageRef,
140
    topHeaderRef,
141
    headerContentRef,
142
    [headerCollapsedInternal, setHeaderCollapsedInternal],
143
    {
144
      noHeader: !titleArea && !headerArea,
43,854✔
145
      fixedHeader: headerPinned,
146
      scrollTimeout,
147
    },
148
  );
149

150
  useEffect(() => {
42,088✔
151
    if (typeof onToggleHeaderArea === 'function' && isToggledRef.current) {
3,847✔
152
      onToggleHeaderArea(headerCollapsed !== true);
260✔
153
    }
154
  }, [headerCollapsed]);
155

156
  useEffect(() => {
42,088✔
157
    const objectPageNode = objectPageRef.current;
3,847✔
158
    if (objectPageNode) {
3,847✔
159
      Object.assign(objectPageNode, {
3,847✔
160
        toggleHeaderArea(snapped?: boolean) {
161
          if (typeof snapped === 'boolean') {
×
162
            onToggleHeaderContentVisibility({ detail: { visible: !snapped } });
×
163
          } else {
164
            onToggleHeaderContentVisibility({ detail: { visible: !!headerCollapsed } });
×
165
          }
166
        },
167
      });
168
    }
169
  }, [headerCollapsed]);
170

171
  const avatar = useMemo(() => {
42,088✔
172
    if (!image) {
2,390✔
173
      return null;
2,298✔
174
    }
175

176
    if (typeof image === 'string') {
92✔
177
      return (
56✔
178
        <span
179
          className={classNames.headerImage}
180
          style={{ borderRadius: imageShapeCircle ? '50%' : 0, overflow: 'hidden' }}
56✔
181
          data-component-name="ObjectPageHeaderImage"
182
        >
183
          <img src={image} className={classNames.image} alt="Company Logo" />
184
        </span>
185
      );
186
    } else {
187
      return cloneElement(image, {
36✔
188
        size: AvatarSize.L,
189
        className: clsx(classNames.headerImage, image.props?.className),
190
        'data-component-name': 'ObjectPageHeaderImage',
191
      } as AvatarPropTypes);
192
    }
193
  }, [image, imageShapeCircle]);
194

195
  const scrollToSectionById = (id: string | undefined, isSubSection = false) => {
42,088✔
196
    const scroll = () => {
688✔
197
      const section = getSectionElementById(objectPageRef.current, isSubSection, id);
687✔
198
      scrollTimeout.current = performance.now() + 500;
687✔
199
      if (section) {
687✔
200
        const safeTopHeaderHeight = topHeaderHeight || prevTopHeaderHeight.current;
668✔
201

202
        const scrollMargin =
203
          -1 /* reduce margin-block so that intersection observer detects correct section*/ +
668✔
204
          safeTopHeaderHeight +
205
          TAB_CONTAINER_HEADER_HEIGHT +
206
          (headerPinned && !headerCollapsed ? headerContentHeight : 0);
1,336!
207
        section.style.scrollMarginBlockStart = scrollMargin + 'px';
668✔
208
        if (isSubSection) {
668✔
209
          section.focus();
239✔
210
        }
211

212
        const sectionRect = section.getBoundingClientRect();
668✔
213
        const objectPageElement = objectPageRef.current;
668✔
214
        const objectPageRect = objectPageElement.getBoundingClientRect();
668✔
215

216
        // Calculate the top position of the section relative to the container
217
        objectPageElement.scrollTop = sectionRect.top - objectPageRect.top + objectPageElement.scrollTop - scrollMargin;
668✔
218

219
        section.style.scrollMarginBlockStart = '';
668✔
220
      }
221
    };
222
    // In TabBar mode the section is only rendered when selected: delay scroll for subsection
223
    if (mode === ObjectPageMode.IconTabBar && isSubSection) {
688✔
224
      setTimeout(scroll, 300);
100✔
225
    } else {
226
      scroll();
588✔
227
    }
228
  };
229

230
  const scrollToSection = (sectionId?: string) => {
42,088✔
231
    if (!sectionId) {
622!
232
      return;
×
233
    }
234
    if (firstSectionId === sectionId) {
622✔
235
      objectPageRef.current?.scrollTo({ top: 0 });
193✔
236
    } else {
237
      scrollToSectionById(sectionId);
429✔
238
    }
239
    isProgrammaticallyScrolled.current = false;
622✔
240
  };
241

242
  // section was selected by clicking on the tab bar buttons
243
  const handleOnSectionSelected: HandleOnSectionSelectedType = (targetEvent, newSelectionSectionId, index, section) => {
42,088✔
244
    isProgrammaticallyScrolled.current = true;
752✔
245
    debouncedOnSectionChange.cancel();
752✔
246
    setSelectedSubSectionId(undefined);
752✔
247
    setInternalSelectedSectionId((prevSelectedSection) => {
752✔
248
      if (prevSelectedSection === newSelectionSectionId) {
1,504✔
249
        scrollToSection(newSelectionSectionId);
226✔
250
      }
251
      return newSelectionSectionId;
1,504✔
252
    });
253
    setTabSelectId(newSelectionSectionId);
752✔
254
    scrollEvent.current = targetEvent;
752✔
255
    fireOnSelectedChangedEvent(targetEvent, index, newSelectionSectionId, section);
752✔
256
  };
257

258
  useIsomorphicLayoutEffect(() => {
42,088✔
259
    if (selectedSectionId) {
2,426✔
260
      const fireSelectEvent = () => {
108✔
261
        const selectedSection = getSectionElementById(objectPageRef.current, false, selectedSectionId);
108✔
262
        if (selectedSection) {
108✔
263
          const selectedSectionIndex = Array.from(
108✔
264
            selectedSection.parentElement.querySelectorAll(':scope > [data-component-name="ObjectPageSection"]'),
265
          ).indexOf(selectedSection);
266
          handleOnSectionSelected({}, selectedSectionId, selectedSectionIndex, selectedSection);
108✔
267
        }
268
      };
269
      if (mode === ObjectPageMode.IconTabBar) {
108✔
270
        setInternalSelectedSectionId(selectedSectionId);
48✔
271
        // In TabBar mode the section is only rendered when selected, therefore delay firing the event until the section is available in the DOM
272
        setTimeout(fireSelectEvent);
48✔
273
      } else {
274
        fireSelectEvent();
60✔
275
      }
276
    }
277
  }, [selectedSectionId, mode]);
278

279
  // do internal scrolling
280
  useEffect(() => {
42,088✔
281
    if (mode === ObjectPageMode.Default && isProgrammaticallyScrolled.current === true && !selectedSubSectionId) {
4,128✔
282
      scrollToSection(internalSelectedSectionId);
396✔
283
    }
284
  }, [internalSelectedSectionId, mode, selectedSubSectionId]);
285

286
  // Scrolling for Sub Section Selection
287
  useEffect(() => {
42,088✔
288
    if (selectedSubSectionId && isProgrammaticallyScrolled.current === true) {
5,202✔
289
      scrollToSectionById(selectedSubSectionId, true);
259✔
290
      isProgrammaticallyScrolled.current = false;
259✔
291
    }
292
  }, [selectedSubSectionId, isProgrammaticallyScrolled.current, sectionSpacer]);
293

294
  useEffect(() => {
42,088✔
295
    if (headerPinnedProp !== undefined) {
2,789✔
296
      setHeaderPinned(headerPinnedProp);
513✔
297
    }
298
    if (headerPinnedProp) {
2,789✔
299
      onToggleHeaderContentVisibility({ detail: { visible: true } });
228✔
300
    }
301
  }, [headerPinnedProp]);
302

303
  const prevHeaderPinned = useRef(headerPinned);
42,088✔
304
  useEffect(() => {
42,088✔
305
    if (prevHeaderPinned.current && !headerPinned && objectPageRef.current.scrollTop > topHeaderHeight) {
4,888✔
306
      onToggleHeaderContentVisibility({ detail: { visible: false } });
232✔
307
      prevHeaderPinned.current = false;
232✔
308
    }
309
    if (!prevHeaderPinned.current && headerPinned) {
4,888✔
310
      prevHeaderPinned.current = true;
289✔
311
    }
312
  }, [headerPinned, topHeaderHeight]);
313

314
  const isInitialTabBarMode = useRef(true);
42,088✔
315
  useEffect(() => {
42,088✔
316
    if (!isMounted) {
3,607✔
317
      requestAnimationFrame(() => setIsMounted(true));
2,390✔
318
      return;
2,390✔
319
    }
320

321
    setSelectedSubSectionId(props.selectedSubSectionId);
1,217✔
322
    if (props.selectedSubSectionId) {
1,217✔
323
      isProgrammaticallyScrolled.current = true;
72✔
324
      if (mode === ObjectPageMode.IconTabBar) {
72✔
325
        let sectionId: string;
326
        let curSection: ReactElement;
327
        let sectionIndex: number = -1;
32✔
328
        childrenArray.forEach((section, index) => {
32✔
329
          if (isValidElement(section) && section.props && section.props.children) {
128✔
330
            safeGetChildrenArray(section.props.children).forEach((subSection) => {
128✔
331
              if (
224✔
332
                isValidElement(subSection) &&
672✔
333
                subSection.props &&
334
                (subSection as ReactElement<ObjectPageSubSectionPropTypes>).props.id === props.selectedSubSectionId
335
              ) {
336
                curSection = section;
32✔
337
                sectionId = section.props?.id;
32✔
338
                sectionIndex = index;
32✔
339
              }
340
            });
341
          }
342
        });
343
        if (sectionId) {
32✔
344
          if (!isInitialTabBarMode.current) {
32✔
345
            //In TabBar mode the section is often not scrolled when subsection changes, thus the onSelectedSectionChange isn't fired
346
            debouncedOnSectionChange({}, sectionIndex, sectionId, curSection);
16✔
347
          }
348
          setInternalSelectedSectionId(sectionId);
32✔
349
        }
350
      }
351
    }
352
    isInitialTabBarMode.current = false;
1,217✔
353
  }, [props.selectedSubSectionId, isMounted]);
354

355
  const tabContainerContainerRef = useRef(null);
42,088✔
356
  const isHeaderPinnedAndExpanded = headerPinned && !headerCollapsed;
42,088✔
357
  useEffect(() => {
42,088✔
358
    const objectPage = objectPageRef.current;
8,827✔
359
    const tabContainerContainer = tabContainerContainerRef.current;
8,827✔
360

361
    if (!objectPage || !tabContainerContainer) {
8,827✔
362
      return;
174✔
363
    }
364

365
    const footerElement = objectPage.querySelector<HTMLDivElement>('[data-component-name="ObjectPageFooter"]');
8,653✔
366
    const topHeaderElement = objectPage.querySelector('[data-component-name="ObjectPageTopHeader"]');
8,653✔
367

368
    const calculateSpacer = ([lastSectionNodeEntry]: ResizeObserverEntry[]) => {
8,653✔
369
      const lastSectionNode = lastSectionNodeEntry?.target;
6,906✔
370

371
      if (!lastSectionNode) {
6,906!
372
        setSectionSpacer(0);
×
373
        return;
×
374
      }
375

376
      const subSections = lastSectionNode.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSubSection"]');
6,906✔
377
      const lastSubSection = subSections[subSections.length - 1];
6,906✔
378
      const lastSubSectionOrSection = lastSubSection ?? lastSectionNode;
6,906✔
379

380
      if ((currentTabModeSection && !lastSubSection) || (sectionNodes.length === 1 && !lastSubSection)) {
6,906✔
381
        setSectionSpacer(0);
3,810✔
382
        return;
3,810✔
383
      }
384

385
      // batching DOM-reads together minimizes reflow
386
      const footerHeight = footerElement?.offsetHeight ?? 0;
3,096✔
387
      const objectPageRect = objectPage.getBoundingClientRect();
3,096✔
388
      const tabContainerContainerRect = tabContainerContainer.getBoundingClientRect();
3,096✔
389
      const lastSubSectionOrSectionRect = lastSubSectionOrSection.getBoundingClientRect();
3,096✔
390

391
      let stickyHeaderBottom = 0;
3,096✔
392
      if (!isHeaderPinnedAndExpanded) {
3,096!
393
        const topHeaderBottom = topHeaderElement?.getBoundingClientRect().bottom ?? 0;
3,096!
394
        stickyHeaderBottom = topHeaderBottom + tabContainerContainerRect.height;
3,096✔
395
      } else {
396
        stickyHeaderBottom = tabContainerContainerRect.bottom;
×
397
      }
398

399
      const spacer = Math.ceil(
3,096✔
400
        objectPageRect.bottom - stickyHeaderBottom - lastSubSectionOrSectionRect.height - footerHeight, // section padding (8px) not included, so that the intersection observer is triggered correctly
401
      );
402
      setSectionSpacer(Math.max(spacer, 0));
3,096✔
403
    };
404

405
    const observer = new ResizeObserver(calculateSpacer);
8,653✔
406
    const sectionNodes = objectPage.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSection"]');
8,653✔
407
    const lastSectionNode = sectionNodes[sectionNodes.length - 1];
8,653✔
408

409
    if (lastSectionNode) {
8,653✔
410
      observer.observe(lastSectionNode, { box: 'border-box' });
7,542✔
411
    }
412

413
    return () => {
8,653✔
414
      observer.disconnect();
8,616✔
415
    };
416
  }, [topHeaderHeight, headerContentHeight, currentTabModeSection, children, mode, isHeaderPinnedAndExpanded]);
417

418
  const onToggleHeaderContentVisibility = (e) => {
42,088✔
419
    isToggledRef.current = true;
1,010✔
420
    scrollTimeout.current = performance.now() + 500;
1,010✔
421
    setToggledCollapsedHeaderWasVisible(false);
1,010✔
422
    if (!e.detail.visible) {
1,010✔
423
      if (objectPageRef.current.scrollTop <= headerContentHeight) {
550✔
424
        setToggledCollapsedHeaderWasVisible(true);
273✔
425
        if (firstSectionId === internalSelectedSectionId || mode === ObjectPageMode.IconTabBar) {
273!
426
          objectPageRef.current.scrollTop = 0;
273✔
427
        }
428
      }
429
      setHeaderCollapsedInternal(true);
550✔
430
      setScrolledHeaderExpanded(false);
550✔
431
    } else {
432
      setHeaderCollapsedInternal(false);
460✔
433
      if (objectPageRef.current.scrollTop >= headerContentHeight) {
460✔
434
        setScrolledHeaderExpanded(true);
216✔
435
      }
436
    }
437
  };
438

439
  const { onScroll: _0, selectedSubSectionId: _1, ...propsWithoutOmitted } = rest;
42,088✔
440

441
  const visibleSectionIds = useRef<Set<string>>(new Set());
42,088✔
442
  useEffect(() => {
42,088✔
443
    // section observers are not required in TabBar mode
444
    if (mode === ObjectPageMode.IconTabBar) {
7,809✔
445
      return;
2,883✔
446
    }
447
    const sectionNodes = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
4,926✔
448
    // only the sticky part of the header must be added as margin
449
    const rootMargin = `-${((headerPinned && !headerCollapsed) || scrolledHeaderExpanded ? totalHeaderHeight : topHeaderHeight) + TAB_CONTAINER_HEADER_HEIGHT}px 0px 0px 0px`;
4,926✔
450

451
    const observer = new IntersectionObserver(
4,926✔
452
      (entries) => {
453
        entries.forEach((entry) => {
4,953✔
454
          const sectionId = entry.target.id;
20,075✔
455
          if (entry.isIntersecting) {
20,075✔
456
            visibleSectionIds.current.add(sectionId);
6,427✔
457
          } else {
458
            visibleSectionIds.current.delete(sectionId);
13,648✔
459
          }
460

461
          let currentIndex: undefined | number;
462
          const sortedVisibleSections = Array.from(sectionNodes).filter((section, index) => {
20,075✔
463
            const isVisibleSection = visibleSectionIds.current.has(section.id);
208,887✔
464
            if (currentIndex === undefined && isVisibleSection) {
208,887✔
465
              currentIndex = index;
19,475✔
466
            }
467
            return visibleSectionIds.current.has(section.id);
208,887✔
468
          });
469

470
          if (sortedVisibleSections.length > 0) {
20,075✔
471
            const section = sortedVisibleSections[0];
19,475✔
472
            const id = sortedVisibleSections[0].id.slice(18);
19,475✔
473
            setInternalSelectedSectionId(id);
19,475✔
474
            debouncedOnSectionChange(scrollEvent.current, currentIndex, id, section);
19,475✔
475
          }
476
        });
477
      },
478
      {
479
        root: objectPageRef.current,
480
        rootMargin,
481
        threshold: [0],
482
      },
483
    );
484
    sectionNodes.forEach((el) => {
4,926✔
485
      observer.observe(el);
20,378✔
486
    });
487

488
    return () => {
4,926✔
489
      observer.disconnect();
4,900✔
490
    };
491
  }, [
492
    totalHeaderHeight,
493
    headerPinned,
494
    headerCollapsed,
495
    topHeaderHeight,
496
    childrenArray.length,
497
    scrolledHeaderExpanded,
498
    mode,
499
  ]);
500

501
  const onTitleClick = (e) => {
42,088✔
502
    e.stopPropagation();
260✔
503
    if (!preserveHeaderStateOnClick) {
260✔
504
      onToggleHeaderContentVisibility(enrichEventWithDetails(e, { visible: headerCollapsed }));
130✔
505
    }
506
  };
507

508
  const renderHeaderContentSection = () => {
42,088✔
509
    if (headerArea?.props) {
42,088✔
510
      return cloneElement(headerArea as ReactElement<ObjectPageHeaderPropTypesWithInternals>, {
40,250✔
511
        ...headerArea.props,
512
        topHeaderHeight,
513
        style:
514
          headerCollapsed === true
40,250✔
515
            ? { ...headerArea.props.style, position: 'absolute', visibility: 'hidden', flexShrink: 0, insetInline: 0 }
516
            : { ...headerArea.props.style, flexShrink: 0 },
517
        headerPinned: headerPinned || scrolledHeaderExpanded,
78,148✔
518
        //@ts-expect-error: todo remove me when forwardref has been replaced
519
        ref: componentRefHeaderContent,
520
        children: (
521
          <div
522
            className={clsx(classNames.headerContainer, avatar && classNames.hasAvatar)}
40,534✔
523
            data-component-name="ObjectPageHeaderContainer"
524
          >
525
            {avatar}
526
            {headerArea.props.children && (
80,500✔
527
              <div data-component-name="ObjectPageHeaderContent">{headerArea.props.children}</div>
528
            )}
529
          </div>
530
        ),
531
      });
532
    }
533
  };
534

535
  const prevScrollTop = useRef(undefined);
42,088✔
536
  const onObjectPageScroll: UIEventHandler<HTMLDivElement> = useCallback(
42,088✔
537
    (e) => {
538
      const target = e.target as HTMLDivElement;
17,518✔
539
      scrollEndHandler(e);
17,518✔
540
      if (!isToggledRef.current) {
17,518✔
541
        isToggledRef.current = true;
651✔
542
      }
543
      if (scrollTimeout.current >= performance.now()) {
17,518✔
544
        return;
11,183✔
545
      }
546
      setToggledCollapsedHeaderWasVisible(false);
6,335✔
547
      scrollEvent.current = e;
6,335✔
548
      if (typeof props.onScroll === 'function') {
6,335!
549
        props.onScroll(e);
×
550
      }
551
      if (selectedSubSectionId) {
6,335✔
552
        setSelectedSubSectionId(undefined);
49✔
553
      }
554
      if (selectionScrollTimeout.current) {
6,335!
555
        clearTimeout(selectionScrollTimeout.current);
×
556
      }
557
      if (!headerPinned || target.scrollTop === 0) {
6,335✔
558
        objectPageRef.current?.classList.remove(classNames.headerCollapsed);
5,831✔
559
      }
560
      if (scrolledHeaderExpanded && target.scrollTop !== prevScrollTop.current) {
6,335✔
561
        if (target.scrollHeight - target.scrollTop === target.clientHeight) {
114!
562
          return;
×
563
        }
564
        prevScrollTop.current = target.scrollTop;
114✔
565
        if (!headerPinned) {
114✔
566
          setHeaderCollapsedInternal(true);
57✔
567
        }
568
        setScrolledHeaderExpanded(false);
114✔
569
      }
570
    },
571
    [topHeaderHeight, headerPinned, props.onScroll, scrolledHeaderExpanded, selectedSubSectionId],
572
  );
573

574
  const onHoverToggleButton: MouseEventHandler<HTMLHeadElement> = useCallback((e) => {
42,088✔
575
    if (e.type === 'mouseover') {
830✔
576
      topHeaderRef.current?.classList.add(classNames.headerHoverStyles);
618✔
577
    } else {
578
      topHeaderRef.current?.classList.remove(classNames.headerHoverStyles);
212✔
579
    }
580
  }, []);
581

582
  const handleTabSelect = useHandleTabSelect({
42,088✔
583
    onBeforeNavigate,
584
    headerPinned,
585
    mode,
586
    setHeaderCollapsedInternal,
587
    setScrolledHeaderExpanded,
588
    childrenArray,
589
    handleOnSectionSelected,
590
    isProgrammaticallyScrolled,
591
    setInternalSelectedSectionId,
592
    objectPageRef,
593
    debouncedOnSectionChange,
594
    scrollTimeout,
595
    setSelectedSubSectionId,
596
    setTabSelectId,
597
  });
598
  const objectPageStyles: CSSProperties = {
42,088✔
599
    ...style,
600
  };
601
  if (headerCollapsed === true && headerArea) {
42,088✔
602
    objectPageStyles[ObjectPageCssVariables.titleFontSize] = ThemingParameters.sapObjectHeader_Title_SnappedFontSize;
14,186✔
603
  }
604

605
  return (
42,088✔
606
    <div
607
      ref={componentRef}
608
      data-component-name="ObjectPage"
609
      slot={slot}
610
      className={clsx(
611
        classNames.objectPage,
612
        className,
613
        mode === ObjectPageMode.IconTabBar && classNames.iconTabBarMode,
58,414✔
614
      )}
615
      style={objectPageStyles}
616
      onScroll={onObjectPageScroll}
617
      data-in-iframe={window && window.self !== window.top}
84,176✔
618
      {...propsWithoutOmitted}
619
    >
620
      <header
621
        onMouseOver={onHoverToggleButton}
622
        onMouseLeave={onHoverToggleButton}
623
        data-component-name="ObjectPageTopHeader"
624
        ref={topHeaderRef}
625
        role={accessibilityAttributes?.objectPageTopHeader?.role}
626
        data-not-clickable={!!preserveHeaderStateOnClick}
627
        aria-roledescription={accessibilityAttributes?.objectPageTopHeader?.ariaRoledescription ?? 'Object Page header'}
84,152✔
628
        className={classNames.header}
629
      >
630
        <span
631
          className={classNames.clickArea}
632
          onClick={onTitleClick}
633
          data-component-name="ObjectPageTitleAreaClickElement"
634
        />
635
        {titleArea &&
82,410✔
636
          cloneElement(titleArea as ReactElement<ObjectPageTitlePropsWithDataAttributes>, {
637
            className: clsx(titleArea?.props?.className),
638
            onToggleHeaderContentVisibility: onTitleClick,
639
            'data-not-clickable': !!preserveHeaderStateOnClick,
640
            'data-header-content-visible': headerArea && headerCollapsed !== true,
79,984✔
641
            _snappedAvatar:
642
              (!headerArea && image) || (image && headerCollapsed === true) ? (
121,894✔
643
                <CollapsedAvatar image={image} imageShapeCircle={imageShapeCircle} />
644
              ) : null,
645
          })}
646
      </header>
647
      {renderHeaderContentSection()}
648
      {headerArea && titleArea && (
122,000✔
649
        <div
650
          data-component-name="ObjectPageAnchorBar"
651
          className={classNames.anchorBar}
652
          style={{
653
            top:
654
              scrolledHeaderExpanded || headerPinned
117,438✔
655
                ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight)}px`
2,988!
656
                : `${topHeaderHeight}px`,
657
          }}
658
        >
659
          <ObjectPageAnchorBar
660
            headerContentVisible={headerArea && headerCollapsed !== true}
79,324✔
661
            hidePinButton={!!hidePinButton}
662
            headerPinned={headerPinned}
663
            accessibilityAttributes={accessibilityAttributes?.objectPageAnchorBar}
664
            onToggleHeaderContentVisibility={onToggleHeaderContentVisibility}
665
            setHeaderPinned={setHeaderPinned}
666
            onHoverToggleButton={onHoverToggleButton}
667
            onPinButtonToggle={onPinButtonToggle}
668
          />
669
        </div>
670
      )}
671
      {!placeholder && (
83,830✔
672
        <div
673
          ref={tabContainerContainerRef}
674
          className={classNames.tabContainer}
675
          data-component-name="ObjectPageTabContainer"
676
          style={{
677
            top:
678
              headerPinned || scrolledHeaderExpanded
122,874✔
679
                ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight)}px`
2,988!
680
                : `${topHeaderHeight}px`,
681
          }}
682
        >
683
          <TabContainer
684
            collapsed
685
            onTabSelect={handleTabSelect}
686
            data-component-name="ObjectPageTabContainer"
687
            className={classNames.tabContainerComponent}
688
          >
689
            {childrenArray.map((section, index) => {
690
              if (!isValidElement(section) || !section.props) return null;
183,340!
691
              const subTabs = safeGetChildrenArray<ReactElement<ObjectPageSubSectionPropTypes>>(
183,340✔
692
                section.props.children,
693
              ).filter(
694
                (subSection) =>
695
                  // @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.
696
                  isValidElement(subSection) && subSection?.type?.displayName === 'ObjectPageSubSection',
250,996✔
697
              );
698
              return (
183,340✔
699
                <Tab
700
                  key={`Anchor-${section.props?.id}`}
701
                  data-index={index}
702
                  data-section-id={section.props.id}
703
                  text={section.props.titleText}
704
                  selected={
705
                    (tabSelectId && tabSelectId === section.props?.id) ||
681,910✔
706
                    (!tabSelectId && internalSelectedSectionId === section.props?.id) ||
707
                    undefined
708
                  }
709
                  items={subTabs.map((item) => {
710
                    if (!isValidElement(item)) {
106,258!
711
                      return null;
×
712
                    }
713
                    return (
106,258✔
714
                      <Tab
715
                        data-parent-id={section.props.id}
716
                        key={item.props.id}
717
                        data-is-sub-tab
718
                        data-section-id={item.props.id}
719
                        text={item.props.titleText}
720
                        selected={item.props.id === selectedSubSectionId || undefined}
209,672✔
721
                        data-index={index}
722
                      >
723
                        {/*ToDo: workaround for nested tab selection*/}
724
                        <span style={{ display: 'none' }} />
725
                      </Tab>
726
                    );
727
                  })}
728
                >
729
                  {/*ToDo: workaround for nested tab selection*/}
730
                  <span style={{ display: 'none' }} />
731
                </Tab>
732
              );
733
            })}
734
          </TabContainer>
735
        </div>
736
      )}
737
      <div
738
        data-component-name="ObjectPageContent"
739
        className={classNames.content}
740
        ref={objectPageContentRef}
741
        // prevent content scroll when elements outside the content are focused
742
        onFocus={() => {
743
          const opNode = objectPageRef.current;
294✔
744
          if (opNode) {
294✔
745
            // 12px or 0.75rem margin for ui5wc border and input margins
746
            opNode.style.scrollPaddingBlock = `${Math.ceil(12 + topHeaderHeight + TAB_CONTAINER_HEADER_HEIGHT + (!headerCollapsed && headerPinned ? headerContentHeight : 0))}px ${footerArea ? 'calc(var(--_ui5wcr-BarHeight) + 1.25rem)' : 0}`;
294!
747
          }
748
        }}
749
        onBlur={(e) => {
750
          const opNode = objectPageRef.current;
×
751
          if (opNode && !e.currentTarget.contains(e.relatedTarget as Node)) {
×
752
            opNode.style.scrollPaddingBlock = '0px';
×
753
          }
754
        }}
755
      >
756
        <div
757
          style={{
758
            height:
142,378✔
759
              ((headerCollapsed && !headerPinned) || scrolledHeaderExpanded) && !toggledCollapsedHeaderWasVisible
760
                ? `${headerContentHeight}px`
761
                : 0,
762
          }}
763
          aria-hidden="true"
764
        />
765
        {placeholder ? placeholder : sections}
42,088✔
766
        <div style={{ height: `${sectionSpacer}px` }} aria-hidden="true" />
767
      </div>
768
      {footerArea && mode === ObjectPageMode.IconTabBar && !sectionSpacer && (
52,850✔
769
        <div className={classNames.footerSpacer} data-component-name="ObjectPageFooterSpacer" aria-hidden="true" />
770
      )}
771
      {footerArea && (
48,794✔
772
        <footer
773
          role={accessibilityAttributes?.objectPageFooterArea?.role}
774
          className={classNames.footer}
775
          data-component-name="ObjectPageFooter"
776
        >
777
          {footerArea}
778
        </footer>
779
      )}
780
    </div>
781
  );
782
});
783

784
ObjectPage.displayName = 'ObjectPage';
446✔
785

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