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

SAP / ui5-webcomponents-react / 14862457017

06 May 2025 02:36PM CUT coverage: 88.173% (-0.05%) from 88.224%
14862457017

Pull #7305

github

web-flow
Merge 96924ac53 into cbb3a14a4
Pull Request #7305: docs(Suggestions): remove redundant import and outdated note

3013 of 3966 branches covered (75.97%)

5286 of 5995 relevant lines covered (88.17%)

102503.23 hits per line

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

94.93
/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;
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,658โœ”
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,688โœ”
67

68
  useStylesheet(styleData, ObjectPage.displayName);
37,688โœ”
69

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

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

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

103
  useEffect(() => {
37,688โœ”
104
    const currentSection =
105
      mode === ObjectPageMode.IconTabBar ? getSectionById(children, internalSelectedSectionId) : null;
5,777โœ”
106
    setCurrentTabModeSection(currentSection);
5,777โœ”
107
  }, [mode, children, internalSelectedSectionId]);
108

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

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

145
  useEffect(() => {
37,688โœ”
146
    if (typeof onToggleHeaderArea === 'function' && isToggledRef.current) {
3,696โœ”
147
      onToggleHeaderArea(headerCollapsed !== true);
252โœ”
148
    }
149
  }, [headerCollapsed]);
150

151
  useEffect(() => {
37,688โœ”
152
    const objectPageNode = objectPageRef.current;
3,696โœ”
153
    if (objectPageNode) {
3,696โœ”
154
      Object.assign(objectPageNode, {
3,696โœ”
155
        toggleHeaderArea(snapped?: boolean) {
156
          if (typeof snapped === 'boolean') {
ร—
157
            onToggleHeaderContentVisibility({ detail: { visible: !snapped } });
ร—
158
          } else {
159
            onToggleHeaderContentVisibility({ detail: { visible: !!headerCollapsed } });
ร—
160
          }
161
        }
162
      });
163
    }
164
  }, [headerCollapsed]);
165

166
  const avatar = useMemo(() => {
37,688โœ”
167
    if (!image) {
2,242โœ”
168
      return null;
2,156โœ”
169
    }
170

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

190
  const scrollToSectionById = (id: string | undefined, isSubSection = false) => {
37,688โœ”
191
    const scroll = () => {
662โœ”
192
      const section = getSectionElementById(objectPageRef.current, isSubSection, id);
661โœ”
193
      scrollTimeout.current = performance.now() + 500;
661โœ”
194
      if (section) {
661โœ”
195
        const safeTopHeaderHeight = topHeaderHeight || prevTopHeaderHeight.current;
643โœ”
196

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

208
        const sectionRect = section.getBoundingClientRect();
643โœ”
209
        const objectPageElement = objectPageRef.current;
643โœ”
210
        const objectPageRect = objectPageElement.getBoundingClientRect();
643โœ”
211

212
        // Calculate the top position of the section relative to the container
213
        objectPageElement.scrollTop = sectionRect.top - objectPageRect.top + objectPageElement.scrollTop - scrollMargin;
643โœ”
214

215
        section.style.scrollMarginBlockStart = '';
643โœ”
216
      }
217
    };
218
    // In TabBar mode the section is only rendered when selected: delay scroll for subsection
219
    if (mode === ObjectPageMode.IconTabBar && isSubSection) {
662โœ”
220
      setTimeout(scroll, 300);
89โœ”
221
    } else {
222
      scroll();
573โœ”
223
    }
224
  };
225

226
  const scrollToSection = (sectionId?: string) => {
37,688โœ”
227
    if (!sectionId) {
599!
228
      return;
ร—
229
    }
230
    if (firstSectionId === sectionId) {
599โœ”
231
      objectPageRef.current?.scrollTo({ top: 0 });
172โœ”
232
    } else {
233
      scrollToSectionById(sectionId);
427โœ”
234
    }
235
    isProgrammaticallyScrolled.current = false;
599โœ”
236
  };
237

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

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

274
  // do internal scrolling
275
  useEffect(() => {
37,688โœ”
276
    if (mode === ObjectPageMode.Default && isProgrammaticallyScrolled.current === true && !selectedSubSectionId) {
3,375โœ”
277
      scrollToSection(internalSelectedSectionId);
319โœ”
278
    }
279
  }, [internalSelectedSectionId, mode, selectedSubSectionId]);
280

281
  // Scrolling for Sub Section Selection
282
  useEffect(() => {
37,688โœ”
283
    if (selectedSubSectionId && isProgrammaticallyScrolled.current === true) {
4,773โœ”
284
      scrollToSectionById(selectedSubSectionId, true);
235โœ”
285
      isProgrammaticallyScrolled.current = false;
235โœ”
286
    }
287
  }, [selectedSubSectionId, isProgrammaticallyScrolled.current, sectionSpacer]);
288

289
  useEffect(() => {
37,688โœ”
290
    if (headerPinnedProp !== undefined) {
2,627โœ”
291
      setHeaderPinned(headerPinnedProp);
495โœ”
292
    }
293
    if (headerPinnedProp) {
2,627โœ”
294
      onToggleHeaderContentVisibility({ detail: { visible: true } });
220โœ”
295
    }
296
  }, [headerPinnedProp]);
297

298
  const prevHeaderPinned = useRef(headerPinned);
37,688โœ”
299
  useEffect(() => {
37,688โœ”
300
    if (prevHeaderPinned.current && !headerPinned && objectPageRef.current.scrollTop > topHeaderHeight) {
4,634โœ”
301
      onToggleHeaderContentVisibility({ detail: { visible: false } });
224โœ”
302
      prevHeaderPinned.current = false;
224โœ”
303
    }
304
    if (!prevHeaderPinned.current && headerPinned) {
4,634โœ”
305
      prevHeaderPinned.current = true;
279โœ”
306
    }
307
  }, [headerPinned, topHeaderHeight]);
308

309
  const isInitialTabBarMode = useRef(true);
37,688โœ”
310
  useEffect(() => {
37,688โœ”
311
    if (!isMounted) {
3,378โœ”
312
      requestAnimationFrame(() => setIsMounted(true));
2,242โœ”
313
      return;
2,242โœ”
314
    }
315

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

350
  const tabContainerContainerRef = useRef(null);
37,688โœ”
351
  const isHeaderPinnedAndExpanded = headerPinned && !headerCollapsed;
37,688โœ”
352
  useEffect(() => {
37,688โœ”
353
    const objectPage = objectPageRef.current;
8,397โœ”
354
    const tabContainerContainer = tabContainerContainerRef.current;
8,397โœ”
355

356
    if (!objectPage || !tabContainerContainer) {
8,397โœ”
357
      return;
160โœ”
358
    }
359

360
    const footerElement = objectPage.querySelector<HTMLDivElement>('[data-component-name="ObjectPageFooter"]');
8,237โœ”
361
    const topHeaderElement = objectPage.querySelector('[data-component-name="ObjectPageTopHeader"]');
8,237โœ”
362

363
    const calculateSpacer = ([lastSectionNodeEntry]: ResizeObserverEntry[]) => {
8,237โœ”
364
      const lastSectionNode = lastSectionNodeEntry?.target;
6,737โœ”
365

366
      if (!lastSectionNode) {
6,737!
367
        setSectionSpacer(0);
ร—
368
        return;
ร—
369
      }
370

371
      const subSections = lastSectionNode.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSubSection"]');
6,737โœ”
372
      const lastSubSection = subSections[subSections.length - 1];
6,737โœ”
373
      const lastSubSectionOrSection = lastSubSection ?? lastSectionNode;
6,737โœ”
374

375
      if ((currentTabModeSection && !lastSubSection) || (sectionNodes.length === 1 && !lastSubSection)) {
6,737โœ”
376
        setSectionSpacer(0);
3,687โœ”
377
        return;
3,687โœ”
378
      }
379

380
      // batching DOM-reads together minimizes reflow
381
      const footerHeight = footerElement?.offsetHeight ?? 0;
3,050โœ”
382
      const objectPageRect = objectPage.getBoundingClientRect();
3,050โœ”
383
      const tabContainerContainerRect = tabContainerContainer.getBoundingClientRect();
3,050โœ”
384
      const lastSubSectionOrSectionRect = lastSubSectionOrSection.getBoundingClientRect();
3,050โœ”
385

386
      let stickyHeaderBottom = 0;
3,050โœ”
387
      if (!isHeaderPinnedAndExpanded) {
3,050!
388
        const topHeaderBottom = topHeaderElement?.getBoundingClientRect().bottom ?? 0;
3,050!
389
        stickyHeaderBottom = topHeaderBottom + tabContainerContainerRect.height;
3,050โœ”
390
      } else {
391
        stickyHeaderBottom = tabContainerContainerRect.bottom;
ร—
392
      }
393

394
      const spacer = Math.ceil(
3,050โœ”
395
        objectPageRect.bottom - stickyHeaderBottom - lastSubSectionOrSectionRect.height - footerHeight // section padding (8px) not included, so that the intersection observer is triggered correctly
396
      );
397
      setSectionSpacer(Math.max(spacer, 0));
3,050โœ”
398
    };
399

400
    const observer = new ResizeObserver(calculateSpacer);
8,237โœ”
401
    const sectionNodes = objectPage.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSection"]');
8,237โœ”
402
    const lastSectionNode = sectionNodes[sectionNodes.length - 1];
8,237โœ”
403

404
    if (lastSectionNode) {
8,237โœ”
405
      observer.observe(lastSectionNode, { box: 'border-box' });
7,192โœ”
406
    }
407

408
    return () => {
8,237โœ”
409
      observer.disconnect();
8,201โœ”
410
    };
411
  }, [topHeaderHeight, headerContentHeight, currentTabModeSection, children, mode, isHeaderPinnedAndExpanded]);
412

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

434
  const { onScroll: _0, selectedSubSectionId: _1, ...propsWithoutOmitted } = rest;
37,688โœ”
435

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

446
    const observer = new IntersectionObserver(
4,730โœ”
447
      (entries) => {
448
        entries.forEach((entry) => {
4,057โœ”
449
          const sectionId = entry.target.id;
18,937โœ”
450
          if (entry.isIntersecting) {
18,937โœ”
451
            visibleSectionIds.current.add(sectionId);
5,939โœ”
452
          } else {
453
            visibleSectionIds.current.delete(sectionId);
12,998โœ”
454
          }
455

456
          let currentIndex: undefined | number;
457
          const sortedVisibleSections = Array.from(sectionNodes).filter((section, index) => {
18,937โœ”
458
            const isVisibleSection = visibleSectionIds.current.has(section.id);
200,275โœ”
459
            if (currentIndex === undefined && isVisibleSection) {
200,275โœ”
460
              currentIndex = index;
18,086โœ”
461
            }
462
            return visibleSectionIds.current.has(section.id);
200,275โœ”
463
          });
464

465
          if (sortedVisibleSections.length > 0) {
18,937โœ”
466
            const section = sortedVisibleSections[0];
18,086โœ”
467
            const id = sortedVisibleSections[0].id.slice(18);
18,086โœ”
468
            setInternalSelectedSectionId(id);
18,086โœ”
469
            debouncedOnSectionChange(scrollEvent.current, currentIndex, id, section);
18,086โœ”
470
          }
471
        });
472
      },
473
      {
474
        root: objectPageRef.current,
475
        rootMargin,
476
        threshold: [0]
477
      }
478
    );
479
    sectionNodes.forEach((el) => {
4,730โœ”
480
      observer.observe(el);
19,577โœ”
481
    });
482

483
    return () => {
4,730โœ”
484
      observer.disconnect();
4,705โœ”
485
    };
486
  }, [
487
    totalHeaderHeight,
488
    headerPinned,
489
    headerCollapsed,
490
    topHeaderHeight,
491
    childrenArray.length,
492
    scrolledHeaderExpanded,
493
    mode
494
  ]);
495

496
  const onTitleClick = (e) => {
37,688โœ”
497
    e.stopPropagation();
252โœ”
498
    if (!preserveHeaderStateOnClick) {
252โœ”
499
      onToggleHeaderContentVisibility(enrichEventWithDetails(e, { visible: headerCollapsed }));
126โœ”
500
    }
501
  };
502

503
  const renderHeaderContentSection = () => {
37,688โœ”
504
    if (headerArea?.props) {
37,688โœ”
505
      return cloneElement(headerArea as ReactElement<ObjectPageHeaderPropTypesWithInternals>, {
36,274โœ”
506
        ...headerArea.props,
507
        topHeaderHeight,
508
        style:
509
          headerCollapsed === true
36,274โœ”
510
            ? { ...headerArea.props.style, position: 'absolute', visibility: 'hidden', flexShrink: 0, insetInline: 0 }
511
            : { ...headerArea.props.style, flexShrink: 0 },
512
        headerPinned: headerPinned || scrolledHeaderExpanded,
70,278โœ”
513
        //@ts-expect-error: todo remove me when forwardref has been replaced
514
        ref: componentRefHeaderContent,
515
        children: (
516
          <div
517
            className={clsx(classNames.headerContainer, avatar && classNames.hasAvatar)}
36,540โœ”
518
            data-component-name="ObjectPageHeaderContainer"
519
          >
520
            {avatar}
521
            {headerArea.props.children && (
72,548โœ”
522
              <div data-component-name="ObjectPageHeaderContent">{headerArea.props.children}</div>
523
            )}
524
          </div>
525
        )
526
      });
527
    }
528
  };
529

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

568
  const onHoverToggleButton: MouseEventHandler<HTMLHeadElement> = useCallback((e) => {
37,688โœ”
569
    if (e.type === 'mouseover') {
787โœ”
570
      topHeaderRef.current?.classList.add(classNames.headerHoverStyles);
583โœ”
571
    } else {
572
      topHeaderRef.current?.classList.remove(classNames.headerHoverStyles);
204โœ”
573
    }
574
  }, []);
575

576
  const handleTabSelect = useHandleTabSelect({
37,688โœ”
577
    onBeforeNavigate,
578
    headerPinned,
579
    mode,
580
    setHeaderCollapsedInternal,
581
    setScrolledHeaderExpanded,
582
    childrenArray,
583
    handleOnSectionSelected,
584
    isProgrammaticallyScrolled,
585
    setInternalSelectedSectionId,
586
    objectPageRef,
587
    debouncedOnSectionChange,
588
    scrollTimeout,
589
    setSelectedSubSectionId
590
  });
591

592
  const objectPageStyles: CSSProperties = {
37,688โœ”
593
    ...style
594
  };
595
  if (headerCollapsed === true && headerArea) {
37,688โœ”
596
    objectPageStyles[ObjectPageCssVariables.titleFontSize] = ThemingParameters.sapObjectHeader_Title_SnappedFontSize;
11,712โœ”
597
  }
598

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

771
ObjectPage.displayName = 'ObjectPage';
444โœ”
772

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