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

SAP / ui5-webcomponents-react / 5530563596

pending completion
5530563596

Pull #4872

github

web-flow
Merge 42d4ceeec into 009abc54b
Pull Request #4872: fix(UI5DomRef - TypeScript): update interface to latest methods and properties

2642 of 3584 branches covered (73.72%)

4953 of 5649 relevant lines covered (87.68%)

16943.87 hits per line

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

82.06
/packages/main/src/components/ObjectPage/index.tsx
1
'use client';
379✔
2

3
import { debounce, enrichEventWithDetails, ThemingParameters, useSyncRef } from '@ui5/webcomponents-react-base';
4
import { clsx } from 'clsx';
5
import type { ReactElement, ReactNode } from 'react';
6
import React, {
7
  cloneElement,
8
  forwardRef,
9
  isValidElement,
10
  useCallback,
11
  useEffect,
12
  useMemo,
13
  useRef,
14
  useState
15
} from 'react';
16
import { createUseStyles } from 'react-jss';
17
import { AvatarSize, GlobalStyleClasses, ObjectPageMode } from '../../enums/index.js';
18
import type { CommonProps } from '../../interfaces/index.js';
19
import { addCustomCSSWithScoping } from '../../internal/addCustomCSSWithScoping.js';
20
import { safeGetChildrenArray } from '../../internal/safeGetChildrenArray.js';
21
import { useObserveHeights } from '../../internal/useObserveHeights.js';
22
import type { AvatarPropTypes } from '../../webComponents/index.js';
23
import { Tab, TabContainer } from '../../webComponents/index.js';
24
import { DynamicPageCssVariables } from '../DynamicPage/DynamicPage.jss.js';
25
import { DynamicPageAnchorBar } from '../DynamicPageAnchorBar/index.js';
26
import type { ObjectPageSectionPropTypes } from '../ObjectPageSection/index.js';
27
import { CollapsedAvatar } from './CollapsedAvatar.js';
28
import { styles } from './ObjectPage.jss.js';
29
import { extractSectionIdFromHtmlId, getSectionById } from './ObjectPageUtils.js';
30

31
addCustomCSSWithScoping(
379✔
32
  'ui5-tabcontainer',
33
  // padding-inline is used here to ensure the same responsive padding behavior as for the rest of the component
34
  // todo: the additional text span adds 3px to the container - needs to be investigated why
35
  `
36
  :host([data-component-name="ObjectPageTabContainer"]) .ui5-tc__header {
37
    padding: 0;
38
    padding-inline: var(--_ui5wcr_ObjectPage_tab_bar_inline_padding);
39
    box-shadow: inset 0 -0.0625rem ${ThemingParameters.sapPageHeader_BorderColor}, 0 0.125rem 0.25rem 0 rgb(0 0 0 / 8%);
40
  }
41
  :host([data-component-name="ObjectPageTabContainer"]) [id$="additionalText"] {
42
    display: none;
43
  }
44
  `
45
);
46

47
const TAB_CONTAINER_HEADER_HEIGHT = 48;
379✔
48

49
export interface ObjectPagePropTypes extends Omit<CommonProps, 'placeholder'> {
50
  /**
51
   * Defines the upper, always static, title section of the `ObjectPage`.
52
   *
53
   * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `DynamicPageTitle` in order to preserve the intended design.
54
   * __Note:__ If not defined otherwise the prop `showSubHeaderRight` of the `DynamicPageTitle` is set to `true` by default.
55
   */
56
  headerTitle?: ReactElement;
57
  /**
58
   * Defines the dynamic header section of the `ObjectPage`.
59
   *
60
   * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `DynamicPageHeader` in order to preserve the intended design.
61
   */
62
  headerContent?: ReactElement;
63
  /**
64
   * React element which defines the footer content.
65
   *
66
   * __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.
67
   */
68
  footer?: ReactElement;
69
  /**
70
   * Defines the image of the `ObjectPage`. You can pass a path to an image or an `Avatar` component.
71
   */
72
  image?: string | ReactElement;
73
  /**
74
   * Defines the content area of the `ObjectPage`. It consists of sections and subsections.
75
   *
76
   * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `ObjectPageSection` and `ObjectPageSubSection` in order to preserve the intended design.
77
   */
78
  children?: ReactElement<ObjectPageSectionPropTypes> | ReactElement<ObjectPageSectionPropTypes>[];
79
  /**
80
   * Defines the ID of the currently `ObjectPageSection` section.
81
   */
82
  selectedSectionId?: string;
83
  /**
84
   * Defines the ID of the currently `ObjectPageSubSection` section.
85
   */
86
  selectedSubSectionId?: string;
87
  /**
88
   * Defines whether the `headerContent` is hidden by scrolling down.
89
   */
90
  alwaysShowContentHeader?: boolean;
91
  /**
92
   * Defines whether the title is displayed in the content section of the header or above the image.
93
   */
94
  showTitleInHeaderContent?: boolean;
95
  /**
96
   * Defines whether the image should be displayed in a circle or in a square.<br />
97
   * __Note:__ If the `image` is not a `string`, this prop has no effect.
98
   */
99
  imageShapeCircle?: boolean;
100
  /**
101
   * Defines the `ObjectPage` mode.
102
   *
103
   * - "Default": All `ObjectPageSections` and `ObjectPageSubSections` are displayed on one page. Selecting tabs will scroll to the corresponding section.
104
   * - "IconTabBar": All `ObjectPageSections` are displayed on separate pages. Selecting tabs will lead to the corresponding page.
105
   */
106
  mode?: ObjectPageMode | keyof typeof ObjectPageMode;
107
  /**
108
   * Defines whether the pin button of the header is displayed.
109
   */
110
  showHideHeaderButton?: boolean;
111
  /**
112
   * Defines whether the `headerContent` is pinnable.
113
   */
114
  headerContentPinnable?: boolean;
115
  /**
116
   * Defines internally used a11y properties.
117
   */
118
  a11yConfig?: {
119
    objectPageTopHeader?: {
120
      role?: string;
121
      ariaRoledescription?: string;
122
    };
123
    dynamicPageAnchorBar?: {
124
      role?: string;
125
    };
126
  };
127
  /**
128
   * If set, only the specified placeholder will be displayed as content of the `ObjectPage`, no sections or sub-sections will be rendered.
129
   *
130
   * __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.
131
   */
132
  placeholder?: ReactNode;
133
  /**
134
   * Fired when the selected section changes.
135
   */
136
  onSelectedSectionChange?: (
137
    event: CustomEvent<{ selectedSectionIndex: number; selectedSectionId: string; section: HTMLDivElement }>
138
  ) => void;
139
  /**
140
   * Fired when the `headerContent` is expanded or collapsed.
141
   */
142
  onToggleHeaderContent?: (visible: boolean) => void;
143
  /**
144
   * Fired when the `headerContent` changes its pinned state.
145
   */
146
  onPinnedStateChange?: (pinned: boolean) => void;
147
}
148

149
const useStyles = createUseStyles(styles, { name: 'ObjectPage' });
379✔
150

151
/**
152
 * A component that allows apps to easily display information related to a business object.
153
 *
154
 * The `ObjectPage` is composed of a header (title and content) and block content wrapped in sections and subsections that structure the information.
155
 */
156
const ObjectPage = forwardRef<HTMLDivElement, ObjectPagePropTypes>((props, ref) => {
379✔
157
  const {
158
    headerTitle,
159
    image,
160
    footer,
161
    mode,
162
    imageShapeCircle,
163
    className,
164
    style,
165
    slot,
166
    showHideHeaderButton,
167
    children,
168
    selectedSectionId,
169
    alwaysShowContentHeader,
170
    showTitleInHeaderContent,
171
    headerContent,
172
    headerContentPinnable,
173
    a11yConfig,
174
    placeholder,
175
    onSelectedSectionChange,
176
    onToggleHeaderContent,
177
    onPinnedStateChange,
178
    ...rest
179
  } = props;
3,472✔
180

181
  const classes = useStyles();
3,472✔
182

183
  const firstSectionId = safeGetChildrenArray<ReactElement>(children)[0]?.props?.id;
3,472✔
184

185
  const [internalSelectedSectionId, setInternalSelectedSectionId] = useState(selectedSectionId ?? firstSectionId);
3,472✔
186
  const [selectedSubSectionId, setSelectedSubSectionId] = useState(props.selectedSubSectionId);
3,472✔
187
  const [headerPinned, setHeaderPinned] = useState(alwaysShowContentHeader);
3,472✔
188
  const isProgrammaticallyScrolled = useRef(false);
3,472✔
189

190
  const [componentRef, objectPageRef] = useSyncRef(ref);
3,472✔
191
  const topHeaderRef = useRef<HTMLDivElement>(null);
3,472✔
192
  const scrollEvent = useRef();
3,472✔
193
  // @ts-expect-error: useSyncRef will create a ref if not present
194
  const [componentRefHeaderContent, headerContentRef] = useSyncRef(headerContent?.ref);
3,472✔
195
  const anchorBarRef = useRef<HTMLDivElement>(null);
3,472✔
196
  const selectionScrollTimeout = useRef(null);
3,472✔
197
  const [isAfterScroll, setIsAfterScroll] = useState(false);
3,472✔
198
  const isToggledRef = useRef(false);
3,472✔
199
  const [headerCollapsedInternal, setHeaderCollapsedInternal] = useState<undefined | boolean>(undefined);
3,472✔
200
  const [scrolledHeaderExpanded, setScrolledHeaderExpanded] = useState(false);
3,472✔
201
  const scrollTimeout = useRef(0);
3,472✔
202
  const [spacerBottomHeight, setSpacerBottomHeight] = useState('0px');
3,472✔
203

204
  const prevInternalSelectedSectionId = useRef(internalSelectedSectionId);
3,472✔
205
  const fireOnSelectedChangedEvent = (targetEvent, index, id, section) => {
3,472✔
206
    if (typeof onSelectedSectionChange === 'function' && prevInternalSelectedSectionId.current !== id) {
167!
207
      onSelectedSectionChange(
×
208
        enrichEventWithDetails(targetEvent, {
209
          selectedSectionIndex: parseInt(index, 10),
210
          selectedSectionId: id,
211
          section
212
        })
213
      );
214
      prevInternalSelectedSectionId.current = id;
×
215
    }
216
  };
217
  const debouncedOnSectionChange = useRef(debounce(fireOnSelectedChangedEvent, 500)).current;
3,472✔
218
  useEffect(() => {
3,472✔
219
    return () => {
252✔
220
      debouncedOnSectionChange.cancel();
236✔
221
      clearTimeout(selectionScrollTimeout.current);
236✔
222
    };
223
  }, []);
224

225
  // observe heights of header parts
226
  const { topHeaderHeight, headerContentHeight, anchorBarHeight, totalHeaderHeight, headerCollapsed } =
227
    useObserveHeights(
3,472✔
228
      objectPageRef,
229
      topHeaderRef,
230
      headerContentRef,
231
      anchorBarRef,
232
      [headerCollapsedInternal, setHeaderCollapsedInternal],
233
      {
234
        noHeader: !headerTitle && !headerContent,
3,476✔
235
        fixedHeader: headerPinned,
236
        scrollTimeout
237
      }
238
    );
239

240
  useEffect(() => {
3,472✔
241
    if (typeof onToggleHeaderContent === 'function' && isToggledRef.current) {
694✔
242
      onToggleHeaderContent(headerCollapsed !== true);
60✔
243
    }
244
  }, [headerCollapsed]);
245

246
  const avatar = useMemo(() => {
3,472✔
247
    if (!image) {
252✔
248
      return null;
243✔
249
    }
250

251
    if (typeof image === 'string') {
9✔
252
      return (
6✔
253
        <span
254
          className={classes.headerImage}
255
          style={{ borderRadius: imageShapeCircle ? '50%' : 0, overflow: 'hidden' }}
6✔
256
        >
257
          <img src={image} className={classes.image} alt="Company Logo" />
258
        </span>
259
      );
260
    } else {
261
      return cloneElement(image, {
3✔
262
        size: AvatarSize.L,
263
        className: clsx(classes.headerImage, image.props?.className)
264
      } as AvatarPropTypes);
265
    }
266
  }, [image, classes.headerImage, classes.image, imageShapeCircle]);
267

268
  const prevTopHeaderHeight = useRef(0);
3,472✔
269
  const scrollToSection = useCallback(
3,472✔
270
    (sectionId) => {
271
      if (!sectionId) {
68!
272
        return;
×
273
      }
274
      if (firstSectionId === sectionId) {
68✔
275
        objectPageRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
30✔
276
      } else {
277
        const childOffset = objectPageRef.current?.querySelector<HTMLElement>(`#ObjectPageSection-${sectionId}`)
38✔
278
          ?.offsetTop;
279
        if (!isNaN(childOffset)) {
38✔
280
          const safeTopHeaderHeight = topHeaderHeight || prevTopHeaderHeight.current;
38!
281
          if (topHeaderHeight) {
38✔
282
            prevTopHeaderHeight.current = topHeaderHeight;
38✔
283
          }
284
          objectPageRef.current?.scrollTo({
38✔
285
            top:
286
              childOffset -
287
              safeTopHeaderHeight -
288
              anchorBarHeight -
289
              TAB_CONTAINER_HEADER_HEIGHT /*tabBar*/ -
290
              (headerPinned ? (headerCollapsed === true ? 0 : headerContentHeight) : 0),
38!
291
            behavior: 'smooth'
292
          });
293
        }
294
      }
295
      isProgrammaticallyScrolled.current = false;
68✔
296
    },
297
    [
298
      firstSectionId,
299
      objectPageRef,
300
      topHeaderHeight,
301
      anchorBarHeight,
302
      headerPinned,
303
      headerContentHeight,
304
      headerCollapsed,
305
      prevTopHeaderHeight.current
306
    ]
307
  );
308

309
  const programmaticallySetSection = () => {
3,472✔
310
    const currentId = selectedSectionId ?? firstSectionId;
×
311
    if (currentId !== prevSelectedSectionId.current) {
×
312
      debouncedOnSectionChange.cancel();
×
313
      isProgrammaticallyScrolled.current = true;
×
314
      setInternalSelectedSectionId(currentId);
×
315
      prevSelectedSectionId.current = currentId;
×
316
      const sections = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
×
317
      const currentIndex = safeGetChildrenArray(children).findIndex((objectPageSection) => {
×
318
        return isValidElement(objectPageSection) && objectPageSection.props?.id === currentId;
×
319
      });
320
      fireOnSelectedChangedEvent({}, currentIndex, currentId, sections[0]);
×
321
    }
322
  };
323

324
  // change selected section when prop is changed (external change)
325
  const prevSelectedSectionId = useRef();
3,472✔
326
  const [timeStamp, setTimeStamp] = useState(0);
3,472✔
327
  const requestAnimationFrameRef = useRef<undefined | number>();
3,472✔
328
  useEffect(() => {
3,472✔
329
    if (selectedSectionId) {
252!
330
      if (mode === ObjectPageMode.Default) {
×
331
        // wait for DOM draw, otherwise initial scroll won't work as intended
332
        if (timeStamp < 750 && timeStamp !== undefined) {
×
333
          requestAnimationFrameRef.current = requestAnimationFrame((internalTimestamp) => {
×
334
            setTimeStamp(internalTimestamp);
×
335
          });
336
        } else {
337
          setTimeStamp(undefined);
×
338
          programmaticallySetSection();
×
339
        }
340
      } else {
341
        programmaticallySetSection();
×
342
      }
343
    }
344
    return () => {
252✔
345
      cancelAnimationFrame(requestAnimationFrameRef.current);
236✔
346
    };
347
  }, [timeStamp, selectedSectionId, firstSectionId, debouncedOnSectionChange]);
348

349
  // section was selected by clicking on the tab bar buttons
350
  const handleOnSectionSelected = useCallback(
3,472✔
351
    (targetEvent, newSelectionSectionId, index, section) => {
352
      isProgrammaticallyScrolled.current = true;
132✔
353
      debouncedOnSectionChange.cancel();
132✔
354
      setInternalSelectedSectionId((oldSelectedSection) => {
132✔
355
        if (oldSelectedSection === newSelectionSectionId) {
132!
356
          scrollToSection(newSelectionSectionId);
×
357
        }
358
        return newSelectionSectionId;
132✔
359
      });
360
      scrollEvent.current = targetEvent;
132✔
361
      fireOnSelectedChangedEvent(targetEvent, index, newSelectionSectionId, section);
132✔
362
    },
363
    [onSelectedSectionChange, setInternalSelectedSectionId, isProgrammaticallyScrolled, scrollToSection]
364
  );
365

366
  // do internal scrolling
367
  useEffect(() => {
3,472✔
368
    if (mode === ObjectPageMode.Default && isProgrammaticallyScrolled.current === true && !selectedSubSectionId) {
1,720✔
369
      scrollToSection(internalSelectedSectionId);
68✔
370
    }
371
  }, [internalSelectedSectionId, mode, isProgrammaticallyScrolled, scrollToSection, selectedSubSectionId]);
372

373
  // Scrolling for Sub Section Selection
374
  useEffect(() => {
3,472✔
375
    if (selectedSubSectionId && isProgrammaticallyScrolled.current === true) {
1,248✔
376
      const currentSubSection = objectPageRef.current?.querySelector<HTMLDivElement>(
58✔
377
        `div[id="ObjectPageSubSection-${selectedSubSectionId}"]`
378
      );
379
      const childOffset = currentSubSection?.offsetTop;
58✔
380
      if (!isNaN(childOffset)) {
58✔
381
        currentSubSection.focus({ preventScroll: true });
58✔
382
        objectPageRef.current?.scrollTo({
58✔
383
          top:
384
            childOffset -
385
            topHeaderHeight -
386
            anchorBarHeight -
387
            TAB_CONTAINER_HEADER_HEIGHT /*tabBar*/ -
388
            (headerPinned ? headerContentHeight : 0) -
58!
389
            16,
390
          behavior: 'smooth'
391
        });
392
      }
393
      isProgrammaticallyScrolled.current = false;
58✔
394
    }
395
  }, [
396
    selectedSubSectionId,
397
    isProgrammaticallyScrolled.current,
398
    topHeaderHeight,
399
    anchorBarHeight,
400
    headerPinned,
401
    headerContentHeight
402
  ]);
403

404
  useEffect(() => {
3,472✔
405
    if (alwaysShowContentHeader !== undefined) {
343✔
406
      setHeaderPinned(alwaysShowContentHeader);
104✔
407
    }
408
    if (alwaysShowContentHeader) {
343✔
409
      onToggleHeaderContentVisibility({ detail: { visible: true } });
52✔
410
    }
411
  }, [alwaysShowContentHeader]);
412

413
  const prevHeaderPinned = useRef(headerPinned);
3,472✔
414
  useEffect(() => {
3,472✔
415
    if (prevHeaderPinned.current && !headerPinned && objectPageRef.current.scrollTop > topHeaderHeight) {
611✔
416
      onToggleHeaderContentVisibility({ detail: { visible: false } });
53✔
417
      prevHeaderPinned.current = false;
53✔
418
    }
419
    if (!prevHeaderPinned.current && headerPinned) {
611✔
420
      prevHeaderPinned.current = true;
66✔
421
    }
422
  }, [headerPinned, topHeaderHeight]);
423

424
  useEffect(() => {
3,472✔
425
    setSelectedSubSectionId(props.selectedSubSectionId);
606✔
426
    if (props.selectedSubSectionId) {
606!
427
      isProgrammaticallyScrolled.current = true;
×
428
      if (mode === ObjectPageMode.IconTabBar) {
×
429
        let sectionId;
430
        safeGetChildrenArray<ReactElement<ObjectPageSectionPropTypes>>(children).forEach((section) => {
×
431
          if (isValidElement(section) && section.props && section.props.children) {
×
432
            safeGetChildrenArray(section.props.children).forEach((subSection) => {
×
433
              if (
×
434
                isValidElement(subSection) &&
×
435
                subSection.props &&
436
                subSection.props.id === props.selectedSubSectionId
437
              ) {
438
                sectionId = section.props?.id;
×
439
              }
440
            });
441
          }
442
        });
443
        if (sectionId) {
×
444
          setInternalSelectedSectionId(sectionId);
×
445
        }
446
      }
447
    }
448
  }, [props.selectedSubSectionId, setInternalSelectedSectionId, setSelectedSubSectionId, children, mode]);
449

450
  useEffect(() => {
3,472✔
451
    const objectPage = objectPageRef.current;
1,324✔
452

453
    const sections = objectPage.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSection"]');
1,324✔
454
    const section = sections[sections.length - 1];
1,324✔
455

456
    const observer = new ResizeObserver(([sectionElement]) => {
1,324✔
457
      let heightDiff = 0;
1,146✔
458
      if (objectPage.scrollHeight === objectPage.offsetHeight) {
1,146✔
459
        heightDiff = Math.max(
407✔
460
          objectPage.getBoundingClientRect().bottom - sectionElement.target.getBoundingClientRect().bottom,
461
          0
462
        );
463
      }
464
      const subSections = section.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSubSection"]');
1,146✔
465
      const lastSubSection = subSections[subSections.length - 1];
1,146✔
466
      if (lastSubSection) {
1,146✔
467
        heightDiff +=
213✔
468
          objectPage.getBoundingClientRect().height -
469
          topHeaderHeight -
470
          TAB_CONTAINER_HEADER_HEIGHT /*tabBar*/ -
471
          (!headerCollapsed ? headerContentHeight : 0) -
213✔
472
          lastSubSection.getBoundingClientRect().height -
473
          32;
474
      }
475
      // heightDiff - footer - tabbar
476
      setSpacerBottomHeight(
1,146✔
477
        footer ? `calc(${heightDiff}px - 1rem - ${TAB_CONTAINER_HEADER_HEIGHT}px)` : `${heightDiff}px`
1,146✔
478
      );
479
    });
480

481
    if (objectPage && section) {
1,324✔
482
      observer.observe(section, { box: 'border-box' });
1,272✔
483
    }
484

485
    return () => {
1,324✔
486
      observer.disconnect();
1,308✔
487
    };
488
  }, [footer, headerCollapsed, topHeaderHeight, headerContentHeight]);
489

490
  const onToggleHeaderContentVisibility = useCallback((e) => {
3,472✔
491
    isToggledRef.current = true;
262✔
492
    scrollTimeout.current = performance.now() + 500;
262✔
493
    if (!e.detail.visible) {
262✔
494
      setHeaderCollapsedInternal(true);
149✔
495
      objectPageRef.current?.classList.add(classes.headerCollapsed);
149✔
496
    } else {
497
      setHeaderCollapsedInternal(false);
113✔
498
      setScrolledHeaderExpanded(true);
113✔
499
      objectPageRef.current?.classList.remove(classes.headerCollapsed);
113✔
500
    }
501
  }, []);
502

503
  const handleOnSubSectionSelected = useCallback(
3,472✔
504
    (e) => {
505
      isProgrammaticallyScrolled.current = true;
58✔
506
      if (mode === ObjectPageMode.IconTabBar) {
58✔
507
        const sectionId = e.detail.sectionId;
28✔
508
        setInternalSelectedSectionId(sectionId);
28✔
509

510
        const sections = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
28✔
511
        const currentIndex = safeGetChildrenArray(children).findIndex((objectPageSection) => {
28✔
512
          return isValidElement(objectPageSection) && objectPageSection.props?.id === sectionId;
88✔
513
        });
514
        debouncedOnSectionChange(e, currentIndex, sectionId, sections[currentIndex]);
28✔
515
      }
516
      const subSectionId = e.detail.subSectionId;
58✔
517
      setSelectedSubSectionId(subSectionId);
58✔
518
    },
519
    [mode, setInternalSelectedSectionId, setSelectedSubSectionId, isProgrammaticallyScrolled, children]
520
  );
521

522
  const objectPageClasses = clsx(
3,472✔
523
    classes.objectPage,
524
    GlobalStyleClasses.sapScrollBar,
525
    className,
526
    mode === ObjectPageMode.IconTabBar && classes.iconTabBarMode
4,425✔
527
  );
528

529
  const { onScroll: _0, selectedSubSectionId: _1, ...propsWithoutOmitted } = rest;
3,472✔
530

531
  useEffect(() => {
3,472✔
532
    const sections = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
1,442✔
533
    const objectPageHeight = objectPageRef.current?.clientHeight ?? 1000;
1,442!
534
    const marginBottom = objectPageHeight - totalHeaderHeight - /*TabContainer*/ TAB_CONTAINER_HEADER_HEIGHT;
1,442✔
535
    const rootMargin = `-${totalHeaderHeight}px 0px -${marginBottom < 0 ? 0 : marginBottom}px 0px`;
1,442✔
536
    const observer = new IntersectionObserver(
1,442✔
537
      ([section]) => {
538
        if (section.isIntersecting && isProgrammaticallyScrolled.current === false) {
1,305✔
539
          if (
1,092✔
540
            objectPageRef.current.getBoundingClientRect().top + totalHeaderHeight + TAB_CONTAINER_HEADER_HEIGHT <=
541
            section.target.getBoundingClientRect().bottom
542
          ) {
543
            const currentId = extractSectionIdFromHtmlId(section.target.id);
1,048✔
544
            setInternalSelectedSectionId(currentId);
1,048✔
545
            const currentIndex = safeGetChildrenArray(children).findIndex((objectPageSection) => {
1,048✔
546
              return isValidElement(objectPageSection) && objectPageSection.props?.id === currentId;
1,209✔
547
            });
548
            debouncedOnSectionChange(scrollEvent.current, currentIndex, currentId, section.target);
1,048✔
549
          }
550
        }
551
      },
552
      {
553
        root: objectPageRef.current,
554
        rootMargin,
555
        threshold: [0]
556
      }
557
    );
558
    // Fallback when scrolling faster than the IntersectionObserver can observe (in most cases faster than 60fps)
559
    if (isAfterScroll) {
1,442✔
560
      let currentSection = sections[sections.length - 1];
103✔
561
      let currentIndex;
562
      for (let i = 0; i <= sections.length - 1; i++) {
103✔
563
        const section = sections[i];
103✔
564
        if (
103✔
565
          objectPageRef.current.getBoundingClientRect().top + totalHeaderHeight + TAB_CONTAINER_HEADER_HEIGHT <=
566
          section.getBoundingClientRect().bottom
567
        ) {
568
          currentSection = section;
103✔
569
          currentIndex = i;
103✔
570
          break;
103✔
571
        }
572
      }
573
      const currentSectionId = extractSectionIdFromHtmlId(currentSection?.id);
103✔
574
      if (currentSectionId !== internalSelectedSectionId) {
103!
575
        setInternalSelectedSectionId(currentSectionId);
×
576
        debouncedOnSectionChange(
×
577
          scrollEvent.current,
578
          currentIndex ?? sections.length - 1,
×
579
          currentSectionId,
580
          currentSection
581
        );
582
      }
583
      setIsAfterScroll(false);
103✔
584
    }
585

586
    sections.forEach((el) => {
1,442✔
587
      observer.observe(el);
1,853✔
588
    });
589

590
    return () => {
1,442✔
591
      observer.disconnect();
1,426✔
592
    };
593
  }, [
594
    objectPageRef.current,
595
    children,
596
    totalHeaderHeight,
597
    setInternalSelectedSectionId,
598
    isProgrammaticallyScrolled,
599
    isAfterScroll
600
  ]);
601

602
  const titleHeaderNotClickable =
603
    (alwaysShowContentHeader && !headerContentPinnable) ||
3,472✔
604
    !headerContent ||
605
    (!showHideHeaderButton && !headerContentPinnable);
606

607
  const onTitleClick = useCallback(
3,472✔
608
    (e) => {
609
      e.stopPropagation();
30✔
610
      if (!titleHeaderNotClickable) {
30✔
611
        onToggleHeaderContentVisibility(enrichEventWithDetails(e, { visible: headerCollapsed }));
30✔
612
      }
613
    },
614
    [onToggleHeaderContentVisibility, headerCollapsed, titleHeaderNotClickable]
615
  );
616

617
  const renderTitleSection = useCallback(
3,472✔
618
    (inHeader = false) => {
3,468✔
619
      const titleStyles = { ...(inHeader ? { padding: 0 } : {}), ...(headerTitle?.props?.style ?? {}) };
3,468!
620

621
      if (headerTitle?.props && headerTitle.props?.showSubHeaderRight === undefined) {
3,468✔
622
        return cloneElement(headerTitle, {
3,468✔
623
          showSubHeaderRight: true,
624
          style: titleStyles,
625
          'data-not-clickable': titleHeaderNotClickable,
626
          onToggleHeaderContentVisibility: onTitleClick
627
        });
628
      }
629
      return cloneElement(headerTitle, {
×
630
        style: titleStyles,
631
        'data-not-clickable': titleHeaderNotClickable,
632
        onToggleHeaderContentVisibility: onTitleClick
633
      });
634
    },
635
    [headerTitle, titleHeaderNotClickable, onTitleClick]
636
  );
637

638
  const renderHeaderContentSection = useCallback(() => {
3,472✔
639
    if (headerContent?.props) {
3,472✔
640
      return cloneElement(headerContent, {
3,425✔
641
        ...headerContent.props,
642
        topHeaderHeight,
643
        style: headerCollapsed === true ? { position: 'absolute', visibility: 'hidden' } : headerContent.props.style,
3,425✔
644
        headerPinned: headerPinned || scrolledHeaderExpanded,
6,507✔
645
        ref: componentRefHeaderContent,
646
        children: (
647
          <div className={classes.headerContainer} data-component-name="ObjectPageHeaderContainer">
648
            {avatar}
649
            {headerContent.props.children && (
6,850✔
650
              <div data-component-name="ObjectPageHeaderContent">
651
                {headerTitle && showTitleInHeaderContent && renderTitleSection(true)}
6,850!
652
                {headerContent.props.children}
653
              </div>
654
            )}
655
          </div>
656
        )
657
      });
658
    }
659
  }, [
660
    headerContent,
661
    topHeaderHeight,
662
    headerPinned,
663
    scrolledHeaderExpanded,
664
    showTitleInHeaderContent,
665
    avatar,
666
    headerContentRef,
667
    renderTitleSection
668
  ]);
669

670
  const onTabItemSelect = (event) => {
3,472✔
671
    event.preventDefault();
190✔
672
    const { sectionId, index, isSubTab, parentId } = event.detail.tab.dataset;
190✔
673
    if (isSubTab) {
190✔
674
      handleOnSubSectionSelected(enrichEventWithDetails(event, { sectionId: parentId, subSectionId: sectionId }));
58✔
675
    } else {
676
      const section = safeGetChildrenArray<ReactElement>(children).find((el) => {
132✔
677
        return el.props.id == sectionId;
306✔
678
      });
679
      handleOnSectionSelected(event, section?.props?.id, index, section);
132✔
680
    }
681
  };
682

683
  const prevScrollTop = useRef();
3,472✔
684
  const onObjectPageScroll = useCallback(
3,472✔
685
    (e) => {
686
      if (!isToggledRef.current) {
3,414✔
687
        isToggledRef.current = true;
143✔
688
      }
689
      if (scrollTimeout.current >= performance.now()) {
3,414✔
690
        return;
122✔
691
      }
692
      scrollEvent.current = e;
3,292✔
693
      if (typeof props.onScroll === 'function') {
3,292!
694
        props.onScroll(e);
×
695
      }
696
      if (selectedSubSectionId) {
3,292✔
697
        setSelectedSubSectionId(undefined);
50✔
698
      }
699
      if (selectionScrollTimeout.current) {
3,292✔
700
        clearTimeout(selectionScrollTimeout.current);
3,149✔
701
      }
702
      selectionScrollTimeout.current = setTimeout(() => {
3,292✔
703
        setIsAfterScroll(true);
103✔
704
      }, 100);
705
      if (!headerPinned || e.target.scrollTop === 0) {
3,292✔
706
        objectPageRef.current?.classList.remove(classes.headerCollapsed);
3,278✔
707
      }
708
      if (scrolledHeaderExpanded && e.target.scrollTop !== prevScrollTop.current) {
3,292✔
709
        if (e.target.scrollHeight - e.target.scrollTop === e.target.clientHeight) {
13!
710
          return;
×
711
        }
712
        prevScrollTop.current = e.target.scrollTop;
13✔
713
        if (!headerPinned) {
13✔
714
          setHeaderCollapsedInternal(true);
13✔
715
        }
716
        setScrolledHeaderExpanded(false);
13✔
717
      }
718
    },
719
    [topHeaderHeight, headerPinned, props.onScroll, scrolledHeaderExpanded, selectedSubSectionId]
720
  );
721

722
  const onHoverToggleButton = useCallback(
3,472✔
723
    (e) => {
724
      if (e?.type === 'mouseover') {
188✔
725
        topHeaderRef.current?.classList.add(classes.headerHoverStyles);
124✔
726
      } else {
727
        topHeaderRef.current?.classList.remove(classes.headerHoverStyles);
64✔
728
      }
729
    },
730
    [classes.headerHoverStyles]
731
  );
732

733
  const objectPageStyles = { ...style };
3,472✔
734
  if (headerCollapsed === true && headerContent) {
3,472✔
735
    objectPageStyles[DynamicPageCssVariables.titleFontSize] = ThemingParameters.sapObjectHeader_Title_SnappedFontSize;
1,310✔
736
  }
737

738
  return (
3,472✔
739
    <div
740
      data-component-name="ObjectPage"
741
      slot={slot}
742
      className={objectPageClasses}
743
      style={objectPageStyles}
744
      ref={componentRef}
745
      onScroll={onObjectPageScroll}
746
      {...propsWithoutOmitted}
747
    >
748
      <header
749
        onMouseOver={onHoverToggleButton}
750
        onMouseLeave={onHoverToggleButton}
751
        data-component-name="ObjectPageTopHeader"
752
        ref={topHeaderRef}
753
        role={a11yConfig?.objectPageTopHeader?.role}
754
        data-not-clickable={titleHeaderNotClickable}
755
        aria-roledescription={a11yConfig?.objectPageTopHeader?.ariaRoledescription ?? 'Object Page header'}
6,944✔
756
        className={classes.header}
757
        onClick={onTitleClick}
758
        style={{
759
          gridAutoColumns: `min-content ${
760
            headerTitle && image && headerCollapsed === true ? `calc(100% - 3rem - 1rem)` : '100%'
10,423!
761
          }`,
762
          display: !showTitleInHeaderContent || headerCollapsed === true ? 'grid' : 'none'
6,944!
763
        }}
764
      >
765
        {headerTitle && image && headerCollapsed === true && (
6,951!
766
          <CollapsedAvatar image={image} imageShapeCircle={imageShapeCircle} />
767
        )}
768
        {headerTitle && renderTitleSection()}
6,940✔
769
      </header>
770
      {renderHeaderContentSection()}
771
      {headerContent && headerTitle && (
10,322✔
772
        <div
773
          data-component-name="ObjectPageAnchorBar"
774
          ref={anchorBarRef}
775
          className={classes.anchorBar}
776
          style={{
777
            top:
778
              scrolledHeaderExpanded || headerPinned
9,510✔
779
                ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight)}px`
861✔
780
                : `${topHeaderHeight + 5}px`
781
          }}
782
        >
783
          <DynamicPageAnchorBar
784
            headerContentVisible={headerContent && headerCollapsed !== true}
6,850✔
785
            headerContentPinnable={headerContentPinnable}
786
            showHideHeaderButton={showHideHeaderButton}
787
            headerPinned={headerPinned}
788
            a11yConfig={a11yConfig}
789
            onToggleHeaderContentVisibility={onToggleHeaderContentVisibility}
790
            setHeaderPinned={setHeaderPinned}
791
            onHoverToggleButton={onHoverToggleButton}
792
            onPinnedStateChange={onPinnedStateChange}
793
          />
794
        </div>
795
      )}
796
      {!placeholder && (
6,939✔
797
        <div
798
          className={classes.tabContainer}
799
          data-component-name="ObjectPageTabContainer"
800
          style={{
801
            top:
802
              headerPinned || scrolledHeaderExpanded
10,058✔
803
                ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight)}px`
861✔
804
                : `${topHeaderHeight}px`
805
          }}
806
        >
807
          <TabContainer
808
            collapsed
809
            fixed
810
            onTabSelect={onTabItemSelect}
811
            data-component-name="ObjectPageTabContainer"
812
            className={classes.tabContainerComponent}
813
          >
814
            {safeGetChildrenArray(children).map((section, index) => {
815
              if (!isValidElement(section) || !section.props) return null;
6,206!
816
              const subTabs = safeGetChildrenArray(section.props.children).filter(
6,206✔
817
                (subSection) =>
818
                  // @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.
819
                  isValidElement(subSection) && subSection?.type?.displayName === 'ObjectPageSubSection'
9,285✔
820
              );
821
              return (
6,206✔
822
                <Tab
823
                  key={`Anchor-${section.props?.id}`}
824
                  data-index={index}
825
                  data-section-id={section.props.id}
826
                  text={section.props.titleText}
827
                  selected={internalSelectedSectionId === section.props?.id || undefined}
9,010✔
828
                  subTabs={subTabs.map((item) => {
829
                    if (!isValidElement(item)) {
3,790!
830
                      return null;
×
831
                    }
832
                    return (
3,790✔
833
                      <Tab
834
                        data-parent-id={section.props.id}
835
                        key={item.props.id}
836
                        data-is-sub-tab
837
                        data-section-id={item.props.id}
838
                        text={item.props.titleText}
839
                        selected={item.props.id === selectedSubSectionId || undefined}
7,506✔
840
                      >
841
                        {/*ToDo: workaround for nested tab selection*/}
842
                        <span style={{ display: 'none' }} />
843
                      </Tab>
844
                    );
845
                  })}
846
                >
847
                  {/*ToDo: workaround for nested tab selection*/}
848
                  <span style={{ display: 'none' }} />
849
                </Tab>
850
              );
851
            })}
852
          </TabContainer>
853
        </div>
854
      )}
855
      <div data-component-name="ObjectPageContent" className={classes.content}>
856
        <div style={{ height: headerCollapsed ? `${headerContentHeight}px` : 0 }} aria-hidden="true" />
3,472✔
857
        {placeholder
3,472✔
858
          ? placeholder
859
          : mode === ObjectPageMode.IconTabBar
3,467✔
860
          ? getSectionById(children, internalSelectedSectionId)
861
          : children}
862
        <div style={{ height: spacerBottomHeight }} aria-hidden="true" />
863
      </div>
864
      {footer && <div style={{ height: '1rem' }} data-component-name="ObjectPageFooterSpacer" />}
4,161✔
865
      {footer && (
4,161✔
866
        <footer className={classes.footer} data-component-name="ObjectPageFooter">
867
          {footer}
868
        </footer>
869
      )}
870
    </div>
871
  );
872
});
873

874
ObjectPage.displayName = 'ObjectPage';
379✔
875

876
ObjectPage.defaultProps = {
379✔
877
  image: null,
878
  mode: ObjectPageMode.Default,
879
  imageShapeCircle: false,
880
  showHideHeaderButton: false
881
};
882

883
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