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

SAP / ui5-webcomponents-react / 15278586233

27 May 2025 02:52PM CUT coverage: 89.049% (-0.05%) from 89.094%
15278586233

Pull #7388

github

web-flow
Merge abfb7279c into 1d5031bcd
Pull Request #7388: fix(AnalyticalTable): prevent error if `onFilter` is not defined

3027 of 3929 branches covered (77.04%)

6 of 6 new or added lines in 2 files covered. (100.0%)

3 existing lines in 1 file now uncovered.

5302 of 5954 relevant lines covered (89.05%)

96351.62 hits per line

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

94.6
/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 { ObjectPageDomRef, ObjectPagePropTypes, ObjectPageTitlePropsWithDataAttributes } from './types/index.js';
29
import { useHandleTabSelect } from './useHandleTabSelect.js';
30

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

36
const TAB_CONTAINER_HEADER_HEIGHT = 44 + 4; // tabbar height + custom 4px padding-block-start
444✔
37

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

68
  useStylesheet(styleData, ObjectPage.displayName);
37,404✔
69

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

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

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

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

108
  const prevInternalSelectedSectionId = useRef(internalSelectedSectionId);
37,404✔
109
  const fireOnSelectedChangedEvent = (targetEvent, index, id, section) => {
37,404✔
110
    if (typeof onSelectedSectionChange === 'function' && targetEvent && prevInternalSelectedSectionId.current !== id) {
979✔
111
      onSelectedSectionChange(
259✔
112
        enrichEventWithDetails(targetEvent, {
113
          selectedSectionIndex: parseInt(index, 10),
114
          selectedSectionId: id,
115
          section,
116
        }),
117
      );
118
      prevInternalSelectedSectionId.current = id;
259✔
119
    }
120
  };
121
  const debouncedOnSectionChange = useRef(debounce(fireOnSelectedChangedEvent, 500)).current;
37,404✔
122
  useEffect(() => {
37,404✔
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, totalHeaderHeight, headerCollapsed } = useObserveHeights(
37,404✔
131
    objectPageRef,
132
    topHeaderRef,
133
    headerContentRef,
134
    [headerCollapsedInternal, setHeaderCollapsedInternal],
135
    {
136
      noHeader: !titleArea && !headerArea,
38,714✔
137
      fixedHeader: headerPinned,
138
      scrollTimeout,
139
    },
140
  );
141

142
  useEffect(() => {
37,404✔
143
    if (typeof onToggleHeaderArea === 'function' && isToggledRef.current) {
3,641✔
144
      onToggleHeaderArea(headerCollapsed !== true);
252✔
145
    }
146
  }, [headerCollapsed]);
147

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

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

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

187
  const scrollToSectionById = (id: string | undefined, isSubSection = false) => {
37,404✔
188
    const scroll = () => {
662✔
189
      const section = getSectionElementById(objectPageRef.current, isSubSection, id);
661✔
190
      scrollTimeout.current = performance.now() + 500;
661✔
191
      if (section) {
661✔
192
        const safeTopHeaderHeight = topHeaderHeight || prevTopHeaderHeight.current;
643✔
193

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

204
        const sectionRect = section.getBoundingClientRect();
643✔
205
        const objectPageElement = objectPageRef.current;
643✔
206
        const objectPageRect = objectPageElement.getBoundingClientRect();
643✔
207

208
        // Calculate the top position of the section relative to the container
209
        objectPageElement.scrollTop = sectionRect.top - objectPageRect.top + objectPageElement.scrollTop - scrollMargin;
643✔
210

211
        section.style.scrollMarginBlockStart = '';
643✔
212
      }
213
    };
214
    // In TabBar mode the section is only rendered when selected: delay scroll for subsection
215
    if (mode === ObjectPageMode.IconTabBar && isSubSection) {
662✔
216
      setTimeout(scroll, 300);
89✔
217
    } else {
218
      scroll();
573✔
219
    }
220
  };
221

222
  const scrollToSection = (sectionId?: string) => {
37,404✔
223
    if (!sectionId) {
599!
224
      return;
×
225
    }
226
    if (firstSectionId === sectionId) {
599✔
227
      objectPageRef.current?.scrollTo({ top: 0 });
172✔
228
    } else {
229
      scrollToSectionById(sectionId);
427✔
230
    }
231
    isProgrammaticallyScrolled.current = false;
599✔
232
  };
233

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

249
  useIsomorphicLayoutEffect(() => {
37,404✔
250
    if (selectedSectionId) {
2,270✔
251
      const fireSelectEvent = () => {
84✔
252
        const selectedSection = getSectionElementById(objectPageRef.current, false, selectedSectionId);
84✔
253
        if (selectedSection) {
84✔
254
          const selectedSectionIndex = Array.from(
84✔
255
            selectedSection.parentElement.querySelectorAll(':scope > [data-component-name="ObjectPageSection"]'),
256
          ).indexOf(selectedSection);
257
          handleOnSectionSelected({}, selectedSectionId, selectedSectionIndex, selectedSection);
84✔
258
        }
259
      };
260
      if (mode === ObjectPageMode.IconTabBar) {
84✔
261
        setInternalSelectedSectionId(selectedSectionId);
36✔
262
        // In TabBar mode the section is only rendered when selected, therefore delay firing the event until the section is available in the DOM
263
        setTimeout(fireSelectEvent);
36✔
264
      } else {
265
        fireSelectEvent();
48✔
266
      }
267
    }
268
  }, [selectedSectionId, mode]);
269

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

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

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

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

305
  const isInitialTabBarMode = useRef(true);
37,404✔
306
  useEffect(() => {
37,404✔
307
    if (!isMounted) {
3,377✔
308
      requestAnimationFrame(() => setIsMounted(true));
2,242✔
309
      return;
2,242✔
310
    }
311

312
    setSelectedSubSectionId(props.selectedSubSectionId);
1,135✔
313
    if (props.selectedSubSectionId) {
1,135✔
314
      isProgrammaticallyScrolled.current = true;
56✔
315
      if (mode === ObjectPageMode.IconTabBar) {
56✔
316
        let sectionId: string;
317
        let curSection: ReactElement;
318
        let sectionIndex: number = -1;
24✔
319
        childrenArray.forEach((section, index) => {
24✔
320
          if (isValidElement(section) && section.props && section.props.children) {
96✔
321
            safeGetChildrenArray(section.props.children).forEach((subSection) => {
96✔
322
              if (
168✔
323
                isValidElement(subSection) &&
504✔
324
                subSection.props &&
325
                (subSection as ReactElement<ObjectPageSubSectionPropTypes>).props.id === props.selectedSubSectionId
326
              ) {
327
                curSection = section;
24✔
328
                sectionId = section.props?.id;
24✔
329
                sectionIndex = index;
24✔
330
              }
331
            });
332
          }
333
        });
334
        if (sectionId) {
24✔
335
          if (!isInitialTabBarMode.current) {
24✔
336
            //In TabBar mode the section is often not scrolled when subsection changes, thus the onSelectedSectionChange isn't fired
337
            debouncedOnSectionChange({}, sectionIndex, sectionId, curSection);
12✔
338
          }
339
          setInternalSelectedSectionId(sectionId);
24✔
340
        }
341
      }
342
    }
343
    isInitialTabBarMode.current = false;
1,135✔
344
  }, [props.selectedSubSectionId, isMounted]);
345

346
  const tabContainerContainerRef = useRef(null);
37,404✔
347
  const isHeaderPinnedAndExpanded = headerPinned && !headerCollapsed;
37,404✔
348
  useEffect(() => {
37,404✔
349
    const objectPage = objectPageRef.current;
8,396✔
350
    const tabContainerContainer = tabContainerContainerRef.current;
8,396✔
351

352
    if (!objectPage || !tabContainerContainer) {
8,396✔
353
      return;
159✔
354
    }
355

356
    const footerElement = objectPage.querySelector<HTMLDivElement>('[data-component-name="ObjectPageFooter"]');
8,237✔
357
    const topHeaderElement = objectPage.querySelector('[data-component-name="ObjectPageTopHeader"]');
8,237✔
358

359
    const calculateSpacer = ([lastSectionNodeEntry]: ResizeObserverEntry[]) => {
8,237✔
360
      const lastSectionNode = lastSectionNodeEntry?.target;
6,548✔
361

362
      if (!lastSectionNode) {
6,548!
363
        setSectionSpacer(0);
×
364
        return;
×
365
      }
366

367
      const subSections = lastSectionNode.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSubSection"]');
6,548✔
368
      const lastSubSection = subSections[subSections.length - 1];
6,548✔
369
      const lastSubSectionOrSection = lastSubSection ?? lastSectionNode;
6,548✔
370

371
      if ((currentTabModeSection && !lastSubSection) || (sectionNodes.length === 1 && !lastSubSection)) {
6,548✔
372
        setSectionSpacer(0);
3,579✔
373
        return;
3,579✔
374
      }
375

376
      // batching DOM-reads together minimizes reflow
377
      const footerHeight = footerElement?.offsetHeight ?? 0;
2,969✔
378
      const objectPageRect = objectPage.getBoundingClientRect();
2,969✔
379
      const tabContainerContainerRect = tabContainerContainer.getBoundingClientRect();
2,969✔
380
      const lastSubSectionOrSectionRect = lastSubSectionOrSection.getBoundingClientRect();
2,969✔
381

382
      let stickyHeaderBottom = 0;
2,969✔
383
      if (!isHeaderPinnedAndExpanded) {
2,969!
384
        const topHeaderBottom = topHeaderElement?.getBoundingClientRect().bottom ?? 0;
2,969!
385
        stickyHeaderBottom = topHeaderBottom + tabContainerContainerRect.height;
2,969✔
386
      } else {
387
        stickyHeaderBottom = tabContainerContainerRect.bottom;
×
388
      }
389

390
      const spacer = Math.ceil(
2,969✔
391
        objectPageRect.bottom - stickyHeaderBottom - lastSubSectionOrSectionRect.height - footerHeight, // section padding (8px) not included, so that the intersection observer is triggered correctly
392
      );
393
      setSectionSpacer(Math.max(spacer, 0));
2,969✔
394
    };
395

396
    const observer = new ResizeObserver(calculateSpacer);
8,237✔
397
    const sectionNodes = objectPage.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSection"]');
8,237✔
398
    const lastSectionNode = sectionNodes[sectionNodes.length - 1];
8,237✔
399

400
    if (lastSectionNode) {
8,237✔
401
      observer.observe(lastSectionNode, { box: 'border-box' });
7,192✔
402
    }
403

404
    return () => {
8,237✔
405
      observer.disconnect();
8,201✔
406
    };
407
  }, [topHeaderHeight, headerContentHeight, currentTabModeSection, children, mode, isHeaderPinnedAndExpanded]);
408

409
  const onToggleHeaderContentVisibility = (e) => {
37,404✔
410
    isToggledRef.current = true;
974✔
411
    scrollTimeout.current = performance.now() + 500;
974✔
412
    setToggledCollapsedHeaderWasVisible(false);
974✔
413
    if (!e.detail.visible) {
974✔
414
      if (objectPageRef.current.scrollTop <= headerContentHeight) {
530✔
415
        setToggledCollapsedHeaderWasVisible(true);
263✔
416
        if (firstSectionId === internalSelectedSectionId || mode === ObjectPageMode.IconTabBar) {
263!
417
          objectPageRef.current.scrollTop = 0;
263✔
418
        }
419
      }
420
      setHeaderCollapsedInternal(true);
530✔
421
      setScrolledHeaderExpanded(false);
530✔
422
    } else {
423
      setHeaderCollapsedInternal(false);
444✔
424
      if (objectPageRef.current.scrollTop >= headerContentHeight) {
444✔
425
        setScrolledHeaderExpanded(true);
208✔
426
      }
427
    }
428
  };
429

430
  const { onScroll: _0, selectedSubSectionId: _1, ...propsWithoutOmitted } = rest;
37,404✔
431

432
  const visibleSectionIds = useRef<Set<string>>(new Set());
37,404✔
433
  useEffect(() => {
37,404✔
434
    // section observers are not required in TabBar mode
435
    if (mode === ObjectPageMode.IconTabBar) {
7,451✔
436
      return;
2,750✔
437
    }
438
    const sectionNodes = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
4,701✔
439
    // only the sticky part of the header must be added as margin
440
    const rootMargin = `-${((headerPinned && !headerCollapsed) || scrolledHeaderExpanded ? totalHeaderHeight : topHeaderHeight) + TAB_CONTAINER_HEADER_HEIGHT}px 0px 0px 0px`;
4,701✔
441

442
    const observer = new IntersectionObserver(
4,701✔
443
      (entries) => {
444
        entries.forEach((entry) => {
4,033✔
445
          const sectionId = entry.target.id;
18,763✔
446
          if (entry.isIntersecting) {
18,763✔
447
            visibleSectionIds.current.add(sectionId);
5,823✔
448
          } else {
449
            visibleSectionIds.current.delete(sectionId);
12,940✔
450
          }
451

452
          let currentIndex: undefined | number;
453
          const sortedVisibleSections = Array.from(sectionNodes).filter((section, index) => {
18,763✔
454
            const isVisibleSection = visibleSectionIds.current.has(section.id);
199,579✔
455
            if (currentIndex === undefined && isVisibleSection) {
199,579✔
456
              currentIndex = index;
17,918✔
457
            }
458
            return visibleSectionIds.current.has(section.id);
199,579✔
459
          });
460

461
          if (sortedVisibleSections.length > 0) {
18,763✔
462
            const section = sortedVisibleSections[0];
17,918✔
463
            const id = sortedVisibleSections[0].id.slice(18);
17,918✔
464
            setInternalSelectedSectionId(id);
17,918✔
465
            debouncedOnSectionChange(scrollEvent.current, currentIndex, id, section);
17,918✔
466
          }
467
        });
468
      },
469
      {
470
        root: objectPageRef.current,
471
        rootMargin,
472
        threshold: [0],
473
      },
474
    );
475
    sectionNodes.forEach((el) => {
4,701✔
476
      observer.observe(el);
19,549✔
477
    });
478

479
    return () => {
4,701✔
480
      observer.disconnect();
4,676✔
481
    };
482
  }, [
483
    totalHeaderHeight,
484
    headerPinned,
485
    headerCollapsed,
486
    topHeaderHeight,
487
    childrenArray.length,
488
    scrolledHeaderExpanded,
489
    mode,
490
  ]);
491

492
  const onTitleClick = (e) => {
37,404✔
493
    e.stopPropagation();
252✔
494
    if (!preserveHeaderStateOnClick) {
252✔
495
      onToggleHeaderContentVisibility(enrichEventWithDetails(e, { visible: headerCollapsed }));
126✔
496
    }
497
  };
498

499
  const renderHeaderContentSection = () => {
37,404✔
500
    if (headerArea?.props) {
37,404✔
501
      return cloneElement(headerArea as ReactElement<ObjectPageHeaderPropTypesWithInternals>, {
36,006✔
502
        ...headerArea.props,
503
        topHeaderHeight,
504
        style:
505
          headerCollapsed === true
36,006✔
506
            ? { ...headerArea.props.style, position: 'absolute', visibility: 'hidden', flexShrink: 0, insetInline: 0 }
507
            : { ...headerArea.props.style, flexShrink: 0 },
508
        headerPinned: headerPinned || scrolledHeaderExpanded,
69,742✔
509
        //@ts-expect-error: todo remove me when forwardref has been replaced
510
        ref: componentRefHeaderContent,
511
        children: (
512
          <div
513
            className={clsx(classNames.headerContainer, avatar && classNames.hasAvatar)}
36,270✔
514
            data-component-name="ObjectPageHeaderContainer"
515
          >
516
            {avatar}
517
            {headerArea.props.children && (
72,012✔
518
              <div data-component-name="ObjectPageHeaderContent">{headerArea.props.children}</div>
519
            )}
520
          </div>
521
        ),
522
      });
523
    }
524
  };
525

526
  const prevScrollTop = useRef(undefined);
37,404✔
527
  const onObjectPageScroll: UIEventHandler<HTMLDivElement> = useCallback(
37,404✔
528
    (e) => {
529
      const target = e.target as HTMLDivElement;
10,741✔
530
      if (!isToggledRef.current) {
10,741✔
531
        isToggledRef.current = true;
580✔
532
      }
533
      if (scrollTimeout.current >= performance.now()) {
10,741✔
534
        return;
6,309✔
535
      }
536
      setToggledCollapsedHeaderWasVisible(false);
4,432✔
537
      scrollEvent.current = e;
4,432✔
538
      if (typeof props.onScroll === 'function') {
4,432!
539
        props.onScroll(e);
×
540
      }
541
      if (selectedSubSectionId) {
4,432!
542
        setSelectedSubSectionId(undefined);
×
543
      }
544
      if (selectionScrollTimeout.current) {
4,432!
545
        clearTimeout(selectionScrollTimeout.current);
×
546
      }
547
      if (!headerPinned || target.scrollTop === 0) {
4,432✔
548
        objectPageRef.current?.classList.remove(classNames.headerCollapsed);
3,889✔
549
      }
550
      if (scrolledHeaderExpanded && target.scrollTop !== prevScrollTop.current) {
4,432✔
551
        if (target.scrollHeight - target.scrollTop === target.clientHeight) {
110!
552
          return;
×
553
        }
554
        prevScrollTop.current = target.scrollTop;
110✔
555
        if (!headerPinned) {
110✔
556
          setHeaderCollapsedInternal(true);
55✔
557
        }
558
        setScrolledHeaderExpanded(false);
110✔
559
      }
560
    },
561
    [topHeaderHeight, headerPinned, props.onScroll, scrolledHeaderExpanded, selectedSubSectionId],
562
  );
563

564
  const onHoverToggleButton: MouseEventHandler<HTMLHeadElement> = useCallback((e) => {
37,404✔
565
    if (e.type === 'mouseover') {
787✔
566
      topHeaderRef.current?.classList.add(classNames.headerHoverStyles);
583✔
567
    } else {
568
      topHeaderRef.current?.classList.remove(classNames.headerHoverStyles);
204✔
569
    }
570
  }, []);
571

572
  const handleTabSelect = useHandleTabSelect({
37,404✔
573
    onBeforeNavigate,
574
    headerPinned,
575
    mode,
576
    setHeaderCollapsedInternal,
577
    setScrolledHeaderExpanded,
578
    childrenArray,
579
    handleOnSectionSelected,
580
    isProgrammaticallyScrolled,
581
    setInternalSelectedSectionId,
582
    objectPageRef,
583
    debouncedOnSectionChange,
584
    scrollTimeout,
585
    setSelectedSubSectionId,
586
  });
587

588
  const objectPageStyles: CSSProperties = {
37,404✔
589
    ...style,
590
  };
591
  if (headerCollapsed === true && headerArea) {
37,404✔
592
    objectPageStyles[ObjectPageCssVariables.titleFontSize] = ThemingParameters.sapObjectHeader_Title_SnappedFontSize;
11,410✔
593
  }
594

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

770
ObjectPage.displayName = 'ObjectPage';
444✔
771

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