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

SAP / ui5-webcomponents-react / 5087989474

pending completion
5087989474

Pull #4639

github

GitHub
Merge 0953c5e55 into cd3175438
Pull Request #4639: docs(CustomComponentsSlots): improve wording

2639 of 3617 branches covered (72.96%)

5118 of 5917 relevant lines covered (86.5%)

13981.2 hits per line

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

82.17
/packages/main/src/components/ObjectPage/index.tsx
1
'use client';
2,439✔
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(
271✔
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;
271✔
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' });
271✔
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) => {
3,477✔
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,465✔
180

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

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

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

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

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

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

246
  const avatar = useMemo(() => {
3,465✔
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,465✔
269
  const scrollToSection = useCallback(
3,465✔
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>(
38✔
278
          `#ObjectPageSection-${sectionId}`
279
        )?.offsetTop;
280
        if (!isNaN(childOffset)) {
38✔
281
          const safeTopHeaderHeight = topHeaderHeight || prevTopHeaderHeight.current;
38!
282
          if (topHeaderHeight) {
38✔
283
            prevTopHeaderHeight.current = topHeaderHeight;
38✔
284
          }
285
          objectPageRef.current?.scrollTo({
38✔
286
            top:
287
              childOffset -
288
              safeTopHeaderHeight -
289
              anchorBarHeight -
290
              TAB_CONTAINER_HEADER_HEIGHT /*tabBar*/ -
291
              (headerPinned ? (headerCollapsed === true ? 0 : headerContentHeight) : 0),
38!
292
            behavior: 'smooth'
293
          });
294
        }
295
      }
296
      isProgrammaticallyScrolled.current = false;
68✔
297
    },
298
    [
299
      firstSectionId,
300
      objectPageRef,
301
      topHeaderHeight,
302
      anchorBarHeight,
303
      headerPinned,
304
      headerContentHeight,
305
      headerCollapsed,
306
      prevTopHeaderHeight.current
307
    ]
308
  );
309

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

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

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

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

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

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

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

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

451
  useEffect(() => {
3,465✔
452
    const objectPage = objectPageRef.current;
1,354✔
453

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

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

482
    if (objectPage && section) {
1,354✔
483
      observer.observe(section, { box: 'border-box' });
1,302✔
484
    }
485

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

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

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

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

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

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

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

587
    sections.forEach((el) => {
1,436✔
588
      observer.observe(el);
1,913✔
589
    });
590

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

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

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

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

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

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

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

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

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

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

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

875
ObjectPage.displayName = 'ObjectPage';
271✔
876

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

884
export { ObjectPage };
542✔
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