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

SAP / ui5-webcomponents-react / 5922566277

21 Aug 2023 06:01AM CUT coverage: 87.786% (-0.02%) from 87.804%
5922566277

Pull #4981

github

web-flow
Merge 7b08b3faa into 7f195ef09
Pull Request #4981: chore(deps): update all non-major dependencies (examples) (patch)

2669 of 3608 branches covered (73.97%)

4952 of 5641 relevant lines covered (87.79%)

17890.91 hits per line

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

82.02
/packages/main/src/components/ObjectPage/index.tsx
1
'use client';
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(
380✔
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;
380✔
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' });
380✔
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) => {
380✔
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,363✔
180

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

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

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

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

204
  const prevInternalSelectedSectionId = useRef(internalSelectedSectionId);
3,363✔
205
  const fireOnSelectedChangedEvent = (targetEvent, index, id, section) => {
3,363✔
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,363✔
218
  useEffect(() => {
3,363✔
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,363✔
228
      objectPageRef,
229
      topHeaderRef,
230
      headerContentRef,
231
      anchorBarRef,
232
      [headerCollapsedInternal, setHeaderCollapsedInternal],
233
      {
234
        noHeader: !headerTitle && !headerContent,
3,367✔
235
        fixedHeader: headerPinned,
236
        scrollTimeout
237
      }
238
    );
239

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

246
  const avatar = useMemo(() => {
3,363✔
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,363✔
269
  const scrollToSection = useCallback(
3,363✔
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,363✔
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,363✔
326
  const [timeStamp, setTimeStamp] = useState(0);
3,363✔
327
  const requestAnimationFrameRef = useRef<undefined | number>();
3,363✔
328
  useEffect(() => {
3,363✔
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,363✔
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,363✔
368
    if (mode === ObjectPageMode.Default && isProgrammaticallyScrolled.current === true && !selectedSubSectionId) {
1,798✔
369
      scrollToSection(internalSelectedSectionId);
68✔
370
    }
371
  }, [internalSelectedSectionId, mode, isProgrammaticallyScrolled, scrollToSection, selectedSubSectionId]);
372

373
  // Scrolling for Sub Section Selection
374
  useEffect(() => {
3,363✔
375
    if (selectedSubSectionId && isProgrammaticallyScrolled.current === true) {
1,292✔
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,363✔
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,363✔
414
  useEffect(() => {
3,363✔
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,363✔
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,363✔
451
    const objectPage = objectPageRef.current;
1,353✔
452

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

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

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

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

490
  const onToggleHeaderContentVisibility = useCallback((e) => {
3,363✔
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,363✔
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,363✔
523
    classes.objectPage,
524
    GlobalStyleClasses.sapScrollBar,
525
    className,
526
    mode === ObjectPageMode.IconTabBar && classes.iconTabBarMode
4,289✔
527
  );
528

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

531
  useEffect(() => {
3,363✔
532
    const sections = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
1,258✔
533
    const objectPageHeight = objectPageRef.current?.clientHeight ?? 1000;
1,258!
534
    const marginBottom = objectPageHeight - totalHeaderHeight - /*TabContainer*/ TAB_CONTAINER_HEADER_HEIGHT;
1,258✔
535
    const rootMargin = `-${totalHeaderHeight}px 0px -${marginBottom < 0 ? 0 : marginBottom}px 0px`;
1,258✔
536
    const observer = new IntersectionObserver(
1,258✔
537
      ([section]) => {
538
        if (section.isIntersecting && isProgrammaticallyScrolled.current === false) {
1,222✔
539
          if (
1,006✔
540
            objectPageRef.current.getBoundingClientRect().top + totalHeaderHeight + TAB_CONTAINER_HEADER_HEIGHT <=
541
            section.target.getBoundingClientRect().bottom
542
          ) {
543
            const currentId = extractSectionIdFromHtmlId(section.target.id);
976✔
544
            setInternalSelectedSectionId(currentId);
976✔
545
            const currentIndex = safeGetChildrenArray(children).findIndex((objectPageSection) => {
976✔
546
              return isValidElement(objectPageSection) && objectPageSection.props?.id === currentId;
1,142✔
547
            });
548
            debouncedOnSectionChange(scrollEvent.current, currentIndex, currentId, section.target);
976✔
549
          }
550
        }
551
      },
552
      {
553
        root: objectPageRef.current,
554
        rootMargin,
555
        threshold: [0]
556
      }
557
    );
558

559
    sections.forEach((el) => {
1,258✔
560
      observer.observe(el);
1,735✔
561
    });
562

563
    return () => {
1,258✔
564
      observer.disconnect();
1,242✔
565
    };
566
  }, [children, totalHeaderHeight, setInternalSelectedSectionId, isProgrammaticallyScrolled]);
567

568
  // Fallback when scrolling faster than the IntersectionObserver can observe (in most cases faster than 60fps)
569
  useEffect(() => {
3,363✔
570
    const sections = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
430✔
571
    if (isAfterScroll) {
430✔
572
      let currentSection = sections[sections.length - 1];
89✔
573
      let currentIndex;
574
      for (let i = 0; i <= sections.length - 1; i++) {
89✔
575
        const section = sections[i];
89✔
576
        if (
89✔
577
          objectPageRef.current.getBoundingClientRect().top + totalHeaderHeight + TAB_CONTAINER_HEADER_HEIGHT <=
578
          section.getBoundingClientRect().bottom
579
        ) {
580
          currentSection = section;
89✔
581
          currentIndex = i;
89✔
582
          break;
89✔
583
        }
584
      }
585
      const currentSectionId = extractSectionIdFromHtmlId(currentSection?.id);
89✔
586
      if (currentSectionId !== internalSelectedSectionId) {
89!
587
        setInternalSelectedSectionId(currentSectionId);
×
588
        debouncedOnSectionChange(
×
589
          scrollEvent.current,
590
          currentIndex ?? sections.length - 1,
×
591
          currentSectionId,
592
          currentSection
593
        );
594
      }
595
      setIsAfterScroll(false);
89✔
596
    }
597
  }, [isAfterScroll]);
598

599
  const titleHeaderNotClickable =
600
    (alwaysShowContentHeader && !headerContentPinnable) ||
3,363✔
601
    !headerContent ||
602
    (!showHideHeaderButton && !headerContentPinnable);
603

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

614
  const renderTitleSection = useCallback(
3,363✔
615
    (inHeader = false) => {
3,359✔
616
      const titleInHeaderClass = inHeader ? classes.titleInHeader : undefined;
3,359!
617

618
      if (headerTitle?.props && headerTitle.props?.showSubHeaderRight === undefined) {
3,359✔
619
        return cloneElement(headerTitle, {
3,359✔
620
          showSubHeaderRight: true,
621
          className: clsx(titleInHeaderClass, headerTitle?.props?.className),
622
          'data-not-clickable': titleHeaderNotClickable,
623
          onToggleHeaderContentVisibility: onTitleClick
624
        });
625
      }
626
      return cloneElement(headerTitle, {
×
627
        className: clsx(titleInHeaderClass, headerTitle?.props?.className),
628
        'data-not-clickable': titleHeaderNotClickable,
629
        onToggleHeaderContentVisibility: onTitleClick
630
      });
631
    },
632
    [headerTitle, titleHeaderNotClickable, onTitleClick]
633
  );
634

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

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

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

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

730
  const objectPageStyles = { ...style };
3,363✔
731
  if (headerCollapsed === true && headerContent) {
3,363✔
732
    objectPageStyles[DynamicPageCssVariables.titleFontSize] = ThemingParameters.sapObjectHeader_Title_SnappedFontSize;
1,339✔
733
  }
734

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

871
ObjectPage.displayName = 'ObjectPage';
380✔
872

873
ObjectPage.defaultProps = {
380✔
874
  image: null,
875
  mode: ObjectPageMode.Default,
876
  imageShapeCircle: false,
877
  showHideHeaderButton: false
878
};
879

880
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