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

SAP / ui5-webcomponents-react / 10004391615

19 Jul 2024 07:35AM CUT coverage: 79.91% (+0.2%) from 79.67%
10004391615

Pull #6089

github

web-flow
Merge 7cb3d1a69 into 44c90daf1
Pull Request #6089: feat(ObjectPage): refactor component to support ui5wc v2

2522 of 4036 branches covered (62.49%)

42 of 45 new or added lines in 5 files covered. (93.33%)

13 existing lines in 4 files now uncovered.

4598 of 5754 relevant lines covered (79.91%)

74465.57 hits per line

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

93.16
/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 { addCustomCSSWithScoping } from '../../internal/addCustomCSSWithScoping.js';
17
import { safeGetChildrenArray } from '../../internal/safeGetChildrenArray.js';
18
import { useObserveHeights } from '../../internal/useObserveHeights.js';
19
import type { CommonProps, Ui5CustomEvent } from '../../types/index.js';
20
import type { AvatarPropTypes, TabContainerDomRef } from '../../webComponents/index.js';
21
import { Tab, TabContainer } from '../../webComponents/index.js';
22
import { ObjectPageAnchorBar } from '../ObjectPageAnchorBar/index.js';
23
import type {
24
  InternalProps as ObjectPageHeaderPropTypesWithInternals,
25
  ObjectPageHeaderPropTypes
26
} from '../ObjectPageHeader/index.js';
27
import type { ObjectPageSectionPropTypes } from '../ObjectPageSection/index.js';
28
import type { ObjectPageSubSectionPropTypes } from '../ObjectPageSubSection/index.js';
29
import type {
30
  InternalProps as ObjectPageTitlePropTypesWithInternals,
31
  ObjectPageTitlePropTypes
32
} from '../ObjectPageTitle/index.js';
33
import { CollapsedAvatar } from './CollapsedAvatar.js';
34
import { classNames, styleData } from './ObjectPage.module.css.js';
35
import { extractSectionIdFromHtmlId, getSectionById } from './ObjectPageUtils.js';
36

37
addCustomCSSWithScoping(
452✔
38
  'ui5-tabcontainer',
39
  // todo: the additional text span adds 3px to the container - needs to be investigated why
40
  `
41
  :host([data-component-name="ObjectPageTabContainer"]) [id$="additionalText"] {
42
    display: none;
43
  }
44
  `
45
);
46

47
const ObjectPageCssVariables = {
452✔
48
  headerDisplay: '--_ui5wcr_ObjectPage_header_display',
49
  titleFontSize: '--_ui5wcr_ObjectPage_title_fontsize'
50
};
51

52
const TAB_CONTAINER_HEADER_HEIGHT = 48;
452✔
53

54
type ObjectPageSectionType = ReactElement<ObjectPageSectionPropTypes> | boolean;
55

56
interface BeforeNavigateDetail {
57
  sectionIndex: number;
58
  sectionId: string;
59
  subSectionId: string | undefined;
60
}
61

62
type ObjectPageTabSelectEventDetail = TabContainerTabSelectEventDetail & BeforeNavigateDetail;
63

64
type ObjectPageTitlePropsWithDataAttributes = ObjectPageTitlePropTypesWithInternals & {
65
  'data-not-clickable': boolean;
66
  'data-header-content-visible': boolean;
67
  'data-is-snapped-rendered-outside': boolean;
68
};
69

70
export interface ObjectPagePropTypes extends Omit<CommonProps, 'placeholder'> {
71
  /**
72
   * Defines the upper, always static, title section of the `ObjectPage`.
73
   *
74
   * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `ObjectPageTitle` in order to preserve the intended design.
75
   *
76
   * __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!
77
   */
78
  headerTitle?: ReactElement<ObjectPageTitlePropTypes>;
79
  /**
80
   * Defines the `ObjectPageHeader` section of the `ObjectPage`.
81
   *
82
   * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `ObjectPageHeader` in order to preserve the intended design.
83
   *
84
   * __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!
85
   */
86
  headerContent?: ReactElement<ObjectPageHeaderPropTypes>;
87
  /**
88
   * React element which defines the footer content.
89
   *
90
   * __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.
91
   */
92
  footer?: ReactElement;
93
  /**
94
   * Defines the image of the `ObjectPage`. You can pass a path to an image or an `Avatar` component.
95
   */
96
  image?: string | ReactElement<AvatarPropTypes>;
97
  /**
98
   * Defines the content area of the `ObjectPage`. It consists of sections and subsections.
99
   *
100
   * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `ObjectPageSection` in order to preserve the intended design.
101
   */
102
  children?: ObjectPageSectionType | ObjectPageSectionType[];
103
  /**
104
   * Sets the current selected `ObjectPageSection` by `id`.
105
   *
106
   * __Note:__ If a valid `selectedSubSectionId` is set, this prop has no effect.
107
   */
108
  selectedSectionId?: string;
109
  /**
110
   * Sets the current selected `ObjectPageSubSection` by `id`.
111
   */
112
  selectedSubSectionId?: string;
113
  /**
114
   * Defines whether the `headerContent` is pinned.
115
   */
116
  headerPinned?: boolean;
117
  /**
118
   * Defines whether the image should be displayed in a circle or in a square.<br />
119
   * __Note:__ If the `image` is not a `string`, this prop has no effect.
120
   */
121
  imageShapeCircle?: boolean;
122
  /**
123
   * Defines the `ObjectPage` mode.
124
   *
125
   * - "Default": All `ObjectPageSections` and `ObjectPageSubSections` are displayed on one page. Selecting tabs will scroll to the corresponding section.
126
   * - "IconTabBar": All `ObjectPageSections` are displayed on separate pages. Selecting tabs will lead to the corresponding page.
127
   *
128
   * @default `"Default"`
129
   */
130
  mode?: ObjectPageMode | keyof typeof ObjectPageMode;
131
  /**
132
   * Defines if the pin button for the `headerContent` is hidden.
133
   */
134
  hidePinButton?: boolean;
135
  /**
136
   * Determines whether the user can switch between the expanded/collapsed states of the `ObjectPageHeader` by clicking on the `ObjectPageTitle`.
137
   *
138
   * __Note:__ Per default the header is toggleable.
139
   */
140
  preserveHeaderStateOnClick?: boolean;
141
  /**
142
   * Defines internally used accessibility properties/attributes.
143
   */
144
  accessibilityAttributes?: {
145
    objectPageTopHeader?: {
146
      role?: string;
147
      ariaRoledescription?: string;
148
    };
149
    objectPageAnchorBar?: {
150
      role?: string;
151
    };
152
  };
153
  /**
154
   * If set, only the specified placeholder will be displayed as content of the `ObjectPage`, no sections or sub-sections will be rendered.
155
   *
156
   * __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.
157
   */
158
  placeholder?: ReactNode;
159
  /**
160
   * 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.
161
   *
162
   * __Note:__ This event is only fired when navigating via tab-bar.
163
   */
164
  onBeforeNavigate?: (event: Ui5CustomEvent<TabContainerDomRef, ObjectPageTabSelectEventDetail>) => void;
165
  /**
166
   * Fired when the selected section changes.
167
   */
168
  onSelectedSectionChange?: (
169
    event: CustomEvent<{ selectedSectionIndex: number; selectedSectionId: string; section: HTMLDivElement }>
170
  ) => void;
171
  /**
172
   * Fired when the `headerContent` is expanded or collapsed.
173
   */
174
  onToggleHeaderContent?: (visible: boolean) => void;
175
  /**
176
   * Fired when the `headerContent` changes its pinned state.
177
   */
178
  onPinnedStateChange?: (pinned: boolean) => void;
179
}
180

181
/**
182
 * A component that allows apps to easily display information related to a business object.
183
 *
184
 * The `ObjectPage` is composed of a header (title and content) and block content wrapped in sections and subsections that structure the information.
185
 */
186
const ObjectPage = forwardRef<HTMLDivElement, ObjectPagePropTypes>((props, ref) => {
91✔
187
  const {
188
    headerTitle,
189
    image,
190
    footer,
191
    mode = ObjectPageMode.Default,
×
192
    imageShapeCircle,
193
    className,
194
    style,
195
    slot,
361✔
196
    children,
197
    selectedSectionId,
198
    headerPinned: headerPinnedProp,
199
    headerContent,
200
    hidePinButton,
2,863✔
201
    preserveHeaderStateOnClick,
202
    accessibilityAttributes,
203
    placeholder,
204
    onSelectedSectionChange,
205
    onToggleHeaderContent,
206
    onPinnedStateChange,
207
    onBeforeNavigate,
208
    ...rest
209
  } = props;
210

211
  useStylesheet(styleData, ObjectPage.displayName);
212

213
  const firstSectionId: string | undefined =
214
    safeGetChildrenArray<ReactElement<ObjectPageSectionPropTypes>>(children)[0]?.props?.id;
215

216
  const [internalSelectedSectionId, setInternalSelectedSectionId] = useState<string | undefined>(
217
    selectedSectionId ?? firstSectionId
×
218
  );
5,565✔
219
  const [selectedSubSectionId, setSelectedSubSectionId] = useState(props.selectedSubSectionId);
220
  const [headerPinned, setHeaderPinned] = useState(headerPinnedProp);
5,565✔
221
  const isProgrammaticallyScrolled = useRef(false);
222
  const prevSelectedSectionId = useRef<string | undefined>(undefined);
223

5,565✔
224
  const [componentRef, objectPageRef] = useSyncRef(ref);
225
  const topHeaderRef = useRef<HTMLDivElement>(null);
5,565✔
226
  const scrollEvent = useRef(undefined);
11,130✔
227
  const prevTopHeaderHeight = useRef(0);
228
  // @ts-expect-error: useSyncRef will create a ref if not present
5,565✔
229
  const [componentRefHeaderContent, headerContentRef] = useSyncRef(headerContent?.ref);
5,565✔
230
  const anchorBarRef = useRef<HTMLDivElement>(null);
5,565✔
231
  const objectPageContentRef = useRef<HTMLDivElement>(null);
5,565✔
232
  const selectionScrollTimeout = useRef(null);
233
  const [isAfterScroll, setIsAfterScroll] = useState(false);
5,565✔
234
  const isToggledRef = useRef(false);
5,565✔
235
  const [headerCollapsedInternal, setHeaderCollapsedInternal] = useState<undefined | boolean>(undefined);
5,565✔
236
  const [scrolledHeaderExpanded, setScrolledHeaderExpanded] = useState(false);
5,565✔
237
  const scrollTimeout = useRef(0);
238
  const [sectionSpacer, setSectionSpacer] = useState(0);
5,565✔
239
  const [currentTabModeSection, setCurrentTabModeSection] = useState(null);
5,565✔
240
  const sections = mode === ObjectPageMode.IconTabBar ? currentTabModeSection : children;
5,565!
241

5,565✔
242
  useEffect(() => {
5,565✔
243
    const currentSection =
5,565✔
244
      mode === ObjectPageMode.IconTabBar ? getSectionById(children, internalSelectedSectionId) : null;
5,565!
245
    setCurrentTabModeSection(currentSection);
5,565✔
246
  }, [mode, children, internalSelectedSectionId]);
5,565✔
247

5,565✔
248
  const prevInternalSelectedSectionId = useRef(internalSelectedSectionId);
5,565✔
249
  const fireOnSelectedChangedEvent = (targetEvent, index, id, section) => {
5,565✔
250
    if (typeof onSelectedSectionChange === 'function' && prevInternalSelectedSectionId.current !== id) {
×
251
      onSelectedSectionChange(
5,565✔
252
        enrichEventWithDetails(targetEvent, {
253
          selectedSectionIndex: parseInt(index, 10),
1,286✔
254
          selectedSectionId: id,
1,286✔
255
          section
256
        })
257
      );
5,565✔
258
      prevInternalSelectedSectionId.current = id;
5,565✔
259
    }
349!
260
  };
261
  const debouncedOnSectionChange = useRef(debounce(fireOnSelectedChangedEvent, 500)).current;
262
  useEffect(() => {
263
    return () => {
264
      debouncedOnSectionChange.cancel();
265
      clearTimeout(selectionScrollTimeout.current);
266
    };
267
  }, []);
268

269
  // observe heights of header parts
270
  const { topHeaderHeight, headerContentHeight, anchorBarHeight, totalHeaderHeight, headerCollapsed } =
5,565✔
271
    useObserveHeights(
5,565✔
272
      objectPageRef,
386✔
273
      topHeaderRef,
363✔
274
      headerContentRef,
363✔
275
      anchorBarRef,
276
      [headerCollapsedInternal, setHeaderCollapsedInternal],
277
      {
278
        noHeader: !headerTitle && !headerContent,
×
279
        fixedHeader: headerPinned,
280
        scrollTimeout
5,565✔
281
      }
282
    );
283

284
  useEffect(() => {
285
    if (typeof onToggleHeaderContent === 'function' && isToggledRef.current) {
×
286
      onToggleHeaderContent(headerCollapsed !== true);
287
    }
5,601✔
288
  }, [headerCollapsed]);
289

290
  const avatar = useMemo(() => {
291
    if (!image) {
×
292
      return null;
293
    }
5,565✔
294

805✔
295
    if (typeof image === 'string') {
76!
296
      return (
297
        <span
298
          className={classNames.headerImage}
299
          style={{ borderRadius: imageShapeCircle ? '50%' : 0, overflow: 'hidden' }}
5,565!
300
        >
805✔
301
          <img src={image} className={classNames.image} alt="Company Logo" />
805✔
302
        </span>
805✔
303
      );
304
    } else {
×
NEW
305
      return cloneElement(image, {
×
306
        size: AvatarSize.L,
307
        className: clsx(classNames.headerImage, image.props?.className)
308
      } as AvatarPropTypes);
309
    }
310
  }, [image, classNames.headerImage, classNames.image, imageShapeCircle]);
311

312
  const scrollToSectionById = (id?: string, isSubSection = false) => {
×
313
    const section = objectPageRef.current?.querySelector<HTMLElement>(
314
      `#${isSubSection ? 'ObjectPageSubSection' : 'ObjectPageSection'}-${CSS.escape(id)}`
5,565!
315
    );
386✔
316
    scrollTimeout.current = performance.now() + 500;
364✔
317
    if (section) {
×
318
      const safeTopHeaderHeight = topHeaderHeight || prevTopHeaderHeight.current;
×
319
      section.style.scrollMarginBlockStart =
22✔
320
        safeTopHeaderHeight +
12✔
321
        anchorBarHeight +
322
        TAB_CONTAINER_HEADER_HEIGHT +
323
        (headerPinned ? headerContentHeight : 0) +
12!
324
        'px';
325
      section.focus();
326
      section.scrollIntoView({ behavior: 'smooth' });
327
      section.style.scrollMarginBlockStart = '0px';
328
    }
329
  };
10✔
330

331
  const scrollToSection = (sectionId?: string) => {
332
    if (!sectionId) {
×
333
      return;
334
    }
335
    if (firstSectionId === sectionId) {
×
336
      objectPageRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
5,565✔
337
    } else {
177✔
338
      scrollToSectionById(sectionId);
177✔
339
    }
340
    isProgrammaticallyScrolled.current = false;
177✔
341
  };
177✔
342

177!
343
  const programmaticallySetSection = () => {
177✔
344
    const currentId = selectedSectionId ?? firstSectionId;
×
345
    if (currentId !== prevSelectedSectionId.current) {
×
346
      debouncedOnSectionChange.cancel();
347
      isProgrammaticallyScrolled.current = true;
177!
348
      setInternalSelectedSectionId(currentId);
349
      prevSelectedSectionId.current = currentId;
177✔
350
      const sectionNodes = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
177✔
351
      const currentIndex = safeGetChildrenArray(children).findIndex((objectPageSection) => {
177✔
352
        return (
353
          isValidElement(objectPageSection) &&
×
354
          (objectPageSection as ReactElement<ObjectPageSectionPropTypes>).props?.id === currentId
355
        );
5,565✔
356
      });
138!
357
      fireOnSelectedChangedEvent({}, currentIndex, currentId, sectionNodes[0]);
×
358
    }
359
  };
138✔
360

56✔
361
  // change selected section when prop is changed (external change)
362
  const [timeStamp, setTimeStamp] = useState(0);
82✔
363
  const requestAnimationFrameRef = useRef<undefined | number>(undefined);
364
  useEffect(() => {
138✔
365
    if (selectedSectionId) {
×
366
      if (mode === ObjectPageMode.Default) {
×
367
        // wait for DOM draw, otherwise initial scroll won't work as intended
5,565✔
368
        if (timeStamp < 750 && timeStamp !== undefined) {
×
369
          requestAnimationFrameRef.current = requestAnimationFrame((internalTimestamp) => {
×
370
            setTimeStamp(internalTimestamp);
×
371
          });
372
        } else {
373
          setTimeStamp(undefined);
×
374
          programmaticallySetSection();
×
375
        }
376
      } else {
377
        programmaticallySetSection();
×
378
      }
379
    }
380
    return () => {
381
      cancelAnimationFrame(requestAnimationFrameRef.current);
×
382
    };
383
  }, [timeStamp, selectedSectionId, firstSectionId, debouncedOnSectionChange]);
384

385
  // section was selected by clicking on the tab bar buttons
386
  const handleOnSectionSelected = (targetEvent, newSelectionSectionId, index, section) => {
5,565✔
387
    isProgrammaticallyScrolled.current = true;
5,565✔
388
    debouncedOnSectionChange.cancel();
5,565✔
389
    setInternalSelectedSectionId((prevSelectedSection) => {
386!
390
      if (prevSelectedSection === newSelectionSectionId) {
×
391
        scrollToSection(newSelectionSectionId);
392
      }
×
393
      return newSelectionSectionId;
×
394
    });
395
    scrollEvent.current = targetEvent;
396
    fireOnSelectedChangedEvent(targetEvent, index, newSelectionSectionId, section);
397
  };
398

399
  // do internal scrolling
400
  useEffect(() => {
401
    if (mode === ObjectPageMode.Default && isProgrammaticallyScrolled.current === true && !selectedSubSectionId) {
×
402
      scrollToSection(internalSelectedSectionId);
403
    }
404
  }, [internalSelectedSectionId, mode, isProgrammaticallyScrolled, scrollToSection, selectedSubSectionId]);
386✔
405

363✔
406
  // Scrolling for Sub Section Selection
407
  useEffect(() => {
408
    if (selectedSubSectionId && isProgrammaticallyScrolled.current === true && sectionSpacer) {
×
409
      scrollToSectionById(selectedSubSectionId, true);
410
      isProgrammaticallyScrolled.current = false;
5,565✔
411
    }
242✔
412
  }, [selectedSubSectionId, isProgrammaticallyScrolled.current, sectionSpacer]);
242✔
413

242✔
414
  useEffect(() => {
242✔
415
    if (headerPinnedProp !== undefined) {
30!
416
      setHeaderPinned(headerPinnedProp);
417
    }
242✔
418
    if (headerPinnedProp) {
×
419
      onToggleHeaderContentVisibility({ detail: { visible: true } });
242✔
420
    }
242✔
421
  }, [headerPinnedProp]);
422

423
  const prevHeaderPinned = useRef(headerPinned);
424
  useEffect(() => {
5,565✔
425
    if (prevHeaderPinned.current && !headerPinned && objectPageRef.current.scrollTop > topHeaderHeight) {
3,950!
426
      onToggleHeaderContentVisibility({ detail: { visible: false } });
108✔
427
      prevHeaderPinned.current = false;
428
    }
429
    if (!prevHeaderPinned.current && headerPinned) {
×
430
      prevHeaderPinned.current = true;
431
    }
5,565✔
432
  }, [headerPinned, topHeaderHeight]);
1,103✔
433

95✔
434
  useEffect(() => {
95✔
435
    setSelectedSubSectionId(props.selectedSubSectionId);
436
    if (props.selectedSubSectionId) {
×
437
      isProgrammaticallyScrolled.current = true;
438
      if (mode === ObjectPageMode.IconTabBar) {
5,565!
439
        let sectionId;
505✔
440
        safeGetChildrenArray<ReactElement<ObjectPageSectionPropTypes>>(children).forEach((section) => {
136✔
441
          if (isValidElement(section) && section.props && section.props.children) {
×
442
            safeGetChildrenArray(section.props.children).forEach((subSection) => {
505✔
443
              if (
68!
444
                isValidElement(subSection) &&
×
445
                subSection.props &&
446
                (subSection as ReactElement<ObjectPageSubSectionPropTypes>).props.id === props.selectedSubSectionId
447
              ) {
5,565✔
448
                sectionId = section.props?.id;
5,565✔
449
              }
1,164✔
450
            });
68✔
451
          }
68✔
452
        });
453
        if (sectionId) {
1,164!
454
          setInternalSelectedSectionId(sectionId);
86✔
455
        }
456
      }
457
    }
458
  }, [props.selectedSubSectionId, children, mode]);
5,565✔
459

874✔
460
  const tabContainerContainerRef = useRef(null);
874!
461
  useEffect(() => {
×
462
    const objectPage = objectPageRef.current;
×
463
    const sectionNodes = objectPage.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSection"]');
464
    const lastSectionNode = sectionNodes[sectionNodes.length - 1];
×
465
    const tabContainerContainer = tabContainerContainerRef.current;
×
466

467
    const observer = new ResizeObserver(([sectionElement]) => {
×
468
      const subSections = lastSectionNode.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSubSection"]');
×
469
      const lastSubSection = subSections[subSections.length - 1];
470
      const lastSubSectionOrSection = lastSubSection ?? sectionElement.target;
×
471
      if ((currentTabModeSection && !lastSubSection) || (sectionNodes.length === 1 && !lastSubSection)) {
×
472
        setSectionSpacer(0);
×
473
      } else if (!!tabContainerContainer) {
×
474
        setSectionSpacer(
475
          objectPage.getBoundingClientRect().bottom -
476
            tabContainerContainer.getBoundingClientRect().bottom -
477
            lastSubSectionOrSection.getBoundingClientRect().height -
×
478
            TAB_CONTAINER_HEADER_HEIGHT
479
        );
480
      }
481
    });
482

483
    if (objectPage && lastSectionNode) {
×
484
      observer.observe(lastSectionNode, { box: 'border-box' });
5,565✔
485
    }
5,565✔
486

2,527✔
487
    return () => {
2,527✔
488
      observer.disconnect();
2,527✔
489
    };
2,527✔
490
  }, [headerCollapsed, topHeaderHeight, headerContentHeight, currentTabModeSection, children]);
491

2,527✔
492
  const onToggleHeaderContentVisibility = useCallback((e) => {
2,113✔
493
    isToggledRef.current = true;
2,113✔
494
    scrollTimeout.current = performance.now() + 500;
2,113✔
495
    if (!e.detail.visible) {
2,113!
496
      setHeaderCollapsedInternal(true);
1,606✔
497
      objectPageRef.current?.classList.add(classNames.headerCollapsed);
507✔
498
    } else {
507✔
499
      setHeaderCollapsedInternal(false);
500
      setScrolledHeaderExpanded(true);
501
      objectPageRef.current?.classList.remove(classNames.headerCollapsed);
502
    }
503
  }, []);
504

505
  const handleOnSubSectionSelected = useCallback(
506
    (e) => {
507
      isProgrammaticallyScrolled.current = true;
2,527✔
508
      if (mode === ObjectPageMode.IconTabBar) {
2,340!
509
        const sectionId = e.detail.sectionId;
510
        setInternalSelectedSectionId(sectionId);
511
        const sectionNodes = objectPageRef.current?.querySelectorAll(
2,527✔
512
          'section[data-component-name="ObjectPageSection"]'
2,504✔
513
        );
514
        const currentIndex = safeGetChildrenArray(children).findIndex((objectPageSection) => {
515
          return (
516
            isValidElement(objectPageSection) &&
5,565!
517
            (objectPageSection as ReactElement<ObjectPagePropTypes>).props?.id === sectionId
345✔
518
          );
345✔
519
        });
345✔
520
        debouncedOnSectionChange(e, currentIndex, sectionId, sectionNodes[currentIndex]);
197✔
521
      }
197✔
522
      const subSectionId = e.detail.subSectionId;
523
      scrollTimeout.current = performance.now() + 200;
148✔
524
      setSelectedSubSectionId(subSectionId);
148✔
525
    },
148✔
526
    [mode, setInternalSelectedSectionId, setSelectedSubSectionId, isProgrammaticallyScrolled, children]
527
  );
528

529
  const objectPageClasses = clsx(
5,565✔
530
    classNames.objectPage,
531
    className,
80✔
532
    mode === ObjectPageMode.IconTabBar && classNames.iconTabBarMode
80!
533
  );
39✔
534

39✔
535
  const { onScroll: _0, selectedSubSectionId: _1, ...propsWithoutOmitted } = rest;
39✔
536

537
  useEffect(() => {
538
    const sectionNodes = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
39✔
539
    const objectPageHeight = objectPageRef.current?.clientHeight ?? 1000;
123!
540
    const marginBottom = objectPageHeight - totalHeaderHeight - /*TabContainer*/ TAB_CONTAINER_HEADER_HEIGHT;
246✔
541
    const rootMargin = `-${totalHeaderHeight}px 0px -${marginBottom < 0 ? 0 : marginBottom}px 0px`;
×
542
    const observer = new IntersectionObserver(
543
      ([section]) => {
544
        if (section.isIntersecting && isProgrammaticallyScrolled.current === false) {
39!
545
          if (
×
546
            objectPageRef.current.getBoundingClientRect().top + totalHeaderHeight + TAB_CONTAINER_HEADER_HEIGHT <=
80✔
547
            section.target.getBoundingClientRect().bottom
80✔
548
          ) {
80✔
549
            const currentId = extractSectionIdFromHtmlId(section.target.id);
550
            setInternalSelectedSectionId(currentId);
551
            const currentIndex = safeGetChildrenArray(children).findIndex((objectPageSection) => {
552
              return (
553
                isValidElement(objectPageSection) &&
5,565!
554
                (objectPageSection as ReactElement<ObjectPageSectionPropTypes>).props?.id === currentId
555
              );
556
            });
7,255✔
557
            debouncedOnSectionChange(scrollEvent.current, currentIndex, currentId, section.target);
558
          }
559
        }
5,565✔
560
      },
561
      {
5,565✔
562
        root: objectPageRef.current,
2,121✔
563
        rootMargin,
2,121!
564
        threshold: [0]
2,121✔
565
      }
2,121✔
566
    );
2,121✔
567

568
    sectionNodes.forEach((el) => {
1,574✔
569
      observer.observe(el);
1,290✔
570
    });
571

572
    return () => {
573
      observer.disconnect();
1,245✔
574
    };
1,245✔
575
  }, [children, totalHeaderHeight, setInternalSelectedSectionId, isProgrammaticallyScrolled]);
1,245✔
576

1,383✔
577
  // Fallback when scrolling faster than the IntersectionObserver can observe (in most cases faster than 60fps)
2,766✔
578
  useEffect(() => {
579
    const sectionNodes = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
580
    if (isAfterScroll) {
×
581
      let currentSection = sectionNodes[sectionNodes.length - 1];
1,245✔
582
      let currentIndex: number;
583
      for (let i = 0; i <= sectionNodes.length - 1; i++) {
584
        const sectionNode = sectionNodes[i];
585
        if (
×
586
          objectPageRef.current.getBoundingClientRect().top + totalHeaderHeight + TAB_CONTAINER_HEADER_HEIGHT <=
587
          sectionNode.getBoundingClientRect().bottom
588
        ) {
589
          currentSection = sectionNode;
590
          currentIndex = i;
591
          break;
592
        }
2,121✔
593
      }
2,513✔
594
      const currentSectionId = extractSectionIdFromHtmlId(currentSection?.id);
595
      if (currentSectionId !== internalSelectedSectionId) {
×
596
        setInternalSelectedSectionId(currentSectionId);
2,121✔
597
        debouncedOnSectionChange(
2,098✔
598
          scrollEvent.current,
599
          currentIndex ?? sectionNodes.length - 1,
×
600
          currentSectionId,
601
          currentSection
602
        );
5,565✔
603
      }
654✔
604
      setIsAfterScroll(false);
654✔
605
    }
134✔
606
  }, [isAfterScroll]);
607

134✔
608
  const onTitleClick = (e) => {
134✔
609
    e.stopPropagation();
134✔
610
    if (!preserveHeaderStateOnClick)
×
611
      onToggleHeaderContentVisibility(enrichEventWithDetails(e, { visible: headerCollapsed }));
612
  };
613

134✔
614
  const snappedHeaderInObjPage = headerTitle && headerTitle.props.snappedContent && headerCollapsed === true && !!image;
134!
615

134✔
616
  const isInitial = useRef(true);
617
  useEffect(() => {
618
    if (!isInitial.current) {
134!
619
      scrollTimeout.current = performance.now() + 200;
134!
620
    } else {
621
      isInitial.current = false;
×
622
    }
623
  }, [snappedHeaderInObjPage]);
×
624

625
  const renderHeaderContentSection = useCallback(() => {
626
    if (headerContent?.props) {
×
627
      return cloneElement(headerContent as ReactElement<ObjectPageHeaderPropTypesWithInternals>, {
628
        ...headerContent.props,
134✔
629
        topHeaderHeight,
630
        style:
631
          headerCollapsed === true
×
632
            ? { position: 'absolute', visibility: 'hidden', flexShrink: 0 }
5,565✔
633
            : { ...headerContent.props.style, flexShrink: 0 },
76✔
634
        headerPinned: headerPinned || scrolledHeaderExpanded,
76!
635
        //@ts-expect-error: todo remove me when forwardref has been replaced
38✔
636
        ref: componentRefHeaderContent,
637
        children: (
638
          <div className={classNames.headerContainer} data-component-name="ObjectPageHeaderContainer">
639
            {avatar}
5,565✔
640
            {headerContent.props.children && (
×
641
              <div data-component-name="ObjectPageHeaderContent">{headerContent.props.children}</div>
5,565✔
642
            )}
5,565✔
643
          </div>
388✔
644
        )
2✔
645
      });
646
    }
386✔
647
  }, [headerContent, topHeaderHeight, headerPinned, scrolledHeaderExpanded, avatar, headerContentRef]);
648

649
  const onTabItemSelect = (event) => {
650
    if (typeof onBeforeNavigate === 'function') {
5,565!
651
      const selectedTabDataset = event.detail.tab.dataset;
5,565✔
652
      const sectionIndex = parseInt(selectedTabDataset.index, 10);
5,488✔
653
      const sectionId = selectedTabDataset.parentId ?? selectedTabDataset.sectionId;
×
654
      const subSectionId = selectedTabDataset.hasOwnProperty('isSubTab') ? selectedTabDataset.sectionId : undefined;
×
655
      onBeforeNavigate(
656
        enrichEventWithDetails(event, {
5,488✔
657
          sectionIndex,
658
          sectionId,
659
          subSectionId
10,617✔
660
        })
661
      );
662
      if (event.defaultPrevented) {
×
663
        return;
664
      }
665
    }
10,976✔
666
    event.preventDefault();
667
    const { sectionId, index, isSubTab, parentId } = event.detail.tab.dataset;
668
    if (isSubTab !== undefined) {
×
669
      handleOnSubSectionSelected(enrichEventWithDetails(event, { sectionId: parentId, subSectionId: sectionId }));
670
    } else {
671
      const section = safeGetChildrenArray<ReactElement<ObjectPageSectionPropTypes>>(children).find((el) => {
672
        return el.props.id == sectionId;
673
      });
674
      handleOnSectionSelected(event, section?.props?.id, index, section);
5,565✔
675
    }
330✔
676
  };
8✔
677

8✔
678
  const prevScrollTop = useRef(undefined);
8✔
679
  const onObjectPageScroll = useCallback(
8✔
680
    (e) => {
8✔
681
      if (!isToggledRef.current) {
×
682
        isToggledRef.current = true;
683
      }
684
      if (scrollTimeout.current >= performance.now()) {
×
685
        return;
686
      }
687
      scrollEvent.current = e;
8✔
688
      if (typeof props.onScroll === 'function') {
8!
689
        props.onScroll(e);
690
      }
691
      if (selectedSubSectionId) {
322!
692
        setSelectedSubSectionId(undefined);
322✔
693
      }
322✔
694
      if (selectionScrollTimeout.current) {
80!
695
        clearTimeout(selectionScrollTimeout.current);
696
      }
242✔
697
      selectionScrollTimeout.current = setTimeout(() => {
572✔
698
        setIsAfterScroll(true);
699
      }, 100);
242✔
700
      if (!headerPinned || e.target.scrollTop === 0) {
×
701
        objectPageRef.current?.classList.remove(classNames.headerCollapsed);
702
      }
703
      if (scrolledHeaderExpanded && e.target.scrollTop !== prevScrollTop.current) {
5,565!
704
        if (e.target.scrollHeight - e.target.scrollTop === e.target.clientHeight) {
5,565!
705
          return;
706
        }
7,400✔
707
        prevScrollTop.current = e.target.scrollTop;
208✔
708
        if (!headerPinned) {
×
709
          setHeaderCollapsedInternal(true);
7,400✔
710
        }
4,328✔
711
        setScrolledHeaderExpanded(false);
712
      }
3,072✔
713
    },
3,072!
714
    [topHeaderHeight, headerPinned, props.onScroll, scrolledHeaderExpanded, selectedSubSectionId]
715
  );
716

3,072✔
717
  const onHoverToggleButton = useCallback(
14✔
718
    (e) => {
719
      if (e?.type === 'mouseover') {
3,072!
720
        topHeaderRef.current?.classList.add(classNames.headerHoverStyles);
2,903✔
721
      } else {
722
        topHeaderRef.current?.classList.remove(classNames.headerHoverStyles);
3,072✔
723
      }
134✔
724
    },
725
    [classNames.headerHoverStyles]
3,072✔
726
  );
2,982✔
727

728
  const objectPageStyles: CSSProperties = {
3,072✔
729
    ...style
17!
730
  };
731
  if (headerCollapsed === true && headerContent) {
×
732
    objectPageStyles[ObjectPageCssVariables.titleFontSize] = ThemingParameters.sapObjectHeader_Title_SnappedFontSize;
17✔
733
  }
17✔
734

17✔
735
  return (
736
    <div
17✔
737
      data-component-name="ObjectPage"
738
      slot={slot}
739
      className={objectPageClasses}
740
      style={objectPageStyles}
741
      ref={componentRef}
742
      onScroll={onObjectPageScroll}
5,565✔
743
      {...propsWithoutOmitted}
744
    >
289✔
745
      <header
203✔
746
        onMouseOver={onHoverToggleButton}
747
        onMouseLeave={onHoverToggleButton}
86✔
748
        data-component-name="ObjectPageTopHeader"
749
        ref={topHeaderRef}
750
        role={accessibilityAttributes?.objectPageTopHeader?.role}
751
        data-not-clickable={!!preserveHeaderStateOnClick}
752
        aria-roledescription={accessibilityAttributes?.objectPageTopHeader?.ariaRoledescription ?? 'Object Page header'}
×
753
        className={classNames.header}
5,565✔
754
        onClick={onTitleClick}
755
        style={{
756
          gridAutoColumns: `min-content ${
5,565✔
757
            headerTitle && image && headerCollapsed === true ? `calc(100% - 3rem - 1rem)` : '100%'
1,107!
758
          }`
759
        }}
760
      >
5,565✔
761
        {headerTitle && image && headerCollapsed === true && (
×
762
          <CollapsedAvatar image={image} imageShapeCircle={imageShapeCircle} />
763
        )}
764
        {headerTitle &&
×
765
          cloneElement(headerTitle as ReactElement<ObjectPageTitlePropsWithDataAttributes>, {
766
            className: clsx(headerTitle?.props?.className),
767
            onToggleHeaderContentVisibility: onTitleClick,
768
            'data-not-clickable': !!preserveHeaderStateOnClick,
769
            'data-header-content-visible': headerContent && headerCollapsed !== true,
×
770
            'data-is-snapped-rendered-outside': snappedHeaderInObjPage
771
          })}
772
        {snappedHeaderInObjPage && (
×
773
          <div className={classNames.snappedContent} data-component-name="ATwithImageSnappedContentContainer">
774
            {headerTitle.props.snappedContent}
775
          </div>
776
        )}
777
      </header>
11,130✔
778
      {renderHeaderContentSection()}
779
      {headerContent && headerTitle && (
×
780
        <div
781
          data-component-name="ObjectPageAnchorBar"
16,726✔
782
          ref={anchorBarRef}
783
          className={classNames.anchorBar}
784
          style={{
785
            top:
786
              scrolledHeaderExpanded || headerPinned
×
787
                ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight)}px`
×
788
                : `${topHeaderHeight + 5}px`
789
          }}
790
        >
11,170✔
791
          <ObjectPageAnchorBar
792
            headerContentVisible={headerContent && headerCollapsed !== true}
×
793
            hidePinButton={!!hidePinButton}
11,094✔
794
            headerPinned={headerPinned}
795
            accessibilityAttributes={accessibilityAttributes}
796
            onToggleHeaderContentVisibility={onToggleHeaderContentVisibility}
797
            setHeaderPinned={setHeaderPinned}
798
            onHoverToggleButton={onHoverToggleButton}
11,017✔
799
            onPinnedStateChange={onPinnedStateChange}
800
          />
801
        </div>
5,574✔
802
      )}
803
      {!placeholder && (
×
804
        <div
805
          ref={tabContainerContainerRef}
806
          className={classNames.tabContainer}
807
          data-component-name="ObjectPageTabContainer"
808
          style={{
16,541✔
809
            top:
810
              headerPinned || scrolledHeaderExpanded
×
811
                ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight)}px`
×
812
                : `${topHeaderHeight}px`
813
          }}
814
        >
815
          <TabContainer
15,460✔
816
            collapsed
1,074✔
817
            onTabSelect={onTabItemSelect}
818
            data-component-name="ObjectPageTabContainer"
819
            className={classNames.tabContainerComponent}
820
          >
821
            {safeGetChildrenArray<ReactElement<ObjectPageSectionPropTypes>>(children).map((section, index) => {
10,976✔
822
              if (!isValidElement(section) || !section.props) return null;
×
823
              const subTabs = safeGetChildrenArray<ReactElement<ObjectPageSubSectionPropTypes>>(
824
                section.props.children
825
              ).filter(
826
                (subSection) =>
827
                  // @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.
828
                  isValidElement(subSection) && subSection?.type?.displayName === 'ObjectPageSubSection'
×
829
              );
830
              return (
831
                <Tab
832
                  key={`Anchor-${section.props?.id}`}
11,105✔
833
                  data-index={index}
834
                  data-section-id={section.props.id}
835
                  text={section.props.titleText}
836
                  selected={internalSelectedSectionId === section.props?.id || undefined}
×
837
                  items={subTabs.map((item) => {
838
                    if (!isValidElement(item)) {
×
839
                      return null;
16,261✔
840
                    }
1,074✔
841
                    return (
842
                      <Tab
843
                        data-parent-id={section.props.id}
844
                        key={item.props.id}
845
                        data-is-sub-tab
846
                        data-section-id={item.props.id}
847
                        text={item.props.titleText}
848
                        selected={item.props.id === selectedSubSectionId || undefined}
×
849
                        data-index={index}
850
                      >
851
                        {/*ToDo: workaround for nested tab selection*/}
10,221!
852
                        <span style={{ display: 'none' }} />
10,221✔
853
                      </Tab>
854
                    );
855
                  })}
856
                >
857
                  {/*ToDo: workaround for nested tab selection*/}
15,614✔
858
                  <span style={{ display: 'none' }} />
859
                </Tab>
10,221✔
860
              );
861
            })}
862
          </TabContainer>
863
        </div>
864
      )}
865
      <div data-component-name="ObjectPageContent" className={classNames.content} ref={objectPageContentRef}>
15,025✔
866
        <div style={{ height: headerCollapsed ? `${headerContentHeight}px` : 0 }} aria-hidden />
×
867
        {placeholder ? placeholder : sections}
6,245!
868
        <div style={{ height: `${sectionSpacer}px` }} aria-hidden />
869
      </div>
870
      {footer && mode === ObjectPageMode.IconTabBar && !sectionSpacer && (
6,245!
871
        <div className={classNames.footerSpacer} data-component-name="ObjectPageFooterSpacer" aria-hidden />
872
      )}
873
      {footer && (
×
874
        <footer className={classNames.footer} data-component-name="ObjectPageFooter">
875
          {footer}
876
        </footer>
877
      )}
12,170✔
878
    </div>
879
  );
880
});
881

882
ObjectPage.displayName = 'ObjectPage';
91✔
883

884
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