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

SAP / ui5-webcomponents-react / 14304969491

07 Apr 2025 08:53AM CUT coverage: 87.54% (+0.08%) from 87.46%
14304969491

Pull #7187

github

web-flow
Merge f9635f7ca into 8d652b4c7
Pull Request #7187: feat(ObjectPage): allow customizing `role` of `footerArea` container

2966 of 3932 branches covered (75.43%)

5185 of 5923 relevant lines covered (87.54%)

84562.43 hits per line

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

91.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 = {
427✔
31
  headerDisplay: '--_ui5wcr_ObjectPage_header_display',
32
  titleFontSize: '--_ui5wcr_ObjectPage_title_fontsize'
33
};
34

35
const TAB_CONTAINER_HEADER_HEIGHT = 44;
427✔
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) => {
427✔
43
  const {
44
    titleArea,
45
    image,
46
    footerArea,
47
    mode = ObjectPageMode.Default,
12,732✔
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;
19,320✔
66

67
  useStylesheet(styleData, ObjectPage.displayName);
19,320✔
68

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

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

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

102
  useEffect(() => {
19,320✔
103
    const currentSection =
104
      mode === ObjectPageMode.IconTabBar ? getSectionById(children, internalSelectedSectionId) : null;
3,139✔
105
    setCurrentTabModeSection(currentSection);
3,139✔
106
  }, [mode, children, internalSelectedSectionId]);
107

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

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

144
  useEffect(() => {
19,320✔
145
    if (typeof onToggleHeaderArea === 'function' && isToggledRef.current) {
1,901✔
146
      onToggleHeaderArea(headerCollapsed !== true);
88✔
147
    }
148
  }, [headerCollapsed]);
149

150
  useEffect(() => {
19,320✔
151
    const objectPageNode = objectPageRef.current;
1,901✔
152
    if (objectPageNode) {
1,901✔
153
      Object.assign(objectPageNode, {
1,901✔
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(() => {
19,320✔
166
    if (!image) {
1,232✔
167
      return null;
1,170✔
168
    }
169

170
    if (typeof image === 'string') {
62✔
171
      return (
36✔
172
        <span
173
          className={classNames.headerImage}
174
          style={{ borderRadius: imageShapeCircle ? '50%' : 0, overflow: 'hidden' }}
36✔
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, {
26✔
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) => {
19,320✔
190
    const scroll = () => {
433✔
191
      const section = getSectionElementById(objectPageRef.current, isSubSection, id);
432✔
192
      scrollTimeout.current = performance.now() + 500;
432✔
193
      if (section) {
432✔
194
        const safeTopHeaderHeight = topHeaderHeight || prevTopHeaderHeight.current;
419✔
195

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

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

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

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

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

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

252
  useEffect(() => {
19,320✔
253
    if (selectedSectionId) {
1,244✔
254
      const selectedSection = getSectionElementById(objectPageRef.current, false, selectedSectionId);
36✔
255
      if (selectedSection) {
36✔
256
        const selectedSectionIndex = Array.from(
36✔
257
          selectedSection.parentElement.querySelectorAll(':scope > [data-component-name="ObjectPageSection"]')
258
        ).indexOf(selectedSection);
259
        handleOnSectionSelected({}, selectedSectionId, selectedSectionIndex, selectedSection);
36✔
260
      }
261
    }
262
  }, [selectedSectionId]);
263

264
  // do internal scrolling
265
  useEffect(() => {
19,320✔
266
    if (mode === ObjectPageMode.Default && isProgrammaticallyScrolled.current === true && !selectedSubSectionId) {
1,952✔
267
      scrollToSection(internalSelectedSectionId);
219✔
268
    }
269
  }, [internalSelectedSectionId, mode, selectedSubSectionId]);
270

271
  // Scrolling for Sub Section Selection
272
  useEffect(() => {
19,320✔
273
    if (selectedSubSectionId && isProgrammaticallyScrolled.current === true) {
2,790✔
274
      scrollToSectionById(selectedSubSectionId, true);
156✔
275
      isProgrammaticallyScrolled.current = false;
156✔
276
    }
277
  }, [selectedSubSectionId, isProgrammaticallyScrolled.current, sectionSpacer]);
278

279
  useEffect(() => {
19,320✔
280
    if (headerPinnedProp !== undefined) {
1,372✔
281
      setHeaderPinned(headerPinnedProp);
180✔
282
    }
283
    if (headerPinnedProp) {
1,372✔
284
      onToggleHeaderContentVisibility({ detail: { visible: true } });
80✔
285
    }
286
  }, [headerPinnedProp]);
287

288
  const prevHeaderPinned = useRef(headerPinned);
19,320✔
289
  useEffect(() => {
19,320✔
290
    if (prevHeaderPinned.current && !headerPinned && objectPageRef.current.scrollTop > topHeaderHeight) {
2,281✔
291
      onToggleHeaderContentVisibility({ detail: { visible: false } });
81✔
292
      prevHeaderPinned.current = false;
81✔
293
    }
294
    if (!prevHeaderPinned.current && headerPinned) {
2,281✔
295
      prevHeaderPinned.current = true;
101✔
296
    }
297
  }, [headerPinned, topHeaderHeight]);
298

299
  useEffect(() => {
19,320✔
300
    if (!isMounted) {
1,851✔
301
      setIsMounted(true);
1,232✔
302
      return;
1,232✔
303
    }
304
    setSelectedSubSectionId(props.selectedSubSectionId);
619✔
305
    if (props.selectedSubSectionId) {
619✔
306
      isProgrammaticallyScrolled.current = true;
24✔
307
      if (mode === ObjectPageMode.IconTabBar) {
24!
308
        let sectionId: string;
309
        childrenArray.forEach((section) => {
×
310
          if (isValidElement(section) && section.props && section.props.children) {
×
311
            safeGetChildrenArray(section.props.children).forEach((subSection) => {
×
312
              if (
×
313
                isValidElement(subSection) &&
×
314
                subSection.props &&
315
                (subSection as ReactElement<ObjectPageSubSectionPropTypes>).props.id === props.selectedSubSectionId
316
              ) {
317
                sectionId = section.props?.id;
×
318
              }
319
            });
320
          }
321
        });
322
        if (sectionId) {
×
323
          setInternalSelectedSectionId(sectionId);
×
324
        }
325
      }
326
    }
327
  }, [props.selectedSubSectionId, isMounted]);
328

329
  const tabContainerContainerRef = useRef(null);
19,320✔
330
  const isHeaderPinnedAndExpanded = headerPinned && !headerCollapsed;
19,320✔
331
  useEffect(() => {
19,320✔
332
    const objectPage = objectPageRef.current;
4,112✔
333
    const tabContainerContainer = tabContainerContainerRef.current;
4,112✔
334

335
    if (!objectPage || !tabContainerContainer) {
4,112✔
336
      return;
40✔
337
    }
338

339
    const footerElement = objectPage.querySelector<HTMLDivElement>('[data-component-name="ObjectPageFooter"]');
4,072✔
340
    const topHeaderElement = objectPage.querySelector('[data-component-name="ObjectPageTopHeader"]');
4,072✔
341

342
    const calculateSpacer = ([lastSectionNodeEntry]: ResizeObserverEntry[]) => {
4,072✔
343
      const lastSectionNode = lastSectionNodeEntry?.target;
3,246✔
344

345
      if (!lastSectionNode) {
3,246!
346
        setSectionSpacer(0);
×
347
        return;
×
348
      }
349

350
      const subSections = lastSectionNode.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSubSection"]');
3,246✔
351
      const lastSubSection = subSections[subSections.length - 1];
3,246✔
352
      const lastSubSectionOrSection = lastSubSection ?? lastSectionNode;
3,246✔
353

354
      if ((currentTabModeSection && !lastSubSection) || (sectionNodes.length === 1 && !lastSubSection)) {
3,246✔
355
        setSectionSpacer(0);
1,674✔
356
        return;
1,674✔
357
      }
358

359
      // batching DOM-reads together minimizes reflow
360
      const footerHeight = footerElement?.offsetHeight ?? 0;
1,572✔
361
      const objectPageRect = objectPage.getBoundingClientRect();
1,572✔
362
      const tabContainerContainerRect = tabContainerContainer.getBoundingClientRect();
1,572✔
363
      const lastSubSectionOrSectionRect = lastSubSectionOrSection.getBoundingClientRect();
1,572✔
364

365
      let stickyHeaderBottom = 0;
1,572✔
366
      if (!isHeaderPinnedAndExpanded) {
1,572!
367
        const topHeaderBottom = topHeaderElement?.getBoundingClientRect().bottom ?? 0;
1,572!
368
        stickyHeaderBottom = topHeaderBottom + tabContainerContainerRect.height;
1,572✔
369
      } else {
370
        stickyHeaderBottom = tabContainerContainerRect.bottom;
×
371
      }
372

373
      const spacer = Math.ceil(
1,572✔
374
        objectPageRect.bottom - stickyHeaderBottom - lastSubSectionOrSectionRect.height - footerHeight // section padding (8px) not included, so that the intersection observer is triggered correctly
375
      );
376
      setSectionSpacer(Math.max(spacer, 0));
1,572✔
377
    };
378

379
    const observer = new ResizeObserver(calculateSpacer);
4,072✔
380
    const sectionNodes = objectPage.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSection"]');
4,072✔
381
    const lastSectionNode = sectionNodes[sectionNodes.length - 1];
4,072✔
382

383
    if (lastSectionNode) {
4,072✔
384
      observer.observe(lastSectionNode, { box: 'border-box' });
3,664✔
385
    }
386

387
    return () => {
4,072✔
388
      observer.disconnect();
4,046✔
389
    };
390
  }, [topHeaderHeight, headerContentHeight, currentTabModeSection, children, mode, isHeaderPinnedAndExpanded]);
391

392
  const onToggleHeaderContentVisibility = (e) => {
19,320✔
393
    isToggledRef.current = true;
412✔
394
    scrollTimeout.current = performance.now() + 500;
412✔
395
    setToggledCollapsedHeaderWasVisible(false);
412✔
396
    if (!e.detail.visible) {
412✔
397
      if (objectPageRef.current.scrollTop <= headerContentHeight) {
237✔
398
        setToggledCollapsedHeaderWasVisible(true);
125✔
399
        if (firstSectionId === internalSelectedSectionId || mode === ObjectPageMode.IconTabBar) {
125!
400
          objectPageRef.current.scrollTop = 0;
125✔
401
        }
402
      }
403
      setHeaderCollapsedInternal(true);
237✔
404
      setScrolledHeaderExpanded(false);
237✔
405
    } else {
406
      setHeaderCollapsedInternal(false);
175✔
407
      if (objectPageRef.current.scrollTop >= headerContentHeight) {
175✔
408
        setScrolledHeaderExpanded(true);
91✔
409
      }
410
    }
411
  };
412

413
  const { onScroll: _0, selectedSubSectionId: _1, ...propsWithoutOmitted } = rest;
19,320✔
414

415
  const visibleSectionIds = useRef<Set<string>>(new Set());
19,320✔
416
  useEffect(() => {
19,320✔
417
    const sectionNodes = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
3,606✔
418
    // only the sticky part of the header must be added as margin
419
    const rootMargin = `-${((headerPinned && !headerCollapsed) || scrolledHeaderExpanded ? totalHeaderHeight : topHeaderHeight) + TAB_CONTAINER_HEADER_HEIGHT}px 0px 0px 0px`;
3,606✔
420

421
    const observer = new IntersectionObserver(
3,606✔
422
      (entries) => {
423
        entries.forEach((entry) => {
2,919✔
424
          const sectionId = entry.target.id;
12,918✔
425
          if (entry.isIntersecting) {
12,918✔
426
            visibleSectionIds.current.add(sectionId);
4,105✔
427
          } else {
428
            visibleSectionIds.current.delete(sectionId);
8,813✔
429
          }
430

431
          let currentIndex: undefined | number;
432
          const sortedVisibleSections = Array.from(sectionNodes).filter((section, index) => {
12,918✔
433
            const isVisibleSection = visibleSectionIds.current.has(section.id);
136,823✔
434
            if (currentIndex === undefined && isVisibleSection) {
136,823✔
435
              currentIndex = index;
12,498✔
436
            }
437
            return visibleSectionIds.current.has(section.id);
136,823✔
438
          });
439

440
          if (sortedVisibleSections.length > 0) {
12,918✔
441
            const section = sortedVisibleSections[0];
12,498✔
442
            const id = sortedVisibleSections[0].id.slice(18);
12,498✔
443
            setInternalSelectedSectionId(id);
12,498✔
444
            debouncedOnSectionChange(scrollEvent.current, currentIndex, id, section);
12,498✔
445
          }
446
        });
447
      },
448
      {
449
        root: objectPageRef.current,
450
        rootMargin,
451
        threshold: [0]
452
      }
453
    );
454
    sectionNodes.forEach((el) => {
3,606✔
455
      observer.observe(el);
13,525✔
456
    });
457

458
    return () => {
3,606✔
459
      observer.disconnect();
3,579✔
460
    };
461
  }, [totalHeaderHeight, headerPinned, headerCollapsed, topHeaderHeight, childrenArray.length, scrolledHeaderExpanded]);
462

463
  const onTitleClick = (e) => {
19,320✔
464
    e.stopPropagation();
88✔
465
    if (!preserveHeaderStateOnClick) {
88✔
466
      onToggleHeaderContentVisibility(enrichEventWithDetails(e, { visible: headerCollapsed }));
44✔
467
    }
468
  };
469

470
  const renderHeaderContentSection = () => {
19,320✔
471
    if (headerArea?.props) {
19,320✔
472
      return cloneElement(headerArea as ReactElement<ObjectPageHeaderPropTypesWithInternals>, {
18,866✔
473
        ...headerArea.props,
474
        topHeaderHeight,
475
        style:
476
          headerCollapsed === true
18,866✔
477
            ? { ...headerArea.props.style, position: 'absolute', visibility: 'hidden', flexShrink: 0, insetInline: 0 }
478
            : { ...headerArea.props.style, flexShrink: 0 },
479
        headerPinned: headerPinned || scrolledHeaderExpanded,
36,888✔
480
        //@ts-expect-error: todo remove me when forwardref has been replaced
481
        ref: componentRefHeaderContent,
482
        children: (
483
          <div
484
            className={clsx(classNames.headerContainer, avatar && classNames.hasAvatar)}
19,010✔
485
            data-component-name="ObjectPageHeaderContainer"
486
          >
487
            {avatar}
488
            {headerArea.props.children && (
37,732✔
489
              <div data-component-name="ObjectPageHeaderContent">{headerArea.props.children}</div>
490
            )}
491
          </div>
492
        )
493
      });
494
    }
495
  };
496

497
  const prevScrollTop = useRef(undefined);
19,320✔
498
  const onObjectPageScroll: UIEventHandler<HTMLDivElement> = useCallback(
19,320✔
499
    (e) => {
500
      const target = e.target as HTMLDivElement;
4,775✔
501
      if (!isToggledRef.current) {
4,775✔
502
        isToggledRef.current = true;
345✔
503
      }
504
      if (scrollTimeout.current >= performance.now()) {
4,775✔
505
        return;
2,692✔
506
      }
507
      setToggledCollapsedHeaderWasVisible(false);
2,083✔
508
      scrollEvent.current = e;
2,083✔
509
      if (typeof props.onScroll === 'function') {
2,083!
510
        props.onScroll(e);
×
511
      }
512
      if (selectedSubSectionId) {
2,083!
513
        setSelectedSubSectionId(undefined);
×
514
      }
515
      if (selectionScrollTimeout.current) {
2,083!
516
        clearTimeout(selectionScrollTimeout.current);
×
517
      }
518
      if (!headerPinned || target.scrollTop === 0) {
2,083✔
519
        objectPageRef.current?.classList.remove(classNames.headerCollapsed);
1,877✔
520
      }
521
      if (scrolledHeaderExpanded && target.scrollTop !== prevScrollTop.current) {
2,083✔
522
        if (target.scrollHeight - target.scrollTop === target.clientHeight) {
40!
523
          return;
×
524
        }
525
        prevScrollTop.current = target.scrollTop;
40✔
526
        if (!headerPinned) {
40✔
527
          setHeaderCollapsedInternal(true);
20✔
528
        }
529
        setScrolledHeaderExpanded(false);
40✔
530
      }
531
    },
532
    [topHeaderHeight, headerPinned, props.onScroll, scrolledHeaderExpanded, selectedSubSectionId]
533
  );
534

535
  const onHoverToggleButton: MouseEventHandler<HTMLHeadElement> = useCallback((e) => {
19,320✔
536
    if (e.type === 'mouseover') {
362✔
537
      topHeaderRef.current?.classList.add(classNames.headerHoverStyles);
258✔
538
    } else {
539
      topHeaderRef.current?.classList.remove(classNames.headerHoverStyles);
104✔
540
    }
541
  }, []);
542

543
  const handleTabSelect = useHandleTabSelect({
19,320✔
544
    onBeforeNavigate,
545
    headerPinned,
546
    mode,
547
    setHeaderCollapsedInternal,
548
    setScrolledHeaderExpanded,
549
    childrenArray,
550
    handleOnSectionSelected,
551
    isProgrammaticallyScrolled,
552
    setInternalSelectedSectionId,
553
    objectPageRef,
554
    debouncedOnSectionChange,
555
    scrollTimeout,
556
    setSelectedSubSectionId
557
  });
558

559
  const objectPageStyles: CSSProperties = {
19,320✔
560
    ...style
561
  };
562
  if (headerCollapsed === true && headerArea) {
19,320✔
563
    objectPageStyles[ObjectPageCssVariables.titleFontSize] = ThemingParameters.sapObjectHeader_Title_SnappedFontSize;
6,720✔
564
  }
565

566
  return (
19,320✔
567
    <div
568
      ref={componentRef}
569
      data-component-name="ObjectPage"
570
      slot={slot}
571
      className={clsx(
572
        classNames.objectPage,
573
        className,
574
        mode === ObjectPageMode.IconTabBar && classNames.iconTabBarMode
23,560✔
575
      )}
576
      style={objectPageStyles}
577
      onScroll={onObjectPageScroll}
578
      data-in-iframe={window && window.self !== window.top}
38,640✔
579
      {...propsWithoutOmitted}
580
    >
581
      <header
582
        onMouseOver={onHoverToggleButton}
583
        onMouseLeave={onHoverToggleButton}
584
        data-component-name="ObjectPageTopHeader"
585
        ref={topHeaderRef}
586
        role={accessibilityAttributes?.objectPageTopHeader?.role}
587
        data-not-clickable={!!preserveHeaderStateOnClick}
588
        aria-roledescription={accessibilityAttributes?.objectPageTopHeader?.ariaRoledescription ?? 'Object Page header'}
38,640✔
589
        className={classNames.header}
590
      >
591
        <span
592
          className={classNames.clickArea}
593
          onClick={onTitleClick}
594
          data-component-name="ObjectPageTitleAreaClickElement"
595
        />
596
        {titleArea &&
38,292✔
597
          cloneElement(titleArea as ReactElement<ObjectPageTitlePropsWithDataAttributes>, {
598
            className: clsx(titleArea?.props?.className),
599
            onToggleHeaderContentVisibility: onTitleClick,
600
            'data-not-clickable': !!preserveHeaderStateOnClick,
601
            'data-header-content-visible': headerArea && headerCollapsed !== true,
37,748✔
602
            _snappedAvatar:
603
              !headerArea || (titleArea && image && headerCollapsed === true) ? (
75,640✔
604
                <CollapsedAvatar image={image} imageShapeCircle={imageShapeCircle} />
605
              ) : null
606
          })}
607
      </header>
608
      {renderHeaderContentSection()}
609
      {headerArea && titleArea && (
56,962✔
610
        <div
611
          data-component-name="ObjectPageAnchorBar"
612
          ref={anchorBarRef}
613
          className={classNames.anchorBar}
614
          style={{
615
            top:
616
              scrolledHeaderExpanded || headerPinned
55,724✔
617
                ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight)}px`
1,128!
618
                : `${topHeaderHeight}px`
619
          }}
620
        >
621
          <ObjectPageAnchorBar
622
            headerContentVisible={headerArea && headerCollapsed !== true}
37,552✔
623
            hidePinButton={!!hidePinButton}
624
            headerPinned={headerPinned}
625
            accessibilityAttributes={accessibilityAttributes}
626
            onToggleHeaderContentVisibility={onToggleHeaderContentVisibility}
627
            setHeaderPinned={setHeaderPinned}
628
            onHoverToggleButton={onHoverToggleButton}
629
            onPinButtonToggle={onPinButtonToggle}
630
          />
631
        </div>
632
      )}
633
      {!placeholder && (
38,560✔
634
        <div
635
          ref={tabContainerContainerRef}
636
          className={classNames.tabContainer}
637
          data-component-name="ObjectPageTabContainer"
638
          style={{
639
            top:
640
              headerPinned || scrolledHeaderExpanded
56,876✔
641
                ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight)}px`
1,128!
642
                : `${topHeaderHeight}px`
643
          }}
644
        >
645
          <TabContainer
646
            collapsed
647
            onTabSelect={handleTabSelect}
648
            data-component-name="ObjectPageTabContainer"
649
            className={classNames.tabContainerComponent}
650
          >
651
            {childrenArray.map((section, index) => {
652
              if (!isValidElement(section) || !section.props) return null;
84,686!
653
              const subTabs = safeGetChildrenArray<ReactElement<ObjectPageSubSectionPropTypes>>(
84,686✔
654
                section.props.children
655
              ).filter(
656
                (subSection) =>
657
                  // @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.
658
                  isValidElement(subSection) && subSection?.type?.displayName === 'ObjectPageSubSection'
117,338✔
659
              );
660
              return (
84,686✔
661
                <Tab
662
                  key={`Anchor-${section.props?.id}`}
663
                  data-index={index}
664
                  data-section-id={section.props.id}
665
                  text={section.props.titleText}
666
                  selected={internalSelectedSectionId === section.props?.id || undefined}
150,438✔
667
                  items={subTabs.map((item) => {
668
                    if (!isValidElement(item)) {
48,010!
669
                      return null;
×
670
                    }
671
                    return (
48,010✔
672
                      <Tab
673
                        data-parent-id={section.props.id}
674
                        key={item.props.id}
675
                        data-is-sub-tab
676
                        data-section-id={item.props.id}
677
                        text={item.props.titleText}
678
                        selected={item.props.id === selectedSubSectionId || undefined}
94,846✔
679
                        data-index={index}
680
                      >
681
                        {/*ToDo: workaround for nested tab selection*/}
682
                        <span style={{ display: 'none' }} />
683
                      </Tab>
684
                    );
685
                  })}
686
                >
687
                  {/*ToDo: workaround for nested tab selection*/}
688
                  <span style={{ display: 'none' }} />
689
                </Tab>
690
              );
691
            })}
692
          </TabContainer>
693
        </div>
694
      )}
695
      <div
696
        data-component-name="ObjectPageContent"
697
        className={classNames.content}
698
        ref={objectPageContentRef}
699
        // prevent content scroll when elements outside the content are focused
700
        onFocus={() => {
701
          objectPageRef.current.style.scrollPaddingBlockStart = `${Math.ceil(topHeaderHeight + TAB_CONTAINER_HEADER_HEIGHT + (!headerCollapsed && headerPinned ? headerContentHeight : 0))}px`;
179!
702
          if (footerArea) {
179✔
703
            objectPageRef.current.style.scrollPaddingBlockEnd = 'calc(var(--_ui5wcr-BarHeight) + 0.5rem)';
87✔
704
          }
705
        }}
706
        onBlur={(e) => {
707
          if (!e.currentTarget.contains(e.relatedTarget as Node)) {
×
708
            objectPageRef.current.style.scrollPaddingBlockStart = '0px';
×
709
            objectPageRef.current.style.scrollPaddingBlockEnd = '0px';
×
710
          }
711
        }}
712
      >
713
        <div
714
          style={{
715
            height:
65,314✔
716
              ((headerCollapsed && !headerPinned) || scrolledHeaderExpanded) && !toggledCollapsedHeaderWasVisible
717
                ? `${headerContentHeight}px`
718
                : 0
719
          }}
720
          aria-hidden
721
        />
722
        {placeholder ? placeholder : sections}
19,320✔
723
        <div style={{ height: `${sectionSpacer}px` }} aria-hidden />
724
      </div>
725
      {footerArea && mode === ObjectPageMode.IconTabBar && !sectionSpacer && (
25,480✔
726
        <div className={classNames.footerSpacer} data-component-name="ObjectPageFooterSpacer" aria-hidden />
727
      )}
728
      {footerArea && (
22,866✔
729
        <footer
730
          role={accessibilityAttributes?.objectPageFooterArea?.role}
731
          className={classNames.footer}
732
          data-component-name="ObjectPageFooter"
733
        >
734
          {footerArea}
735
        </footer>
736
      )}
737
    </div>
738
  );
739
});
740

741
ObjectPage.displayName = 'ObjectPage';
427✔
742

743
export { ObjectPage };
744
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