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

SAP / ui5-webcomponents-react / 14170380248

31 Mar 2025 11:22AM CUT coverage: 88.191% (+0.7%) from 87.483%
14170380248

Pull #7150

github

web-flow
Merge 097519048 into f9557b7a0
Pull Request #7150: fix(ObjectPage): improve focus and scroll behavior

3145 of 4129 branches covered (76.17%)

40 of 77 new or added lines in 2 files covered. (51.95%)

7 existing lines in 1 file now uncovered.

5325 of 6038 relevant lines covered (88.19%)

163483.52 hits per line

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

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

3
import type { TabContainerTabSelectEventDetail } from '@ui5/webcomponents/dist/TabContainer.js';
4
import AvatarSize from '@ui5/webcomponents/dist/types/AvatarSize.js';
5
import {
6
  debounce,
7
  enrichEventWithDetails,
8
  ThemingParameters,
9
  useStylesheet,
10
  useSyncRef
11
} from '@ui5/webcomponents-react-base';
12
import { clsx } from 'clsx';
13
import type { CSSProperties, ReactElement, ReactNode } from 'react';
14
import { cloneElement, forwardRef, isValidElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
import { ObjectPageMode } from '../../enums/index.js';
16
import { safeGetChildrenArray } from '../../internal/safeGetChildrenArray.js';
17
import { useObserveHeights } from '../../internal/useObserveHeights.js';
18
import type { CommonProps, Ui5CustomEvent } from '../../types/index.js';
19
import type { AvatarPropTypes, TabContainerDomRef } from '../../webComponents/index.js';
20
import { Tab, TabContainer } from '../../webComponents/index.js';
21
import { ObjectPageAnchorBar } from '../ObjectPageAnchorBar/index.js';
22
import type {
23
  InternalProps as ObjectPageHeaderPropTypesWithInternals,
24
  ObjectPageHeaderPropTypes
25
} from '../ObjectPageHeader/index.js';
26
import type { ObjectPageSectionPropTypes } from '../ObjectPageSection/index.js';
27
import type { ObjectPageSubSectionPropTypes } from '../ObjectPageSubSection/index.js';
28
import type {
29
  InternalProps as ObjectPageTitlePropTypesWithInternals,
30
  ObjectPageTitlePropTypes
426✔
31
} from '../ObjectPageTitle/index.js';
32
import { CollapsedAvatar } from './CollapsedAvatar.js';
33
import { classNames, styleData } from './ObjectPage.module.css.js';
34
import { getSectionById, getSectionElementById } from './ObjectPageUtils.js';
35

426✔
36
const ObjectPageCssVariables = {
245✔
37
  headerDisplay: '--_ui5wcr_ObjectPage_header_display',
38
  titleFontSize: '--_ui5wcr_ObjectPage_title_fontsize'
39
};
40

41
const TAB_CONTAINER_HEADER_HEIGHT = 44;
245✔
42

426✔
43
type ObjectPageSectionType = ReactElement<ObjectPageSectionPropTypes> | boolean;
44

45
interface BeforeNavigateDetail {
46
  sectionIndex: number;
47
  sectionId: string;
12,292✔
48
  subSectionId: string | undefined;
49
}
50

51
type ObjectPageTabSelectEventDetail = TabContainerTabSelectEventDetail & BeforeNavigateDetail;
52

53
type ObjectPageTitlePropsWithDataAttributes = ObjectPageTitlePropTypesWithInternals & {
54
  'data-not-clickable': boolean;
55
  'data-header-content-visible': boolean;
56
  'data-is-snapped-rendered-outside': boolean;
57
};
58

59
export interface ObjectPagePropTypes extends Omit<CommonProps, 'placeholder'> {
60
  /**
61
   * Defines the upper, always static, title section of the `ObjectPage`.
62
   *
63
   * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `ObjectPageTitle` in order to preserve the intended design.
64
   *
65
   * __Note:__ When the `ObjectPageTitle` is rendered inside a custom component, it's essential to pass through all props, as otherwise the component won't function as intended!
18,978✔
66
   */
67
  titleArea?: ReactElement<ObjectPageTitlePropTypes>;
18,978✔
68
  /**
69
   * Defines the `ObjectPageHeader` section of the `ObjectPage`.
70
   *
18,978✔
71
   * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `ObjectPageHeader` in order to preserve the intended design.
3,890✔
72
   *
73
   * __Note:__ When the `ObjectPageHeader` is rendered inside a custom component, it's essential to pass through all props, as otherwise the component won't function as intended!
74
   */
18,978✔
75
  headerArea?: ReactElement<ObjectPageHeaderPropTypes>;
18,978✔
76
  /**
37,674✔
77
   * React element which defines the footer content.
78
   *
79
   * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `Bar` with `design={BarDesign.FloatingFooter}` in order to preserve the intended design.
18,978✔
80
   */
18,978✔
81
  footerArea?: ReactElement;
18,978✔
82
  /**
18,978✔
83
   * Defines the image of the `ObjectPage`. You can pass a path to an image or an `Avatar` component.
84
   */
18,978✔
85
  image?: string | ReactElement<AvatarPropTypes>;
18,978✔
86
  /**
18,978✔
87
   * Defines the content area of the `ObjectPage`. It consists of sections and subsections.
18,978✔
88
   *
18,978✔
89
   * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `ObjectPageSection` in order to preserve the intended design.
18,978✔
90
   */
18,978✔
91
  children?: ObjectPageSectionType | ObjectPageSectionType[];
18,978✔
92
  /**
93
   * Sets the current selected `ObjectPageSection` by `id`.
18,978✔
94
   *
18,978✔
95
   * __Note:__ If a valid `selectedSubSectionId` is set, this prop has no effect.
18,978✔
96
   */
18,978✔
97
  selectedSectionId?: string;
18,978✔
98
  /**
18,978✔
99
   * Sets the current selected `ObjectPageSubSection` by `id`.
18,978✔
100
   */
18,978✔
101
  selectedSubSectionId?: string;
18,978✔
102
  /**
103
   * Defines whether the `headerArea` is pinned.
18,978✔
104
   */
105
  headerPinned?: boolean;
3,036✔
106
  /**
3,036✔
107
   * Defines whether the image should be displayed in a circle or in a square.<br />
108
   * __Note:__ If the `image` is not a `string`, this prop has no effect.
109
   */
18,978✔
110
  imageShapeCircle?: boolean;
18,978✔
111
  /**
556✔
112
   * Defines the `ObjectPage` mode.
51✔
113
   *
114
   * - "Default": All `ObjectPageSections` and `ObjectPageSubSections` are displayed on one page. Selecting tabs will scroll to the corresponding section.
115
   * - "IconTabBar": All `ObjectPageSections` are displayed on separate pages. Selecting tabs will lead to the corresponding page.
116
   *
117
   * @default `"Default"`
118
   */
119
  mode?: ObjectPageMode | keyof typeof ObjectPageMode;
51✔
120
  /**
121
   * Defines if the pin button for the `headerArea` is hidden.
122
   */
18,978✔
123
  hidePinButton?: boolean;
18,978✔
124
  /**
1,198✔
125
   * Determines whether the user can switch between the expanded/collapsed states of the `ObjectPageHeader` by clicking on the `ObjectPageTitle`.
1,171✔
126
   *
1,171✔
127
   * __Note:__ Per default the header is toggleable.
128
   */
129
  preserveHeaderStateOnClick?: boolean;
130
  /**
131
   * Defines internally used accessibility properties/attributes.
132
   */
18,978✔
133
  accessibilityAttributes?: {
134
    objectPageTopHeader?: {
135
      role?: string;
136
      ariaRoledescription?: string;
137
    };
138
    objectPageAnchorBar?: {
139
      role?: string;
19,332✔
140
    };
141
  };
142
  /**
143
   * If set, only the specified placeholder will be displayed as content of the `ObjectPage`, no sections or sub-sections will be rendered.
144
   *
145
   * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use placeholder components like the `IllustratedMessage` or custom skeletons pages in order to preserve the intended design.
18,978✔
146
   */
1,850✔
147
  placeholder?: ReactNode;
88✔
148
  /**
149
   * The event is fired before the selected section is changed using the navigation. It can be aborted by the application with `preventDefault()`, which means that there will be no navigation.
150
   *
151
   * __Note:__ This event is only fired when navigating via tab-bar.
18,978✔
152
   */
1,850✔
153
  onBeforeNavigate?: (event: Ui5CustomEvent<TabContainerDomRef, ObjectPageTabSelectEventDetail>) => void;
1,850✔
154
  /**
1,850✔
155
   * Fired when the selected section changes.
156
   */
×
157
  onSelectedSectionChange?: (
158
    event: CustomEvent<{ selectedSectionIndex: number; selectedSectionId: string; section: HTMLDivElement }>
159
  ) => void;
160
  /**
161
   * Fired when the `headerArea` is expanded or collapsed.
162
   */
163
  onToggleHeaderArea?: (visible: boolean) => void;
164
  /**
165
   * Fired when the `headerArea` changes its pinned state.
166
   */
18,978✔
167
  onPinButtonToggle?: (pinned: boolean) => void;
1,198✔
168
}
1,136✔
169

170
export interface ObjectPageDomRef extends HTMLDivElement {
171
  /**
62✔
172
   * Toggles the `headerArea` of the `ObjectPage`.
36✔
173
   *
174
   * __Note:__ If no argument is passed, the header state is toggled, otherwise the respective `snapped` state is applied.
175
   */
36✔
176
  toggleHeaderArea: (snapped?: boolean) => void;
177
}
178

179
/**
180
 * A component that allows apps to easily display information related to a business object.
181
 *
182
 * The `ObjectPage` is composed of a header (title and content) and block content wrapped in sections and subsections that structure the information.
26✔
183
 */
184
const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref) => {
245✔
185
  const {
186
    titleArea,
187
    image,
188
    footerArea,
189
    mode = ObjectPageMode.Default,
11,326✔
190
    imageShapeCircle,
18,978✔
191
    className,
416✔
192
    style,
415✔
193
    slot,
415✔
194
    children,
415✔
195
    selectedSectionId,
402✔
196
    headerPinned: headerPinnedProp,
197
    headerArea,
198
    hidePinButton,
402✔
199
    preserveHeaderStateOnClick,
200
    accessibilityAttributes,
201
    placeholder,
202
    onSelectedSectionChange,
804!
203
    onToggleHeaderArea,
402✔
204
    onPinButtonToggle,
402✔
205
    onBeforeNavigate,
125✔
206
    ...rest
207
  } = props;
17,654✔
208

402✔
209
  useStylesheet(styleData, ObjectPage.displayName);
18,056✔
210

402✔
211
  // memo necessary due to side effects triggered on each update
212
  const childrenArray = useMemo(
17,654✔
213
    () => safeGetChildrenArray<ReactElement<ObjectPageSectionPropTypes>>(children),
4,222✔
214
    [children]
215
  );
402✔
216
  const firstSectionId: string | undefined = childrenArray[0]?.props?.id;
17,654✔
217

218
  const [internalSelectedSectionId, setInternalSelectedSectionId] = useState<string | undefined>(
17,654✔
219
    selectedSectionId ?? firstSectionId
416✔
220
  );
48✔
221
  const [selectedSubSectionId, setSelectedSubSectionId] = useState(undefined);
17,654✔
222
  const [headerPinned, setHeaderPinned] = useState(headerPinnedProp);
18,022✔
223
  const isProgrammaticallyScrolled = useRef(false);
17,654✔
224
  const [isMounted, setIsMounted] = useState(false);
17,654✔
225

226
  const [componentRef, objectPageRef] = useSyncRef(ref);
36,632✔
227
  const topHeaderRef = useRef<HTMLDivElement>(null);
18,029!
228
  const scrollEvent = useRef(undefined);
17,654✔
229
  const prevTopHeaderHeight = useRef(0);
17,654✔
230
  // @ts-expect-error: useSyncRef will create a ref if not present
375✔
231
  const [componentRefHeaderContent, headerContentRef] = useSyncRef(headerArea?.ref);
17,752✔
232
  const anchorBarRef = useRef<HTMLDivElement>(null);
17,654✔
233
  const objectPageContentRef = useRef<HTMLDivElement>(null);
17,931✔
234
  const selectionScrollTimeout = useRef(null);
17,654✔
235
  const isToggledRef = useRef(false);
18,029✔
236
  const isInitial = useRef(true);
17,654✔
237
  const [headerCollapsedInternal, setHeaderCollapsedInternal] = useState<undefined | boolean>(undefined);
17,654✔
238
  const [scrolledHeaderExpanded, setScrolledHeaderExpanded] = useState(false);
17,654✔
239
  const scrollTimeout = useRef(0);
36,632✔
240
  const [sectionSpacer, setSectionSpacer] = useState(0);
18,037✔
241
  const [currentTabModeSection, setCurrentTabModeSection] = useState(null);
18,037✔
242
  const sections = mode === ObjectPageMode.IconTabBar ? currentTabModeSection : children;
18,037✔
243

383✔
244
  useEffect(() => {
18,420✔
245
    const currentSection =
156✔
246
      mode === ObjectPageMode.IconTabBar ? getSectionById(children, internalSelectedSectionId) : null;
2,952✔
247
    setCurrentTabModeSection(currentSection);
3,718✔
248
  }, [mode, children, internalSelectedSectionId]);
249

383✔
250
  const prevInternalSelectedSectionId = useRef(internalSelectedSectionId);
18,037✔
251
  const fireOnSelectedChangedEvent = (targetEvent, index, id, section) => {
17,654✔
252
    if (typeof onSelectedSectionChange === 'function' && targetEvent && prevInternalSelectedSectionId.current !== id) {
509✔
253
      onSelectedSectionChange(
19,029✔
254
        enrichEventWithDetails(targetEvent, {
1,210✔
255
          selectedSectionIndex: parseInt(index, 10),
36✔
256
          selectedSectionId: id,
36✔
257
          section
36✔
258
        })
259
      );
260
      prevInternalSelectedSectionId.current = id;
87✔
261
    }
262
  };
263
  const debouncedOnSectionChange = useRef(debounce(fireOnSelectedChangedEvent, 500)).current;
17,654✔
264
  useEffect(() => {
17,654✔
265
    return () => {
1,128✔
266
      debouncedOnSectionChange.cancel();
20,079✔
267
      clearTimeout(selectionScrollTimeout.current);
2,933✔
268
    };
219✔
269
  }, []);
270

271
  // observe heights of header parts
272
  const { topHeaderHeight, headerContentHeight, anchorBarHeight, totalHeaderHeight, headerCollapsed } =
273
    useObserveHeights(
36,632✔
274
      objectPageRef,
2,713✔
275
      topHeaderRef,
139✔
276
      headerContentRef,
139✔
277
      anchorBarRef,
278
      [headerCollapsedInternal, setHeaderCollapsedInternal],
279
      {
280
        noHeader: !titleArea && !headerArea,
18,978✔
281
        fixedHeader: headerPinned,
1,338✔
282
        scrollTimeout
180✔
283
      }
284
    );
1,338✔
285

80✔
286
  useEffect(() => {
17,654✔
287
    if (typeof onToggleHeaderArea === 'function' && isToggledRef.current) {
1,639✔
288
      onToggleHeaderArea(headerCollapsed !== true);
88✔
289
    }
18,978✔
290
  }, [headerCollapsed]);
18,978✔
291

2,226✔
292
  useEffect(() => {
17,735✔
293
    const objectPageNode = objectPageRef.current;
1,720✔
294
    if (objectPageNode) {
1,639✔
295
      Object.assign(objectPageNode, {
3,865✔
296
        toggleHeaderArea(snapped?: boolean) {
101✔
297
          if (typeof snapped === 'boolean') {
×
298
            onToggleHeaderContentVisibility({ detail: { visible: !snapped } });
299
          } else {
UNCOV
300
            onToggleHeaderContentVisibility({ detail: { visible: !!headerCollapsed } });
18,978✔
301
          }
1,800✔
302
        }
1,198✔
303
      });
1,198✔
304
    }
305
  }, [headerCollapsed]);
602✔
306

602✔
307
  const avatar = useMemo(() => {
17,678✔
308
    if (!image) {
1,152!
309
      return null;
1,066✔
310
    }
311

×
312
    if (typeof image === 'string') {
62✔
313
      return (
36!
314
        <span
×
315
          className={classNames.headerImage}
316
          style={{ borderRadius: imageShapeCircle ? '50%' : 0, overflow: 'hidden' }}
36✔
317
          data-component-name="ObjectPageHeaderImage"
318
        >
319
          <img src={image} className={classNames.image} alt="Company Logo" />
320
        </span>
321
      );
322
    } else {
323
      return cloneElement(image, {
26!
324
        size: AvatarSize.L,
325
        className: clsx(classNames.headerImage, image.props?.className),
326
        'data-component-name': 'ObjectPageHeaderImage'
327
      } as AvatarPropTypes);
328
    }
329
  }, [image, imageShapeCircle]);
330

18,978✔
331
  const scrollToSectionById = (id: string | undefined, isSubSection = false) => {
36,632✔
332
    const section = getSectionElementById(objectPageRef.current, isSubSection, id);
19,305✔
333
    scrollTimeout.current = performance.now() + 500;
4,384✔
334
    if (section) {
4,384✔
335
      const safeTopHeaderHeight = topHeaderHeight || prevTopHeaderHeight.current;
327✔
336

4,057✔
337
      const scrollMargin =
40✔
338
        -1 /* reduce margin-block so that intersection observer detects correct section*/ +
327✔
339
        safeTopHeaderHeight +
340
        anchorBarHeight +
4,017✔
341
        TAB_CONTAINER_HEADER_HEIGHT +
4,017✔
342
        (headerPinned && !headerCollapsed ? headerContentHeight : 0);
654!
343
      section.style.scrollMarginBlockStart = scrollMargin + 'px';
4,344✔
344
      if (isSubSection) {
3,539✔
345
        section.focus();
104✔
346
      }
3,212!
347

348
      const sectionRect = section.getBoundingClientRect();
327✔
349
      const objectPageElement = objectPageRef.current;
327✔
350
      const objectPageRect = objectPageElement.getBoundingClientRect();
327✔
351

3,212✔
352
      // Calculate the top position of the section relative to the container
3,212✔
353
      objectPageElement.scrollTop = sectionRect.top - objectPageRect.top + objectPageElement.scrollTop - scrollMargin;
3,539✔
354

355
      section.style.scrollMarginBlockStart = '';
3,539✔
356
    }
1,691✔
357
  };
1,691✔
358

359
  const scrollToSection = (sectionId?: string) => {
17,654✔
360
    if (!sectionId) {
321!
UNCOV
361
      return;
1,521✔
362
    }
1,521✔
363
    if (firstSectionId === sectionId) {
1,842✔
364
      objectPageRef.current?.scrollTo({ top: 0 });
1,619✔
365
    } else {
366
      scrollToSectionById(sectionId);
1,744✔
367
    }
1,521!
368
    isProgrammaticallyScrolled.current = false;
1,842!
369
  };
1,521✔
370

371
  // section was selected by clicking on the tab bar buttons
372
  const handleOnSectionSelected = (targetEvent, newSelectionSectionId, index: number | string, section) => {
17,654✔
373
    isProgrammaticallyScrolled.current = true;
365✔
374
    debouncedOnSectionChange.cancel();
1,886✔
375
    setSelectedSubSectionId(undefined);
365✔
376
    setInternalSelectedSectionId((prevSelectedSection) => {
365✔
377
      if (prevSelectedSection === newSelectionSectionId) {
2,251✔
378
        scrollToSection(newSelectionSectionId);
120✔
379
      }
380
      return newSelectionSectionId;
4,747✔
381
    });
4,017✔
382
    scrollEvent.current = targetEvent;
4,382✔
383
    fireOnSelectedChangedEvent(targetEvent, index, newSelectionSectionId, section);
365✔
384
  };
4,017✔
385

3,613✔
386
  useEffect(() => {
17,654✔
387
    if (selectedSectionId) {
1,140✔
388
      const selectedSection = getSectionElementById(objectPageRef.current, false, selectedSectionId);
4,053✔
389
      if (selectedSection) {
4,027✔
390
        const selectedSectionIndex = Array.from(
36✔
391
          selectedSection.parentElement.querySelectorAll(':scope > [data-component-name="ObjectPageSection"]')
392
        ).indexOf(selectedSection);
393
        handleOnSectionSelected({}, selectedSectionId, selectedSectionIndex, selectedSection);
19,014✔
394
      }
412✔
395
    }
412✔
396
  }, [selectedSectionId]);
412✔
397

412✔
398
  // do internal scrolling
237✔
399
  useEffect(() => {
17,779✔
400
    if (mode === ObjectPageMode.Default && isProgrammaticallyScrolled.current === true && !selectedSubSectionId) {
1,838!
401
      scrollToSection(internalSelectedSectionId);
326✔
402
    }
403
  }, [internalSelectedSectionId, mode, selectedSubSectionId]);
404

237✔
405
  // Scrolling for Sub Section Selection
237✔
406
  useEffect(() => {
17,654✔
407
    if (selectedSubSectionId && isProgrammaticallyScrolled.current === true && sectionSpacer) {
2,715✔
408
      scrollToSectionById(selectedSubSectionId, true);
279✔
409
      isProgrammaticallyScrolled.current = false;
195✔
410
    }
411
  }, [selectedSubSectionId, isProgrammaticallyScrolled.current, sectionSpacer]);
412

413
  useEffect(() => {
17,654✔
414
    if (headerPinnedProp !== undefined) {
20,246✔
415
      setHeaderPinned(headerPinnedProp);
180✔
416
    }
18,978✔
417
    if (headerPinnedProp) {
20,246✔
418
      onToggleHeaderContentVisibility({ detail: { visible: true } });
3,614✔
419
    }
420
  }, [headerPinnedProp]);
3,534✔
421

422
  const prevHeaderPinned = useRef(headerPinned);
21,188✔
423
  useEffect(() => {
17,654✔
424
    if (prevHeaderPinned.current && !headerPinned && objectPageRef.current.scrollTop > topHeaderHeight) {
4,953✔
425
      onToggleHeaderContentVisibility({ detail: { visible: false } });
12,651✔
426
      prevHeaderPinned.current = false;
12,651✔
427
    }
3,953✔
428
    if (!prevHeaderPinned.current && headerPinned) {
2,129✔
429
      prevHeaderPinned.current = true;
8,718✔
430
    }
431
  }, [headerPinned, topHeaderHeight]);
432

433
  useEffect(() => {
30,224✔
434
    if (!isMounted) {
136,943✔
435
      setIsMounted(true);
136,367✔
436
      return;
13,309✔
437
    }
438
    setSelectedSubSectionId(props.selectedSubSectionId);
135,815✔
439
    if (props.selectedSubSectionId) {
576✔
440
      isProgrammaticallyScrolled.current = true;
24✔
441
      if (mode === ObjectPageMode.IconTabBar) {
12,594!
442
        let sectionId: string;
12,181✔
UNCOV
443
        childrenArray.forEach((section) => {
12,181✔
UNCOV
444
          if (isValidElement(section) && section.props && section.props.children) {
12,181!
UNCOV
445
            safeGetChildrenArray(section.props.children).forEach((subSection) => {
12,181✔
446
              if (
×
447
                isValidElement(subSection) &&
×
448
                subSection.props &&
449
                (subSection as ReactElement<ObjectPageSubSectionPropTypes>).props.id === props.selectedSubSectionId
450
              ) {
451
                sectionId = section.props?.id;
452
              }
453
            });
454
          }
455
        });
3,534✔
UNCOV
456
        if (sectionId) {
13,185!
457
          setInternalSelectedSectionId(sectionId);
458
        }
459
      }
3,534✔
460
    }
3,507✔
461
  }, [props.selectedSubSectionId, isMounted]);
462

463
  const tabContainerContainerRef = useRef(null);
17,654✔
464
  const isHeaderPinnedAndExpanded = headerPinned && !headerCollapsed;
36,632✔
465
  useEffect(() => {
17,742✔
466
    const objectPage = objectPageRef.current;
4,304✔
467
    const tabContainerContainer = tabContainerContainerRef.current;
4,260✔
468

469
    if (!objectPage || !tabContainerContainer) {
4,216✔
470
      return;
48✔
471
    }
18,978✔
472

473
    const footerElement = objectPage.querySelector<HTMLDivElement>('[data-component-name="ObjectPageFooter"]');
23,146✔
474
    const topHeaderElement = objectPage.querySelector('[data-component-name="ObjectPageTopHeader"]');
5,368✔
475

601✔
476
    const calculateSpacer = ([lastSectionNodeEntry]: ResizeObserverEntry[]) => {
4,168✔
477
      const lastSectionNode = lastSectionNodeEntry?.target;
3,836✔
478

479
      if (!lastSectionNode) {
3,237!
480
        setSectionSpacer(0);
UNCOV
481
        return;
18,978✔
482
      }
18,978✔
483

18,532✔
484
      const subSections = lastSectionNode.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSubSection"]');
3,237✔
485
      const lastSubSection = subSections[subSections.length - 1];
3,237✔
486
      const lastSubSectionOrSection = lastSubSection ?? lastSectionNode;
3,237✔
487

18,532✔
488
      if ((currentTabModeSection && !lastSubSection) || (sectionNodes.length === 1 && !lastSubSection)) {
3,237✔
489
        setSectionSpacer(0);
1,775✔
490
        return;
1,775✔
491
      }
492

493
      // batching DOM-reads together minimizes reflow
494
      const footerHeight = footerElement?.offsetHeight ?? 0;
1,462✔
495
      const objectPageRect = objectPage.getBoundingClientRect();
1,462✔
496
      const tabContainerContainerRect = tabContainerContainer.getBoundingClientRect();
1,462✔
497
      const lastSubSectionOrSectionRect = lastSubSectionOrSection.getBoundingClientRect();
1,462✔
498

499
      let stickyHeaderBottom = 0;
1,462✔
500
      if (!isHeaderPinnedAndExpanded) {
1,462!
501
        const topHeaderBottom = topHeaderElement?.getBoundingClientRect().bottom ?? 0;
1,462!
502
        stickyHeaderBottom = topHeaderBottom + tabContainerContainerRect.height;
1,462✔
503
      } else {
504
        stickyHeaderBottom = tabContainerContainerRect.bottom;
505
      }
506

507
      const spacer = Math.ceil(
1,462✔
508
        objectPageRect.bottom - stickyHeaderBottom - lastSubSectionOrSectionRect.height - footerHeight // section padding (8px) not included, so that the intersection observer is triggered correctly
18,978✔
509
      );
18,978✔
510
      setSectionSpacer(Math.max(spacer, 0));
1,462✔
511
    };
4,894✔
512

4,894✔
513
    const observer = new ResizeObserver(calculateSpacer);
4,515✔
514
    const sectionNodes = objectPage.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSection"]');
4,168✔
515
    const lastSectionNode = sectionNodes[sectionNodes.length - 1];
9,062✔
516

3,461✔
517
    if (lastSectionNode) {
4,168✔
518
      observer.observe(lastSectionNode, { box: 'border-box' });
5,197✔
519
    }
1,433✔
520

1,433!
521
    return () => {
4,168✔
522
      observer.disconnect();
4,142✔
523
    };
1,433!
524
  }, [topHeaderHeight, headerContentHeight, currentTabModeSection, children, mode, isHeaderPinnedAndExpanded]);
525

526
  const onToggleHeaderContentVisibility = useCallback((e) => {
19,087!
527
    isToggledRef.current = true;
412✔
528
    scrollTimeout.current = performance.now() + 500;
412✔
529
    if (!e.detail.visible) {
1,845✔
530
      setHeaderCollapsedInternal(true);
1,506✔
531
      objectPageRef.current?.classList.add(classNames.headerCollapsed);
237✔
532
    } else {
1,433✔
533
      setHeaderCollapsedInternal(false);
215!
534
      setScrolledHeaderExpanded(true);
175✔
535
      objectPageRef.current?.classList.remove(classNames.headerCollapsed);
175✔
536
    }
40✔
537
  }, []);
40✔
538

20✔
539
  const handleOnSubSectionSelected = (e) => {
17,654✔
540
    isProgrammaticallyScrolled.current = true;
120✔
541
    if (mode === ObjectPageMode.IconTabBar) {
80✔
542
      const sectionId = e.detail.sectionId;
48✔
543
      setInternalSelectedSectionId(sectionId);
48✔
544
      const sectionNodes = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
48✔
545
      const currentIndex = childrenArray.findIndex((objectPageSection) => {
48✔
546
        return (
19,128✔
547
          isValidElement(objectPageSection) &&
362✔
548
          (objectPageSection as ReactElement<ObjectPagePropTypes>).props?.id === sectionId
258✔
549
        );
550
      });
104✔
551
      debouncedOnSectionChange(e, currentIndex, sectionId, sectionNodes[currentIndex]);
48✔
552
    }
553
    const subSectionId = e.detail.subSectionId;
80✔
554
    scrollTimeout.current = performance.now() + 200;
19,058✔
555
    setSelectedSubSectionId(subSectionId);
80✔
556
  };
557

558
  const objectPageClasses = clsx(
17,654✔
559
    classNames.objectPage,
560
    className,
561
    mode === ObjectPageMode.IconTabBar && classNames.iconTabBarMode,
21,726✔
562
    footerArea && classNames.withFooter
20,176✔
563
  );
564

565
  const { onScroll: _0, selectedSubSectionId: _1, ...propsWithoutOmitted } = rest;
17,654✔
566

567
  const visibleSectionIds = useRef<Set<string>>(new Set());
17,654✔
568
  useEffect(() => {
17,654✔
569
    const sectionNodes = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
3,532✔
570
    // only the sticky part of the header must be added as margin
18,978✔
571
    const rootMargin = `-${(headerPinned && !headerCollapsed ? totalHeaderHeight : topHeaderHeight) + TAB_CONTAINER_HEADER_HEIGHT}px 0px 0px 0px`;
3,532✔
572

573
    const observer = new IntersectionObserver(
22,510✔
574
      (entries) => {
6,572✔
575
        entries.forEach((entry) => {
2,073✔
576
          const sectionId = entry.target.id;
10,914✔
577
          if (entry.isIntersecting) {
29,892✔
578
            visibleSectionIds.current.add(sectionId);
2,941✔
579
          } else {
580
            visibleSectionIds.current.delete(sectionId);
7,973✔
581
          }
582

583
          let currentIndex: undefined | number;
584
          const sortedVisibleSections = Array.from(sectionNodes).filter((section, index) => {
10,914✔
585
            const isVisibleSection = visibleSectionIds.current.has(section.id);
129,126✔
586
            if (currentIndex === undefined && isVisibleSection) {
129,126✔
587
              currentIndex = index;
10,487✔
588
            }
589
            return visibleSectionIds.current.has(section.id);
129,126✔
590
          });
591

592
          if (sortedVisibleSections.length > 0) {
10,914✔
593
            const section = sortedVisibleSections[0];
10,487✔
594
            const id = sortedVisibleSections[0].id.slice(18);
10,487✔
595
            setInternalSelectedSectionId(id);
10,487✔
596
            debouncedOnSectionChange(scrollEvent.current, currentIndex, id, section);
10,487✔
597
          }
598
        });
599
      },
37,956✔
600
      {
601
        root: objectPageRef.current,
602
        rootMargin,
603
        threshold: [0]
56,740✔
604
      }
605
    );
606
    sectionNodes.forEach((el) => {
3,532✔
607
      observer.observe(el);
12,349✔
608
    });
609

610
    return () => {
3,532✔
611
      observer.disconnect();
3,505✔
612
    };
37,778✔
613
  }, [totalHeaderHeight, headerPinned, headerCollapsed, topHeaderHeight, childrenArray.length]);
614

615
  const onTitleClick = (e) => {
17,654✔
616
    e.stopPropagation();
88✔
617
    if (!preserveHeaderStateOnClick) {
88✔
618
      onToggleHeaderContentVisibility(enrichEventWithDetails(e, { visible: headerCollapsed }));
44✔
619
    }
620
  };
37,066✔
621

622
  const snappedHeaderInObjPage = titleArea && titleArea.props.snappedContent && headerCollapsed === true && !!image;
17,654✔
623

18,994✔
624
  useEffect(() => {
17,654✔
625
    if (!isInitial.current) {
1,130✔
626
      scrollTimeout.current = performance.now() + 200;
566✔
627
    } else {
628
      isInitial.current = false;
564✔
629
    }
630
  }, [snappedHeaderInObjPage]);
55,952✔
631

632
  const renderHeaderContentSection = () => {
17,654✔
633
    if (headerArea?.props) {
17,654✔
634
      return cloneElement(headerArea as ReactElement<ObjectPageHeaderPropTypesWithInternals>, {
17,230✔
635
        ...headerArea.props,
636
        topHeaderHeight,
637
        style:
54,762✔
638
          headerCollapsed === true
18,278!
639
            ? { ...headerArea.props.style, position: 'absolute', visibility: 'hidden', flexShrink: 0, insetInline: 0 }
640
            : { ...headerArea.props.style, flexShrink: 0 },
641
        headerPinned: headerPinned || scrolledHeaderExpanded,
33,576✔
642
        //@ts-expect-error: todo remove me when forwardref has been replaced
643
        ref: componentRefHeaderContent,
36,884✔
644
        children: (
645
          <div
646
            className={clsx(classNames.headerContainer, avatar && classNames.hasAvatar)}
17,390✔
647
            data-component-name="ObjectPageHeaderContainer"
648
          >
649
            {avatar}
650
            {headerArea.props.children && (
34,460✔
651
              <div data-component-name="ObjectPageHeaderContent">{headerArea.props.children}</div>
652
            )}
653
          </div>
654
        )
37,876✔
655
      });
656
    }
657
  };
658

659
  const onTabItemSelect = (event) => {
17,654✔
660
    if (typeof onBeforeNavigate === 'function') {
423✔
661
      const selectedTabDataset = event.detail.tab.dataset;
14✔
662
      const sectionIndex = parseInt(selectedTabDataset.index, 10);
14!
663
      const sectionId = selectedTabDataset.parentId ?? selectedTabDataset.sectionId;
14✔
664
      const subSectionId = Object.prototype.hasOwnProperty.call(selectedTabDataset, 'isSubTab')
14✔
665
        ? selectedTabDataset.sectionId
666
        : undefined;
667
      onBeforeNavigate(
14✔
668
        enrichEventWithDetails(event, {
669
          sectionIndex,
670
          sectionId,
671
          subSectionId
672
        })
673
      );
82,852!
674
      if (event.defaultPrevented) {
82,866✔
675
        return;
14✔
676
      }
677
    }
678
    event.preventDefault();
409✔
679
    const { sectionId, index, isSubTab, parentId } = event.detail.tab.dataset;
114,459✔
680

681
    if (isSubTab !== undefined) {
83,261✔
682
      handleOnSubSectionSelected(enrichEventWithDetails(event, { sectionId: parentId, subSectionId: sectionId }));
80✔
683
    } else {
684
      const section = childrenArray.find((el) => {
329✔
685
        return el.props.id == sectionId;
1,219✔
686
      });
687
      handleOnSectionSelected(event, section?.props?.id, index, section);
329✔
688
    }
689
  };
45,496!
690

691
  const prevScrollTop = useRef(undefined);
17,654✔
692
  const onObjectPageScroll = useCallback(
63,150✔
693
    (e) => {
694
      if (!isToggledRef.current) {
5,827✔
695
        isToggledRef.current = true;
313✔
696
      }
697
      if (scrollTimeout.current >= performance.now()) {
5,827✔
698
        return;
4,113✔
699
      }
89,920✔
700
      scrollEvent.current = e;
1,714✔
701
      if (typeof props.onScroll === 'function') {
1,714!
702
        props.onScroll(e);
703
      }
704
      if (selectedSubSectionId) {
1,714!
705
        setSelectedSubSectionId(undefined);
706
      }
707
      if (selectionScrollTimeout.current) {
1,714!
708
        clearTimeout(selectionScrollTimeout.current);
709
      }
710
      if (!headerPinned || e.target.scrollTop === 0) {
1,714✔
711
        objectPageRef.current?.classList.remove(classNames.headerCollapsed);
1,591✔
712
      }
713
      if (scrolledHeaderExpanded && e.target.scrollTop !== prevScrollTop.current) {
1,714✔
714
        if (e.target.scrollHeight - e.target.scrollTop === e.target.clientHeight) {
40!
715
          return;
716
        }
717
        prevScrollTop.current = e.target.scrollTop;
40✔
718
        if (!headerPinned) {
40✔
719
          setHeaderCollapsedInternal(true);
20✔
720
        }
721
        setScrolledHeaderExpanded(false);
40✔
722
      }
162!
723
    },
162✔
724
    [topHeaderHeight, headerPinned, props.onScroll, scrolledHeaderExpanded, selectedSubSectionId]
70✔
725
  );
726

727
  const onHoverToggleButton = useCallback(
17,654✔
728
    (e) => {
13✔
729
      if (e?.type === 'mouseover') {
375✔
730
        topHeaderRef.current?.classList.add(classNames.headerHoverStyles);
271✔
731
      } else {
732
        topHeaderRef.current?.classList.remove(classNames.headerHoverStyles);
104✔
733
      }
734
    },
735
    [classNames.headerHoverStyles]
736
  );
64,100✔
737

738
  const objectPageStyles: CSSProperties = {
17,654✔
739
    ...style,
740
    scrollPaddingBlockStart: `${Math.ceil(topHeaderHeight + TAB_CONTAINER_HEADER_HEIGHT + (!headerCollapsed && headerPinned ? headerContentHeight : 0))}px`
50,372✔
741
  };
742
  if (headerCollapsed === true && headerArea) {
17,654✔
743
    objectPageStyles[ObjectPageCssVariables.titleFontSize] = ThemingParameters.sapObjectHeader_Title_SnappedFontSize;
2,590✔
744
  }
745

746
  return (
17,654✔
747
    <div
748
      data-component-name="ObjectPage"
749
      slot={slot}
22,350✔
750
      className={objectPageClasses}
751
      style={objectPageStyles}
752
      ref={componentRef}
753
      onScroll={onObjectPageScroll}
754
      data-in-iframe={window && window.self !== window.top}
35,308✔
755
      {...propsWithoutOmitted}
756
    >
757
      <header
758
        onMouseOver={onHoverToggleButton}
426✔
759
        onMouseLeave={onHoverToggleButton}
760
        data-component-name="ObjectPageTopHeader"
761
        ref={topHeaderRef}
762
        role={accessibilityAttributes?.objectPageTopHeader?.role}
763
        data-not-clickable={!!preserveHeaderStateOnClick}
764
        aria-roledescription={accessibilityAttributes?.objectPageTopHeader?.ariaRoledescription ?? 'Object Page header'}
35,308✔
765
        className={classNames.header}
766
        style={{
767
          gridAutoColumns: `min-content ${
768
            titleArea && image && headerCollapsed === true ? `calc(100% - 3rem - 1rem)` : '100%'
52,782✔
769
          }`
770
        }}
771
      >
772
        <span
773
          className={classNames.clickArea}
774
          onClick={onTitleClick}
775
          data-component-name="ObjectPageTitleAreaClickElement"
776
        />
777
        {titleArea && image && headerCollapsed === true && (
35,142✔
778
          <CollapsedAvatar image={image} imageShapeCircle={imageShapeCircle} />
779
        )}
780
        {titleArea &&
34,952✔
781
          cloneElement(titleArea as ReactElement<ObjectPageTitlePropsWithDataAttributes>, {
782
            className: clsx(titleArea?.props?.className),
783
            onToggleHeaderContentVisibility: onTitleClick,
784
            'data-not-clickable': !!preserveHeaderStateOnClick,
785
            'data-header-content-visible': headerArea && headerCollapsed !== true,
34,426✔
786
            'data-is-snapped-rendered-outside': snappedHeaderInObjPage
787
          })}
788
        {snappedHeaderInObjPage && (
17,668✔
789
          <div className={classNames.snappedContent} data-component-name="ATwithImageSnappedContentContainer">
790
            {titleArea.props.snappedContent}
791
          </div>
792
        )}
793
      </header>
794
      {renderHeaderContentSection()}
795
      {headerArea && titleArea && (
52,012✔
796
        <div
797
          data-component-name="ObjectPageAnchorBar"
798
          ref={anchorBarRef}
799
          className={classNames.anchorBar}
800
          style={{
801
            top:
802
              scrolledHeaderExpanded || headerPinned
49,430✔
803
                ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight)}px`
2,278✔
804
                : `${topHeaderHeight}px`
805
          }}
806
        >
807
          <ObjectPageAnchorBar
808
            headerContentVisible={headerArea && headerCollapsed !== true}
34,256✔
809
            hidePinButton={!!hidePinButton}
810
            headerPinned={headerPinned}
811
            accessibilityAttributes={accessibilityAttributes}
812
            onToggleHeaderContentVisibility={onToggleHeaderContentVisibility}
813
            setHeaderPinned={setHeaderPinned}
814
            onHoverToggleButton={onHoverToggleButton}
815
            onPinButtonToggle={onPinButtonToggle}
816
          />
817
        </div>
818
      )}
819
      {!placeholder && (
35,212✔
820
        <div
821
          ref={tabContainerContainerRef}
822
          className={classNames.tabContainer}
823
          data-component-name="ObjectPageTabContainer"
824
          style={{
825
            top:
826
              headerPinned || scrolledHeaderExpanded
51,790✔
827
                ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight)}px`
2,278✔
828
                : `${topHeaderHeight}px`
829
          }}
830
        >
831
          <TabContainer
832
            collapsed
833
            onTabSelect={onTabItemSelect}
834
            data-component-name="ObjectPageTabContainer"
835
            className={classNames.tabContainerComponent}
836
          >
837
            {childrenArray.map((section, index) => {
838
              if (!isValidElement(section) || !section.props) return null;
78,240!
839
              const subTabs = safeGetChildrenArray<ReactElement<ObjectPageSubSectionPropTypes>>(
78,240✔
840
                section.props.children
841
              ).filter(
842
                (subSection) =>
843
                  // @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.
844
                  isValidElement(subSection) && subSection?.type?.displayName === 'ObjectPageSubSection'
104,492✔
845
              );
846
              return (
78,240✔
847
                <Tab
848
                  key={`Anchor-${section.props?.id}`}
849
                  data-index={index}
850
                  data-section-id={section.props.id}
851
                  text={section.props.titleText}
852
                  selected={internalSelectedSectionId === section.props?.id || undefined}
139,238✔
853
                  items={subTabs.map((item) => {
854
                    if (!isValidElement(item)) {
38,436!
855
                      return null;
856
                    }
857
                    return (
38,436✔
858
                      <Tab
859
                        data-parent-id={section.props.id}
860
                        key={item.props.id}
861
                        data-is-sub-tab
862
                        data-section-id={item.props.id}
863
                        text={item.props.titleText}
864
                        selected={item.props.id === selectedSubSectionId || undefined}
76,090✔
865
                        data-index={index}
866
                      >
867
                        {/*ToDo: workaround for nested tab selection*/}
868
                        <span style={{ display: 'none' }} />
869
                      </Tab>
870
                    );
871
                  })}
872
                >
873
                  {/*ToDo: workaround for nested tab selection*/}
874
                  <span style={{ display: 'none' }} />
875
                </Tab>
876
              );
877
            })}
878
          </TabContainer>
879
        </div>
880
      )}
881
      <div data-component-name="ObjectPageContent" className={classNames.content} ref={objectPageContentRef}>
882
        <div style={{ height: headerCollapsed && !headerPinned ? `${headerContentHeight}px` : 0 }} aria-hidden />
37,898✔
883
        {placeholder ? placeholder : sections}
17,654✔
884
        <div style={{ height: `${sectionSpacer}px` }} aria-hidden />
885
      </div>
886
      {footerArea && mode === ObjectPageMode.IconTabBar && !sectionSpacer && (
22,808✔
887
        <div className={classNames.footerSpacer} data-component-name="ObjectPageFooterSpacer" aria-hidden />
888
      )}
889
      {footerArea && (
20,176✔
890
        <footer className={classNames.footer} data-component-name="ObjectPageFooter">
891
          {footerArea}
892
        </footer>
893
      )}
894
    </div>
895
  );
896
});
897

898
ObjectPage.displayName = 'ObjectPage';
245✔
899

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