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

SAP / ui5-webcomponents-react / 9596692451

20 Jun 2024 11:33AM CUT coverage: 87.699% (+1.6%) from 86.086%
9596692451

Pull #5939

github

web-flow
Merge 7bde5228b into 18aeb52a9
Pull Request #5939: feat(DynamicPage & ObjectPage): use ui5wc `DynamicPage` & rename `ObjectPage` components

2877 of 3838 branches covered (74.96%)

13 of 14 new or added lines in 4 files covered. (92.86%)

1 existing line in 1 file now uncovered.

5169 of 5894 relevant lines covered (87.7%)

70145.82 hits per line

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

86.81
/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
  deprecationNotice,
8
  enrichEventWithDetails,
9
  ThemingParameters,
10
  useStylesheet,
11
  useSyncRef
12
} from '@ui5/webcomponents-react-base';
13
import { clsx } from 'clsx';
14
import type { CSSProperties, ReactElement, ReactNode } from 'react';
15
import { cloneElement, forwardRef, isValidElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
import { GlobalStyleClasses, ObjectPageMode } from '../../enums/index.js';
17
import { addCustomCSSWithScoping } from '../../internal/addCustomCSSWithScoping.js';
18
import { safeGetChildrenArray } from '../../internal/safeGetChildrenArray.js';
19
import { useObserveHeights } from '../../internal/useObserveHeights.js';
20
import type { CommonProps, Ui5CustomEvent } from '../../types/index.js';
21
import type { AvatarPropTypes, TabContainerDomRef } from '../../webComponents/index.js';
22
import { Tab, TabContainer } from '../../webComponents/index.js';
23
import { ObjectPageAnchorBar } from '../ObjectPageAnchorBar/index.js';
24
import type {
25
  ObjectPageHeaderPropTypes,
26
  InternalProps as ObjectPageHeaderPropTypesWithInternals
27
} from '../ObjectPageHeader/index.js';
28
import { ObjectPageHeader } from '../ObjectPageHeader/index.js';
29
import type { ObjectPageSectionPropTypes } from '../ObjectPageSection/index.js';
30
import type { ObjectPageSubSectionPropTypes } from '../ObjectPageSubSection/index.js';
31
import type {
32
  ObjectPageTitlePropTypes,
33
  InternalProps as ObjectPageTitlePropTypesWithInternals
34
} from '../ObjectPageTitle/index.js';
35
import { CollapsedAvatar } from './CollapsedAvatar.js';
36
import { classNames, styleData } from './ObjectPage.module.css.js';
37
import { extractSectionIdFromHtmlId, getSectionById } from './ObjectPageUtils.js';
38

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

49
const ObjectPageCssVariables = {
417✔
50
  headerDisplay: '--_ui5wcr_ObjectPage_header_display',
51
  titleFontSize: '--_ui5wcr_ObjectPage_title_fontsize'
52
};
53

54
const TAB_CONTAINER_HEADER_HEIGHT = 48;
417✔
55

56
type ObjectPageSectionType = ReactElement<ObjectPageSectionPropTypes> | boolean;
57

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

64
type ObjectPageTabSelectEventDetail = TabContainerTabSelectEventDetail & BeforeNavigateDetail;
65

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

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

189
/**
190
 * A component that allows apps to easily display information related to a business object.
191
 *
192
 * The `ObjectPage` is composed of a header (title and content) and block content wrapped in sections and subsections that structure the information.
193
 */
194
const ObjectPage = forwardRef<HTMLDivElement, ObjectPagePropTypes>((props, ref) => {
417✔
195
  const {
196
    headerTitle,
197
    image,
198
    footer,
199
    mode = ObjectPageMode.Default,
2,680✔
200
    imageShapeCircle,
201
    className,
202
    style,
203
    slot,
204
    showHideHeaderButton,
205
    children,
206
    selectedSectionId,
207
    alwaysShowContentHeader,
208
    showTitleInHeaderContent,
209
    headerContent,
210
    headerContentPinnable,
211
    a11yConfig,
212
    placeholder,
213
    onSelectedSectionChange,
214
    onToggleHeaderContent,
215
    onPinnedStateChange,
216
    onBeforeNavigate,
217
    ...rest
218
  } = props;
5,397✔
219

220
  useStylesheet(styleData, ObjectPage.displayName);
5,397✔
221

222
  const firstSectionId: string | undefined =
223
    safeGetChildrenArray<ReactElement<ObjectPageSectionPropTypes>>(children)[0]?.props?.id;
5,397✔
224

225
  const [internalSelectedSectionId, setInternalSelectedSectionId] = useState<string | undefined>(
5,397✔
226
    selectedSectionId ?? firstSectionId
10,794✔
227
  );
228
  const [selectedSubSectionId, setSelectedSubSectionId] = useState(props.selectedSubSectionId);
5,397✔
229
  const [headerPinned, setHeaderPinned] = useState(alwaysShowContentHeader);
5,397✔
230
  const isProgrammaticallyScrolled = useRef(false);
5,397✔
231
  const prevSelectedSectionId = useRef<string | undefined>(undefined);
5,397✔
232

233
  const [componentRef, objectPageRef] = useSyncRef(ref);
5,397✔
234
  const topHeaderRef = useRef<HTMLDivElement>(null);
5,397✔
235
  const scrollEvent = useRef(undefined);
5,397✔
236
  const prevTopHeaderHeight = useRef(0);
5,397✔
237
  // @ts-expect-error: useSyncRef will create a ref if not present
238
  const [componentRefHeaderContent, headerContentRef] = useSyncRef(headerContent?.ref);
5,397✔
239
  const anchorBarRef = useRef<HTMLDivElement>(null);
5,397✔
240
  const objectPageContentRef = useRef<HTMLDivElement>(null);
5,397✔
241
  const selectionScrollTimeout = useRef(null);
5,397✔
242
  const [isAfterScroll, setIsAfterScroll] = useState(false);
5,397✔
243
  const isToggledRef = useRef(false);
5,397✔
244
  const [headerCollapsedInternal, setHeaderCollapsedInternal] = useState<undefined | boolean>(undefined);
5,397✔
245
  const [scrolledHeaderExpanded, setScrolledHeaderExpanded] = useState(false);
5,397✔
246
  const scrollTimeout = useRef(0);
5,397✔
247
  const titleInHeader = headerTitle && showTitleInHeaderContent;
5,397✔
248
  const [sectionSpacer, setSectionSpacer] = useState(0);
5,397✔
249
  const [currentTabModeSection, setCurrentTabModeSection] = useState(null);
5,397✔
250
  const sections = mode === ObjectPageMode.IconTabBar ? currentTabModeSection : children;
5,397✔
251

252
  const deprecationNoticeDisplayed = useRef(false);
5,397✔
253
  useEffect(() => {
5,397✔
254
    if (showTitleInHeaderContent && !deprecationNoticeDisplayed.current) {
382!
255
      deprecationNotice(
×
256
        'showTitleInHeaderContent',
257
        'showTitleInHeaderContent is deprecated and will be removed with the next major release.'
258
      );
259
      deprecationNoticeDisplayed.current = true;
×
260
    }
261
  }, [showTitleInHeaderContent]);
262

263
  useEffect(() => {
5,397✔
264
    const currentSection =
265
      mode === ObjectPageMode.IconTabBar ? getSectionById(children, internalSelectedSectionId) : null;
1,306✔
266
    setCurrentTabModeSection(currentSection);
1,306✔
267
  }, [mode, children, internalSelectedSectionId]);
268

269
  const prevInternalSelectedSectionId = useRef(internalSelectedSectionId);
5,397✔
270
  const fireOnSelectedChangedEvent = (targetEvent, index, id, section) => {
5,397✔
271
    if (typeof onSelectedSectionChange === 'function' && prevInternalSelectedSectionId.current !== id) {
350!
272
      onSelectedSectionChange(
×
273
        enrichEventWithDetails(targetEvent, {
274
          selectedSectionIndex: parseInt(index, 10),
275
          selectedSectionId: id,
276
          section
277
        })
278
      );
279
      prevInternalSelectedSectionId.current = id;
×
280
    }
281
  };
282
  const debouncedOnSectionChange = useRef(debounce(fireOnSelectedChangedEvent, 500)).current;
5,397✔
283
  useEffect(() => {
5,397✔
284
    return () => {
382✔
285
      debouncedOnSectionChange.cancel();
358✔
286
      clearTimeout(selectionScrollTimeout.current);
358✔
287
    };
288
  }, []);
289

290
  // observe heights of header parts
291
  const { topHeaderHeight, headerContentHeight, anchorBarHeight, totalHeaderHeight, headerCollapsed } =
292
    useObserveHeights(
5,397✔
293
      objectPageRef,
294
      topHeaderRef,
295
      headerContentRef,
296
      anchorBarRef,
297
      [headerCollapsedInternal, setHeaderCollapsedInternal],
298
      {
299
        noHeader: !headerTitle && !headerContent,
5,433✔
300
        fixedHeader: headerPinned,
301
        scrollTimeout
302
      }
303
    );
304

305
  useEffect(() => {
5,397✔
306
    if (typeof onToggleHeaderContent === 'function' && isToggledRef.current) {
801✔
307
      onToggleHeaderContent(headerCollapsed !== true);
76✔
308
    }
309
  }, [headerCollapsed]);
310

311
  const avatar = useMemo(() => {
5,397✔
312
    if (!image) {
382✔
313
      return null;
360✔
314
    }
315

316
    if (typeof image === 'string') {
22✔
317
      return (
12✔
318
        <span
319
          className={classNames.headerImage}
320
          style={{ borderRadius: imageShapeCircle ? '50%' : 0, overflow: 'hidden' }}
12✔
321
        >
322
          <img src={image} className={classNames.image} alt="Company Logo" />
323
        </span>
324
      );
325
    } else {
326
      return cloneElement(image, {
10✔
327
        size: AvatarSize.L,
328
        className: clsx(classNames.headerImage, image.props?.className)
329
      } as AvatarPropTypes);
330
    }
331
  }, [image, classNames.headerImage, classNames.image, imageShapeCircle]);
332

333
  const scrollToSectionById = (id?: string, isSubSection = false) => {
5,397✔
334
    const section = objectPageRef.current?.querySelector<HTMLElement>(
177✔
335
      `#${isSubSection ? 'ObjectPageSubSection' : 'ObjectPageSection'}-${CSS.escape(id)}`
177✔
336
    );
337
    scrollTimeout.current = performance.now() + 500;
177✔
338
    if (section) {
177✔
339
      const safeTopHeaderHeight = topHeaderHeight || prevTopHeaderHeight.current;
177!
340
      section.style.scrollMarginBlockStart =
177✔
341
        safeTopHeaderHeight +
342
        anchorBarHeight +
343
        TAB_CONTAINER_HEADER_HEIGHT +
344
        (headerPinned ? headerContentHeight : 0) +
177!
345
        'px';
346
      section.focus();
177✔
347
      section.scrollIntoView({ behavior: 'smooth' });
177✔
348
      section.style.scrollMarginBlockStart = '0px';
177✔
349
    }
350
  };
351

352
  const scrollToSection = (sectionId?: string) => {
5,397✔
353
    if (!sectionId) {
138!
354
      return;
×
355
    }
356
    if (firstSectionId === sectionId) {
138✔
357
      objectPageRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
56✔
358
    } else {
359
      scrollToSectionById(sectionId);
82✔
360
    }
361
    isProgrammaticallyScrolled.current = false;
138✔
362
  };
363

364
  const programmaticallySetSection = () => {
5,397✔
365
    const currentId = selectedSectionId ?? firstSectionId;
×
366
    if (currentId !== prevSelectedSectionId.current) {
×
367
      debouncedOnSectionChange.cancel();
×
368
      isProgrammaticallyScrolled.current = true;
×
369
      setInternalSelectedSectionId(currentId);
×
370
      prevSelectedSectionId.current = currentId;
×
371
      const sectionNodes = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
×
372
      const currentIndex = safeGetChildrenArray(children).findIndex((objectPageSection) => {
×
373
        return (
×
374
          isValidElement(objectPageSection) &&
×
375
          (objectPageSection as ReactElement<ObjectPageSectionPropTypes>).props?.id === currentId
376
        );
377
      });
378
      fireOnSelectedChangedEvent({}, currentIndex, currentId, sectionNodes[0]);
×
379
    }
380
  };
381

382
  // change selected section when prop is changed (external change)
383
  const [timeStamp, setTimeStamp] = useState(0);
5,397✔
384
  const requestAnimationFrameRef = useRef<undefined | number>(undefined);
5,397✔
385
  useEffect(() => {
5,397✔
386
    if (selectedSectionId) {
382!
387
      if (mode === ObjectPageMode.Default) {
×
388
        // wait for DOM draw, otherwise initial scroll won't work as intended
389
        if (timeStamp < 750 && timeStamp !== undefined) {
×
390
          requestAnimationFrameRef.current = requestAnimationFrame((internalTimestamp) => {
×
391
            setTimeStamp(internalTimestamp);
×
392
          });
393
        } else {
394
          setTimeStamp(undefined);
×
395
          programmaticallySetSection();
×
396
        }
397
      } else {
398
        programmaticallySetSection();
×
399
      }
400
    }
401
    return () => {
382✔
402
      cancelAnimationFrame(requestAnimationFrameRef.current);
358✔
403
    };
404
  }, [timeStamp, selectedSectionId, firstSectionId, debouncedOnSectionChange]);
405

406
  // section was selected by clicking on the tab bar buttons
407
  const handleOnSectionSelected = (targetEvent, newSelectionSectionId, index, section) => {
5,397✔
408
    isProgrammaticallyScrolled.current = true;
242✔
409
    debouncedOnSectionChange.cancel();
242✔
410
    setInternalSelectedSectionId((prevSelectedSection) => {
242✔
411
      if (prevSelectedSection === newSelectionSectionId) {
242✔
412
        scrollToSection(newSelectionSectionId);
30✔
413
      }
414
      return newSelectionSectionId;
242✔
415
    });
416
    scrollEvent.current = targetEvent;
242✔
417
    fireOnSelectedChangedEvent(targetEvent, index, newSelectionSectionId, section);
242✔
418
  };
419

420
  // do internal scrolling
421
  useEffect(() => {
5,397✔
422
    if (mode === ObjectPageMode.Default && isProgrammaticallyScrolled.current === true && !selectedSubSectionId) {
3,804✔
423
      scrollToSection(internalSelectedSectionId);
108✔
424
    }
425
  }, [internalSelectedSectionId, mode, isProgrammaticallyScrolled, scrollToSection, selectedSubSectionId]);
426

427
  // Scrolling for Sub Section Selection
428
  useEffect(() => {
5,397✔
429
    if (selectedSubSectionId && isProgrammaticallyScrolled.current === true && sectionSpacer) {
1,084✔
430
      scrollToSectionById(selectedSubSectionId, true);
95✔
431
      isProgrammaticallyScrolled.current = false;
95✔
432
    }
433
  }, [selectedSubSectionId, isProgrammaticallyScrolled.current, sectionSpacer]);
434

435
  useEffect(() => {
5,397✔
436
    if (alwaysShowContentHeader !== undefined) {
501✔
437
      setHeaderPinned(alwaysShowContentHeader);
136✔
438
    }
439
    if (alwaysShowContentHeader) {
501✔
440
      onToggleHeaderContentVisibility({ detail: { visible: true } });
68✔
441
    }
442
  }, [alwaysShowContentHeader]);
443

444
  const prevHeaderPinned = useRef(headerPinned);
5,397✔
445
  useEffect(() => {
5,397✔
446
    if (prevHeaderPinned.current && !headerPinned && objectPageRef.current.scrollTop > topHeaderHeight) {
914✔
447
      onToggleHeaderContentVisibility({ detail: { visible: false } });
51✔
448
      prevHeaderPinned.current = false;
51✔
449
    }
450
    if (!prevHeaderPinned.current && headerPinned) {
914✔
451
      prevHeaderPinned.current = true;
86✔
452
    }
453
  }, [headerPinned, topHeaderHeight]);
454

455
  useEffect(() => {
5,397✔
456
    setSelectedSubSectionId(props.selectedSubSectionId);
870✔
457
    if (props.selectedSubSectionId) {
870!
458
      isProgrammaticallyScrolled.current = true;
×
459
      if (mode === ObjectPageMode.IconTabBar) {
×
460
        let sectionId;
461
        safeGetChildrenArray<ReactElement<ObjectPageSectionPropTypes>>(children).forEach((section) => {
×
462
          if (isValidElement(section) && section.props && section.props.children) {
×
463
            safeGetChildrenArray(section.props.children).forEach((subSection) => {
×
464
              if (
×
465
                isValidElement(subSection) &&
×
466
                subSection.props &&
467
                (subSection as ReactElement<ObjectPageSubSectionPropTypes>).props.id === props.selectedSubSectionId
468
              ) {
469
                sectionId = section.props?.id;
×
470
              }
471
            });
472
          }
473
        });
474
        if (sectionId) {
×
475
          setInternalSelectedSectionId(sectionId);
×
476
        }
477
      }
478
    }
479
  }, [props.selectedSubSectionId, children, mode]);
480

481
  const tabContainerContainerRef = useRef(null);
5,397✔
482
  useEffect(() => {
5,397✔
483
    const objectPage = objectPageRef.current;
2,383✔
484
    const sectionNodes = objectPage.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSection"]');
2,383✔
485
    const lastSectionNode = sectionNodes[sectionNodes.length - 1];
2,383✔
486
    const tabContainerContainer = tabContainerContainerRef.current;
2,383✔
487

488
    const observer = new ResizeObserver(([sectionElement]) => {
2,383✔
489
      const subSections = lastSectionNode.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSubSection"]');
1,862✔
490
      const lastSubSection = subSections[subSections.length - 1];
1,862✔
491
      const lastSubSectionOrSection = lastSubSection ?? sectionElement.target;
1,862✔
492
      if ((currentTabModeSection && !lastSubSection) || (sectionNodes.length === 1 && !lastSubSection)) {
1,862✔
493
        setSectionSpacer(0);
1,377✔
494
      } else if (!!tabContainerContainer) {
485✔
495
        setSectionSpacer(
485✔
496
          objectPage.getBoundingClientRect().bottom -
497
            tabContainerContainer.getBoundingClientRect().bottom -
498
            lastSubSectionOrSection.getBoundingClientRect().height -
499
            TAB_CONTAINER_HEADER_HEIGHT
500
        );
501
      }
502
    });
503

504
    if (objectPage && lastSectionNode) {
2,383✔
505
      observer.observe(lastSectionNode, { box: 'border-box' });
2,147✔
506
    }
507

508
    return () => {
2,383✔
509
      observer.disconnect();
2,359✔
510
    };
511
  }, [headerCollapsed, topHeaderHeight, headerContentHeight, currentTabModeSection, children]);
512

513
  const onToggleHeaderContentVisibility = useCallback((e) => {
5,397✔
514
    isToggledRef.current = true;
328✔
515
    scrollTimeout.current = performance.now() + 500;
328✔
516
    if (!e.detail.visible) {
328✔
517
      setHeaderCollapsedInternal(true);
180✔
518
      objectPageRef.current?.classList.add(classNames.headerCollapsed);
180✔
519
    } else {
520
      setHeaderCollapsedInternal(false);
148✔
521
      setScrolledHeaderExpanded(true);
148✔
522
      objectPageRef.current?.classList.remove(classNames.headerCollapsed);
148✔
523
    }
524
  }, []);
525

526
  const handleOnSubSectionSelected = useCallback(
5,397✔
527
    (e) => {
528
      isProgrammaticallyScrolled.current = true;
80✔
529
      if (mode === ObjectPageMode.IconTabBar) {
80✔
530
        const sectionId = e.detail.sectionId;
39✔
531
        setInternalSelectedSectionId(sectionId);
39✔
532
        const sectionNodes = objectPageRef.current?.querySelectorAll(
39✔
533
          'section[data-component-name="ObjectPageSection"]'
534
        );
535
        const currentIndex = safeGetChildrenArray(children).findIndex((objectPageSection) => {
39✔
536
          return (
123✔
537
            isValidElement(objectPageSection) &&
246✔
538
            (objectPageSection as ReactElement<ObjectPagePropTypes>).props?.id === sectionId
539
          );
540
        });
541
        debouncedOnSectionChange(e, currentIndex, sectionId, sectionNodes[currentIndex]);
39✔
542
      }
543
      const subSectionId = e.detail.subSectionId;
80✔
544
      scrollTimeout.current = performance.now() + 200;
80✔
545
      setSelectedSubSectionId(subSectionId);
80✔
546
    },
547
    [mode, setInternalSelectedSectionId, setSelectedSubSectionId, isProgrammaticallyScrolled, children]
548
  );
549

550
  const objectPageClasses = clsx(
5,397✔
551
    classNames.objectPage,
552
    GlobalStyleClasses.sapScrollBar,
553
    className,
554
    mode === ObjectPageMode.IconTabBar && classNames.iconTabBarMode
7,087✔
555
  );
556

557
  const { onScroll: _0, selectedSubSectionId: _1, ...propsWithoutOmitted } = rest;
5,397✔
558

559
  useEffect(() => {
5,397✔
560
    const sectionNodes = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
1,941✔
561
    const objectPageHeight = objectPageRef.current?.clientHeight ?? 1000;
1,941!
562
    const marginBottom = objectPageHeight - totalHeaderHeight - /*TabContainer*/ TAB_CONTAINER_HEADER_HEIGHT;
1,941✔
563
    const rootMargin = `-${totalHeaderHeight}px 0px -${marginBottom < 0 ? 0 : marginBottom}px 0px`;
1,941✔
564
    const observer = new IntersectionObserver(
1,941✔
565
      ([section]) => {
566
        if (section.isIntersecting && isProgrammaticallyScrolled.current === false) {
1,499✔
567
          if (
1,212✔
568
            objectPageRef.current.getBoundingClientRect().top + totalHeaderHeight + TAB_CONTAINER_HEADER_HEIGHT <=
569
            section.target.getBoundingClientRect().bottom
570
          ) {
571
            const currentId = extractSectionIdFromHtmlId(section.target.id);
1,186✔
572
            setInternalSelectedSectionId(currentId);
1,186✔
573
            const currentIndex = safeGetChildrenArray(children).findIndex((objectPageSection) => {
1,186✔
574
              return (
1,319✔
575
                isValidElement(objectPageSection) &&
2,638✔
576
                (objectPageSection as ReactElement<ObjectPageSectionPropTypes>).props?.id === currentId
577
              );
578
            });
579
            debouncedOnSectionChange(scrollEvent.current, currentIndex, currentId, section.target);
1,186✔
580
          }
581
        }
582
      },
583
      {
584
        root: objectPageRef.current,
585
        rootMargin,
586
        threshold: [0]
587
      }
588
    );
589

590
    sectionNodes.forEach((el) => {
1,941✔
591
      observer.observe(el);
2,284✔
592
    });
593

594
    return () => {
1,941✔
595
      observer.disconnect();
1,917✔
596
    };
597
  }, [children, totalHeaderHeight, setInternalSelectedSectionId, isProgrammaticallyScrolled]);
598

599
  // Fallback when scrolling faster than the IntersectionObserver can observe (in most cases faster than 60fps)
600
  useEffect(() => {
5,397✔
601
    const sectionNodes = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
584✔
602
    if (isAfterScroll) {
584✔
603
      let currentSection = sectionNodes[sectionNodes.length - 1];
101✔
604
      let currentIndex: number;
605
      for (let i = 0; i <= sectionNodes.length - 1; i++) {
101✔
606
        const sectionNode = sectionNodes[i];
101✔
607
        if (
101✔
608
          objectPageRef.current.getBoundingClientRect().top + totalHeaderHeight + TAB_CONTAINER_HEADER_HEIGHT <=
609
          sectionNode.getBoundingClientRect().bottom
610
        ) {
611
          currentSection = sectionNode;
101✔
612
          currentIndex = i;
101✔
613
          break;
101✔
614
        }
615
      }
616
      const currentSectionId = extractSectionIdFromHtmlId(currentSection?.id);
101✔
617
      if (currentSectionId !== internalSelectedSectionId) {
101!
618
        setInternalSelectedSectionId(currentSectionId);
×
619
        debouncedOnSectionChange(
×
620
          scrollEvent.current,
621
          currentIndex ?? sectionNodes.length - 1,
×
622
          currentSectionId,
623
          currentSection
624
        );
625
      }
626
      setIsAfterScroll(false);
101✔
627
    }
628
  }, [isAfterScroll]);
629

630
  const titleHeaderNotClickable =
631
    (alwaysShowContentHeader && !headerContentPinnable) ||
5,397✔
632
    !headerContent ||
633
    (!showHideHeaderButton && !headerContentPinnable);
634

635
  const onTitleClick = useCallback(
5,397✔
636
    (e) => {
637
      e.stopPropagation();
38✔
638
      if (!titleHeaderNotClickable) {
38✔
639
        onToggleHeaderContentVisibility(enrichEventWithDetails(e, { visible: headerCollapsed }));
38✔
640
      }
641
    },
642
    [onToggleHeaderContentVisibility, headerCollapsed, titleHeaderNotClickable]
643
  );
644

645
  const snappedHeaderInObjPage = headerTitle && headerTitle.props.snappedContent && headerCollapsed === true && !!image;
5,397✔
646

647
  const hasHeaderContent = !!headerContent;
5,397✔
648
  const renderTitleSection = useCallback(
5,397✔
649
    (inHeader = false) => {
5,361✔
650
      const titleInHeaderClass = inHeader ? classNames.titleInHeader : undefined;
5,361!
651

652
      if (headerTitle?.props && headerTitle.props?.showSubHeaderRight === undefined) {
5,361✔
653
        return cloneElement(headerTitle as ReactElement<ObjectPageTitlePropsWithDataAttributes>, {
5,361✔
654
          showSubHeaderRight: true,
655
          className: clsx(titleInHeaderClass, headerTitle?.props?.className),
656
          onToggleHeaderContentVisibility: onTitleClick,
657
          'data-not-clickable': titleHeaderNotClickable,
658
          'data-header-content-visible': headerContent && headerCollapsed !== true,
10,583✔
659
          'data-is-snapped-rendered-outside': snappedHeaderInObjPage
660
        });
661
      }
NEW
662
      return cloneElement(headerTitle as ReactElement<ObjectPageTitlePropsWithDataAttributes>, {
×
663
        className: clsx(titleInHeaderClass, headerTitle?.props?.className),
664
        onToggleHeaderContentVisibility: onTitleClick,
665
        'data-not-clickable': titleHeaderNotClickable,
666
        'data-header-content-visible': headerContent && headerCollapsed !== true,
×
667
        'data-is-snapped-rendered-outside': snappedHeaderInObjPage
668
      });
669
    },
670
    [headerTitle, titleHeaderNotClickable, onTitleClick, headerCollapsed, snappedHeaderInObjPage, hasHeaderContent]
671
  );
672

673
  const isInitial = useRef(true);
5,397✔
674
  useEffect(() => {
5,397✔
675
    if (!isInitial.current) {
384✔
676
      scrollTimeout.current = performance.now() + 200;
2✔
677
    } else {
678
      isInitial.current = false;
382✔
679
    }
680
  }, [snappedHeaderInObjPage]);
681

682
  const renderHeaderContentSection = useCallback(() => {
5,397✔
683
    if (headerContent?.props) {
5,397✔
684
      return cloneElement(headerContent as ReactElement<ObjectPageHeaderPropTypesWithInternals>, {
5,222✔
685
        ...headerContent.props,
686
        topHeaderHeight,
687
        style:
688
          headerCollapsed === true
5,222✔
689
            ? { position: 'absolute', visibility: 'hidden', flexShrink: 0 }
690
            : { ...headerContent.props.style, flexShrink: 0 },
691
        headerPinned: headerPinned || scrolledHeaderExpanded,
10,119✔
692
        //@ts-expect-error: todo remove me when forwardref has been replaced
693
        ref: componentRefHeaderContent,
694
        children: (
695
          <div className={classNames.headerContainer} data-component-name="ObjectPageHeaderContainer">
696
            {avatar}
10,444!
697
            {(headerContent.props.children || titleInHeader) && (
698
              <div data-component-name="ObjectPageHeaderContent">
699
                {titleInHeader && renderTitleSection(true)}
5,222!
700
                {headerContent.props.children}
701
              </div>
702
            )}
703
          </div>
704
        )
705
      });
706
    } else if (titleInHeader) {
175!
707
      return (
×
708
        <ObjectPageHeader
709
          topHeaderHeight={topHeaderHeight}
710
          style={headerCollapsed === true ? { position: 'absolute', visibility: 'hidden' } : undefined}
×
711
          headerPinned={headerPinned || scrolledHeaderExpanded}
×
712
          ref={componentRefHeaderContent}
713
        >
714
          <div className={classNames.headerContainer} data-component-name="ObjectPageHeaderContainer">
715
            {avatar}
716
            <div data-component-name="ObjectPageHeaderContent">{titleInHeader && renderTitleSection(true)}</div>
×
717
          </div>
718
        </ObjectPageHeader>
719
      );
720
    }
721
  }, [
722
    headerContent,
723
    topHeaderHeight,
724
    headerPinned,
725
    scrolledHeaderExpanded,
726
    titleInHeader,
727
    avatar,
728
    headerContentRef,
729
    renderTitleSection
730
  ]);
731

732
  const onTabItemSelect = (event) => {
5,397✔
733
    if (typeof onBeforeNavigate === 'function') {
330✔
734
      const selectedTabDataset = event.detail.tab.dataset;
8✔
735
      const sectionIndex = parseInt(selectedTabDataset.index, 10);
8✔
736
      const sectionId = selectedTabDataset.parentId ?? selectedTabDataset.sectionId;
8✔
737
      const subSectionId = selectedTabDataset.hasOwnProperty('isSubTab') ? selectedTabDataset.sectionId : undefined;
8✔
738
      onBeforeNavigate(
8✔
739
        enrichEventWithDetails(event, {
740
          sectionIndex,
741
          sectionId,
742
          subSectionId
743
        })
744
      );
745
      if (event.defaultPrevented) {
8✔
746
        return;
8✔
747
      }
748
    }
749
    event.preventDefault();
322✔
750
    const { sectionId, index, isSubTab, parentId } = event.detail.tab.dataset;
322✔
751
    if (isSubTab !== undefined) {
322✔
752
      handleOnSubSectionSelected(enrichEventWithDetails(event, { sectionId: parentId, subSectionId: sectionId }));
80✔
753
    } else {
754
      const section = safeGetChildrenArray<ReactElement<ObjectPageSectionPropTypes>>(children).find((el) => {
242✔
755
        return el.props.id == sectionId;
572✔
756
      });
757
      handleOnSectionSelected(event, section?.props?.id, index, section);
242✔
758
    }
759
  };
760

761
  const prevScrollTop = useRef(undefined);
5,397✔
762
  const onObjectPageScroll = useCallback(
5,397✔
763
    (e) => {
764
      if (!isToggledRef.current) {
7,545✔
765
        isToggledRef.current = true;
181✔
766
      }
767
      if (scrollTimeout.current >= performance.now()) {
7,545✔
768
        return;
4,338✔
769
      }
770
      scrollEvent.current = e;
3,207✔
771
      if (typeof props.onScroll === 'function') {
3,207!
772
        props.onScroll(e);
×
773
      }
774
      if (selectedSubSectionId) {
3,207✔
775
        setSelectedSubSectionId(undefined);
14✔
776
      }
777
      if (selectionScrollTimeout.current) {
3,207✔
778
        clearTimeout(selectionScrollTimeout.current);
3,038✔
779
      }
780
      selectionScrollTimeout.current = setTimeout(() => {
3,207✔
781
        setIsAfterScroll(true);
101✔
782
      }, 100);
783
      if (!headerPinned || e.target.scrollTop === 0) {
3,207✔
784
        objectPageRef.current?.classList.remove(classNames.headerCollapsed);
3,117✔
785
      }
786
      if (scrolledHeaderExpanded && e.target.scrollTop !== prevScrollTop.current) {
3,207✔
787
        if (e.target.scrollHeight - e.target.scrollTop === e.target.clientHeight) {
17!
788
          return;
×
789
        }
790
        prevScrollTop.current = e.target.scrollTop;
17✔
791
        if (!headerPinned) {
17✔
792
          setHeaderCollapsedInternal(true);
17✔
793
        }
794
        setScrolledHeaderExpanded(false);
17✔
795
      }
796
    },
797
    [topHeaderHeight, headerPinned, props.onScroll, scrolledHeaderExpanded, selectedSubSectionId]
798
  );
799

800
  const onHoverToggleButton = useCallback(
5,397✔
801
    (e) => {
802
      if (e?.type === 'mouseover') {
251✔
803
        topHeaderRef.current?.classList.add(classNames.headerHoverStyles);
165✔
804
      } else {
805
        topHeaderRef.current?.classList.remove(classNames.headerHoverStyles);
86✔
806
      }
807
    },
808
    [classNames.headerHoverStyles]
809
  );
810

811
  const objectPageStyles: CSSProperties = {
5,397✔
812
    ...style
813
  };
814
  if (headerCollapsed === true && (headerContent || titleInHeader)) {
5,397!
815
    objectPageStyles[ObjectPageCssVariables.titleFontSize] = ThemingParameters.sapObjectHeader_Title_SnappedFontSize;
1,004✔
816
  }
817

818
  return (
5,397✔
819
    <div
820
      data-component-name="ObjectPage"
821
      slot={slot}
822
      className={objectPageClasses}
823
      style={objectPageStyles}
824
      ref={componentRef}
825
      onScroll={onObjectPageScroll}
826
      {...propsWithoutOmitted}
827
    >
828
      <header
829
        onMouseOver={onHoverToggleButton}
830
        onMouseLeave={onHoverToggleButton}
831
        data-component-name="ObjectPageTopHeader"
832
        ref={topHeaderRef}
833
        role={a11yConfig?.objectPageTopHeader?.role}
834
        data-not-clickable={titleHeaderNotClickable}
835
        aria-roledescription={a11yConfig?.objectPageTopHeader?.ariaRoledescription ?? 'Object Page header'}
10,794✔
836
        className={classNames.header}
837
        onClick={onTitleClick}
838
        style={{
839
          gridAutoColumns: `min-content ${
840
            headerTitle && image && headerCollapsed === true ? `calc(100% - 3rem - 1rem)` : '100%'
16,229✔
841
          }`,
842
          display: !showTitleInHeaderContent || headerCollapsed === true ? 'grid' : 'none'
10,794!
843
        }}
844
      >
845
        {headerTitle && image && headerCollapsed === true && (
10,840✔
846
          <CollapsedAvatar image={image} imageShapeCircle={imageShapeCircle} />
847
        )}
848
        {headerTitle && renderTitleSection()}
10,758✔
849
        {snappedHeaderInObjPage && (
5,405✔
850
          <div className={classNames.snappedContent} data-component-name="ATwithImageSnappedContentContainer">
851
            {headerTitle.props.snappedContent}
852
          </div>
853
        )}
854
      </header>
855
      {renderHeaderContentSection()}
856
      {headerContent && headerTitle && (
15,841✔
857
        <div
858
          data-component-name="ObjectPageAnchorBar"
859
          ref={anchorBarRef}
860
          className={classNames.anchorBar}
861
          style={{
862
            top:
863
              scrolledHeaderExpanded || headerPinned
14,734✔
864
                ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight)}px`
1,002✔
865
                : `${topHeaderHeight + 5}px`
866
          }}
867
        >
868
          <ObjectPageAnchorBar
869
            headerContentVisible={headerContent && headerCollapsed !== true}
10,444✔
870
            headerContentPinnable={headerContentPinnable}
871
            showHideHeaderButton={showHideHeaderButton}
872
            headerPinned={headerPinned}
873
            a11yConfig={a11yConfig}
874
            onToggleHeaderContentVisibility={onToggleHeaderContentVisibility}
875
            setHeaderPinned={setHeaderPinned}
876
            onHoverToggleButton={onHoverToggleButton}
877
            onPinnedStateChange={onPinnedStateChange}
878
          />
879
        </div>
880
      )}
881
      {!placeholder && (
10,764✔
882
        <div
883
          ref={tabContainerContainerRef}
884
          className={classNames.tabContainer}
885
          data-component-name="ObjectPageTabContainer"
886
          style={{
887
            top:
888
              headerPinned || scrolledHeaderExpanded
15,776✔
889
                ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight)}px`
1,002✔
890
                : `${topHeaderHeight}px`
891
          }}
892
        >
893
          <TabContainer
894
            collapsed
895
            onTabSelect={onTabItemSelect}
896
            data-component-name="ObjectPageTabContainer"
897
            className={classNames.tabContainerComponent}
898
          >
899
            {safeGetChildrenArray<ReactElement<ObjectPageSectionPropTypes>>(children).map((section, index) => {
900
              if (!isValidElement(section) || !section.props) return null;
10,098!
901
              const subTabs = safeGetChildrenArray<ReactElement<ObjectPageSubSectionPropTypes>>(
10,098✔
902
                section.props.children
903
              ).filter(
904
                (subSection) =>
905
                  // @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.
906
                  isValidElement(subSection) && subSection?.type?.displayName === 'ObjectPageSubSection'
15,743✔
907
              );
908
              return (
10,098✔
909
                <Tab
910
                  key={`Anchor-${section.props?.id}`}
911
                  data-index={index}
912
                  data-section-id={section.props.id}
913
                  text={section.props.titleText}
914
                  selected={internalSelectedSectionId === section.props?.id || undefined}
15,054✔
915
                  items={subTabs.map((item) => {
916
                    if (!isValidElement(item)) {
6,526!
917
                      return null;
×
918
                    }
919
                    return (
6,526✔
920
                      <Tab
921
                        data-parent-id={section.props.id}
922
                        key={item.props.id}
923
                        data-is-sub-tab
924
                        data-section-id={item.props.id}
925
                        text={item.props.titleText}
926
                        selected={item.props.id === selectedSubSectionId || undefined}
12,743✔
927
                        data-index={index}
928
                      >
929
                        {/*ToDo: workaround for nested tab selection*/}
930
                        <span style={{ display: 'none' }} />
931
                      </Tab>
932
                    );
933
                  })}
934
                >
935
                  {/*ToDo: workaround for nested tab selection*/}
936
                  <span style={{ display: 'none' }} />
937
                </Tab>
938
              );
939
            })}
940
          </TabContainer>
941
        </div>
942
      )}
943
      <div data-component-name="ObjectPageContent" className={classNames.content} ref={objectPageContentRef}>
944
        <div style={{ height: headerCollapsed ? `${headerContentHeight}px` : 0 }} aria-hidden />
5,397✔
945
        {placeholder ? placeholder : sections}
5,397✔
946
        <div style={{ height: `${sectionSpacer}px` }} aria-hidden />
947
      </div>
948
      {footer && mode === ObjectPageMode.IconTabBar && !sectionSpacer && (
7,619✔
949
        <div className={classNames.footerSpacer} data-component-name="ObjectPageFooterSpacer" aria-hidden />
950
      )}
951
      {footer && (
6,555✔
952
        <footer className={classNames.footer} data-component-name="ObjectPageFooter">
953
          {footer}
954
        </footer>
955
      )}
956
    </div>
957
  );
958
});
959

960
ObjectPage.displayName = 'ObjectPage';
417✔
961

962
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