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

SAP / ui5-webcomponents-react / 10005734463

19 Jul 2024 09:21AM CUT coverage: 79.583% (-0.09%) from 79.67%
10005734463

Pull #6098

github

web-flow
Merge aa9112769 into 105b2da44
Pull Request #6098: docs(MigrationGuide): format headings

2514 of 3758 branches covered (66.9%)

4580 of 5755 relevant lines covered (79.58%)

70956.81 hits per line

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

86.79
/packages/main/src/components/ObjectPage/index.tsx
1
'use client';
2

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

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

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

52
const TAB_CONTAINER_HEADER_HEIGHT = 48;
361✔
53

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

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

62
type ObjectPageTabSelectEventDetail = TabContainerTabSelectEventDetail & BeforeNavigateDetail;
63

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

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

181
export interface ObjectPageDomRef extends HTMLDivElement {
182
  /**
183
   * Toggles the `headerArea` of the `ObjectPage`.
184
   *
185
   * __Note:__ If no argument is passed, the header state is toggled, otherwise the respective `snapped` state is applied.
186
   */
187
  toggleHeaderArea: (snapped?: boolean) => void;
188
}
189

190
/**
191
 * A component that allows apps to easily display information related to a business object.
192
 *
193
 * The `ObjectPage` is composed of a header (title and content) and block content wrapped in sections and subsections that structure the information.
194
 */
195
const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref) => {
361✔
196
  const {
197
    titleArea,
198
    image,
199
    footerArea,
200
    mode = ObjectPageMode.Default,
2,913✔
201
    imageShapeCircle,
202
    className,
203
    style,
204
    slot,
205
    children,
206
    selectedSectionId,
207
    headerPinned: headerPinnedProp,
208
    headerArea,
209
    hidePinButton,
210
    preserveHeaderStateOnClick,
211
    accessibilityAttributes,
212
    placeholder,
213
    onSelectedSectionChange,
214
    onToggleHeaderArea,
215
    onPinButtonToggle,
216
    onBeforeNavigate,
217
    ...rest
218
  } = props;
5,641✔
219

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

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

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

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

251
  useEffect(() => {
5,641✔
252
    const currentSection =
253
      mode === ObjectPageMode.IconTabBar ? getSectionById(children, internalSelectedSectionId) : null;
1,290✔
254
    setCurrentTabModeSection(currentSection);
1,290✔
255
  }, [mode, children, internalSelectedSectionId]);
256

257
  const prevInternalSelectedSectionId = useRef(internalSelectedSectionId);
5,641✔
258
  const fireOnSelectedChangedEvent = (targetEvent, index, id, section) => {
5,641✔
259
    if (typeof onSelectedSectionChange === 'function' && prevInternalSelectedSectionId.current !== id) {
324!
260
      onSelectedSectionChange(
×
261
        enrichEventWithDetails(targetEvent, {
262
          selectedSectionIndex: parseInt(index, 10),
263
          selectedSectionId: id,
264
          section
265
        })
266
      );
267
      prevInternalSelectedSectionId.current = id;
×
268
    }
269
  };
270
  const debouncedOnSectionChange = useRef(debounce(fireOnSelectedChangedEvent, 500)).current;
5,641✔
271
  useEffect(() => {
5,641✔
272
    return () => {
386✔
273
      debouncedOnSectionChange.cancel();
363✔
274
      clearTimeout(selectionScrollTimeout.current);
363✔
275
    };
276
  }, []);
277

278
  // observe heights of header parts
279
  const { topHeaderHeight, headerContentHeight, anchorBarHeight, totalHeaderHeight, headerCollapsed } =
280
    useObserveHeights(
5,641✔
281
      objectPageRef,
282
      topHeaderRef,
283
      headerContentRef,
284
      anchorBarRef,
285
      [headerCollapsedInternal, setHeaderCollapsedInternal],
286
      {
287
        noHeader: !titleArea && !headerArea,
5,677✔
288
        fixedHeader: headerPinned,
289
        scrollTimeout
290
      }
291
    );
292

293
  useEffect(() => {
5,641✔
294
    if (typeof onToggleHeaderArea === 'function' && isToggledRef.current) {
805✔
295
      onToggleHeaderArea(headerCollapsed !== true);
76✔
296
    }
297
  }, [headerCollapsed]);
298

299
  useEffect(() => {
5,641✔
300
    const objectPageNode = objectPageRef.current;
805✔
301
    if (objectPageNode) {
805✔
302
      Object.assign(objectPageNode, {
805✔
303
        toggleHeaderArea(snapped?: boolean) {
304
          if (typeof snapped === 'boolean') {
×
305
            onToggleHeaderContentVisibility({ detail: { visible: !snapped } });
×
306
          } else {
307
            onToggleHeaderContentVisibility({ detail: { visible: !!headerCollapsed } });
×
308
          }
309
        }
310
      });
311
    }
312
  }, [headerCollapsed]);
313

314
  const avatar = useMemo(() => {
5,641✔
315
    if (!image) {
386✔
316
      return null;
364✔
317
    }
318

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

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

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

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

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

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

423
  // do internal scrolling
424
  useEffect(() => {
5,641✔
425
    if (mode === ObjectPageMode.Default && isProgrammaticallyScrolled.current === true && !selectedSubSectionId) {
3,973✔
426
      scrollToSection(internalSelectedSectionId);
108✔
427
    }
428
  }, [internalSelectedSectionId, mode, isProgrammaticallyScrolled, scrollToSection, selectedSubSectionId]);
429

430
  // Scrolling for Sub Section Selection
431
  useEffect(() => {
5,641✔
432
    if (selectedSubSectionId && isProgrammaticallyScrolled.current === true && sectionSpacer) {
1,092✔
433
      scrollToSectionById(selectedSubSectionId, true);
95✔
434
      isProgrammaticallyScrolled.current = false;
95✔
435
    }
436
  }, [selectedSubSectionId, isProgrammaticallyScrolled.current, sectionSpacer]);
437

438
  useEffect(() => {
5,641✔
439
    if (headerPinnedProp !== undefined) {
505✔
440
      setHeaderPinned(headerPinnedProp);
136✔
441
    }
442
    if (headerPinnedProp) {
505✔
443
      onToggleHeaderContentVisibility({ detail: { visible: true } });
68✔
444
    }
445
  }, [headerPinnedProp]);
446

447
  const prevHeaderPinned = useRef(headerPinned);
5,641✔
448
  useEffect(() => {
5,641✔
449
    if (prevHeaderPinned.current && !headerPinned && objectPageRef.current.scrollTop > topHeaderHeight) {
1,175✔
450
      onToggleHeaderContentVisibility({ detail: { visible: false } });
68✔
451
      prevHeaderPinned.current = false;
68✔
452
    }
453
    if (!prevHeaderPinned.current && headerPinned) {
1,175✔
454
      prevHeaderPinned.current = true;
86✔
455
    }
456
  }, [headerPinned, topHeaderHeight]);
457

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

484
  const tabContainerContainerRef = useRef(null);
5,641✔
485
  useEffect(() => {
5,641✔
486
    const objectPage = objectPageRef.current;
2,589✔
487
    const sectionNodes = objectPage.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSection"]');
2,589✔
488
    const lastSectionNode = sectionNodes[sectionNodes.length - 1];
2,589✔
489
    const tabContainerContainer = tabContainerContainerRef.current;
2,589✔
490

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

507
    if (objectPage && lastSectionNode) {
2,589✔
508
      observer.observe(lastSectionNode, { box: 'border-box' });
2,391✔
509
    }
510

511
    return () => {
2,589✔
512
      observer.disconnect();
2,566✔
513
    };
514
  }, [headerCollapsed, topHeaderHeight, headerContentHeight, currentTabModeSection, children]);
515

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

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

553
  const objectPageClasses = clsx(
5,641✔
554
    classNames.objectPage,
555
    className,
556
    mode === ObjectPageMode.IconTabBar && classNames.iconTabBarMode
7,331✔
557
  );
558

559
  const { onScroll: _0, selectedSubSectionId: _1, ...propsWithoutOmitted } = rest;
5,641✔
560

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

592
    sectionNodes.forEach((el) => {
2,166✔
593
      observer.observe(el);
2,547✔
594
    });
595

596
    return () => {
2,166✔
597
      observer.disconnect();
2,143✔
598
    };
599
  }, [children, totalHeaderHeight, setInternalSelectedSectionId, isProgrammaticallyScrolled]);
600

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

632
  const onTitleClick = (e) => {
5,641✔
633
    e.stopPropagation();
76✔
634
    if (!preserveHeaderStateOnClick) {
76✔
635
      onToggleHeaderContentVisibility(enrichEventWithDetails(e, { visible: headerCollapsed }));
38✔
636
    }
637
  };
638

639
  const snappedHeaderInObjPage = titleArea && titleArea.props.snappedContent && headerCollapsed === true && !!image;
5,641✔
640

641
  const isInitial = useRef(true);
5,641✔
642
  useEffect(() => {
5,641✔
643
    if (!isInitial.current) {
388✔
644
      scrollTimeout.current = performance.now() + 200;
2✔
645
    } else {
646
      isInitial.current = false;
386✔
647
    }
648
  }, [snappedHeaderInObjPage]);
649

650
  const renderHeaderContentSection = () => {
5,641✔
651
    if (headerArea?.props) {
5,641✔
652
      return cloneElement(headerArea as ReactElement<ObjectPageHeaderPropTypesWithInternals>, {
5,564✔
653
        ...headerArea.props,
654
        topHeaderHeight,
655
        style:
656
          headerCollapsed === true
5,564✔
657
            ? { position: 'absolute', visibility: 'hidden', flexShrink: 0 }
658
            : { ...headerArea.props.style, flexShrink: 0 },
659
        headerPinned: headerPinned || scrolledHeaderExpanded,
10,769✔
660
        //@ts-expect-error: todo remove me when forwardref has been replaced
661
        ref: componentRefHeaderContent,
662
        children: (
663
          <div className={classNames.headerContainer} data-component-name="ObjectPageHeaderContainer">
664
            {avatar}
665
            {headerArea.props.children && (
11,128✔
666
              <div data-component-name="ObjectPageHeaderContent">{headerArea.props.children}</div>
667
            )}
668
          </div>
669
        )
670
      });
671
    }
672
  };
673

674
  const onTabItemSelect = (event) => {
5,641✔
675
    if (typeof onBeforeNavigate === 'function') {
330✔
676
      const selectedTabDataset = event.detail.tab.dataset;
8✔
677
      const sectionIndex = parseInt(selectedTabDataset.index, 10);
8✔
678
      const sectionId = selectedTabDataset.parentId ?? selectedTabDataset.sectionId;
8✔
679
      const subSectionId = selectedTabDataset.hasOwnProperty('isSubTab') ? selectedTabDataset.sectionId : undefined;
8✔
680
      onBeforeNavigate(
8✔
681
        enrichEventWithDetails(event, {
682
          sectionIndex,
683
          sectionId,
684
          subSectionId
685
        })
686
      );
687
      if (event.defaultPrevented) {
8✔
688
        return;
8✔
689
      }
690
    }
691
    event.preventDefault();
322✔
692
    const { sectionId, index, isSubTab, parentId } = event.detail.tab.dataset;
322✔
693
    if (isSubTab !== undefined) {
322✔
694
      handleOnSubSectionSelected(enrichEventWithDetails(event, { sectionId: parentId, subSectionId: sectionId }));
80✔
695
    } else {
696
      const section = safeGetChildrenArray<ReactElement<ObjectPageSectionPropTypes>>(children).find((el) => {
242✔
697
        return el.props.id == sectionId;
572✔
698
      });
699
      handleOnSectionSelected(event, section?.props?.id, index, section);
242✔
700
    }
701
  };
702

703
  const prevScrollTop = useRef(undefined);
5,641✔
704
  const onObjectPageScroll = useCallback(
5,641✔
705
    (e) => {
706
      if (!isToggledRef.current) {
7,736✔
707
        isToggledRef.current = true;
192✔
708
      }
709
      if (scrollTimeout.current >= performance.now()) {
7,736✔
710
        return;
4,515✔
711
      }
712
      scrollEvent.current = e;
3,221✔
713
      if (typeof props.onScroll === 'function') {
3,221!
714
        props.onScroll(e);
×
715
      }
716
      if (selectedSubSectionId) {
3,221✔
717
        setSelectedSubSectionId(undefined);
14✔
718
      }
719
      if (selectionScrollTimeout.current) {
3,221✔
720
        clearTimeout(selectionScrollTimeout.current);
3,052✔
721
      }
722
      selectionScrollTimeout.current = setTimeout(() => {
3,221✔
723
        setIsAfterScroll(true);
118✔
724
      }, 100);
725
      if (!headerPinned || e.target.scrollTop === 0) {
3,221✔
726
        objectPageRef.current?.classList.remove(classNames.headerCollapsed);
3,203✔
727
      }
728
      if (scrolledHeaderExpanded && e.target.scrollTop !== prevScrollTop.current) {
3,221✔
729
        if (e.target.scrollHeight - e.target.scrollTop === e.target.clientHeight) {
17!
730
          return;
×
731
        }
732
        prevScrollTop.current = e.target.scrollTop;
17✔
733
        if (!headerPinned) {
17✔
734
          setHeaderCollapsedInternal(true);
17✔
735
        }
736
        setScrolledHeaderExpanded(false);
17✔
737
      }
738
    },
739
    [topHeaderHeight, headerPinned, props.onScroll, scrolledHeaderExpanded, selectedSubSectionId]
740
  );
741

742
  const onHoverToggleButton = useCallback(
5,641✔
743
    (e) => {
744
      if (e?.type === 'mouseover') {
289✔
745
        topHeaderRef.current?.classList.add(classNames.headerHoverStyles);
203✔
746
      } else {
747
        topHeaderRef.current?.classList.remove(classNames.headerHoverStyles);
86✔
748
      }
749
    },
750
    [classNames.headerHoverStyles]
751
  );
752

753
  const objectPageStyles: CSSProperties = {
5,641✔
754
    ...style
755
  };
756
  if (headerCollapsed === true && headerArea) {
5,641✔
757
    objectPageStyles[ObjectPageCssVariables.titleFontSize] = ThemingParameters.sapObjectHeader_Title_SnappedFontSize;
1,179✔
758
  }
759

760
  return (
5,641✔
761
    <div
762
      data-component-name="ObjectPage"
763
      slot={slot}
764
      className={objectPageClasses}
765
      style={objectPageStyles}
766
      ref={componentRef}
767
      onScroll={onObjectPageScroll}
768
      {...propsWithoutOmitted}
769
    >
770
      <header
771
        onMouseOver={onHoverToggleButton}
772
        onMouseLeave={onHoverToggleButton}
773
        data-component-name="ObjectPageTopHeader"
774
        ref={topHeaderRef}
775
        role={accessibilityAttributes?.objectPageTopHeader?.role}
776
        data-not-clickable={!!preserveHeaderStateOnClick}
777
        aria-roledescription={accessibilityAttributes?.objectPageTopHeader?.ariaRoledescription ?? 'Object Page header'}
11,282✔
778
        className={classNames.header}
779
        style={{
780
          gridAutoColumns: `min-content ${
781
            titleArea && image && headerCollapsed === true ? `calc(100% - 3rem - 1rem)` : '100%'
16,960✔
782
          }`
783
        }}
784
      >
785
        <span
786
          className={classNames.clickArea}
787
          onClick={onTitleClick}
788
          data-component-name="ObjectPageTitleAreaClickElement"
789
        />
790
        {titleArea && image && headerCollapsed === true && (
11,328✔
791
          <CollapsedAvatar image={image} imageShapeCircle={imageShapeCircle} />
792
        )}
793
        {titleArea &&
11,246✔
794
          cloneElement(titleArea as ReactElement<ObjectPageTitlePropsWithDataAttributes>, {
795
            className: clsx(titleArea?.props?.className),
796
            onToggleHeaderContentVisibility: onTitleClick,
797
            'data-not-clickable': !!preserveHeaderStateOnClick,
798
            'data-header-content-visible': headerArea && headerCollapsed !== true,
11,169✔
799
            'data-is-snapped-rendered-outside': snappedHeaderInObjPage
800
          })}
801
        {snappedHeaderInObjPage && (
5,650✔
802
          <div className={classNames.snappedContent} data-component-name="ATwithImageSnappedContentContainer">
803
            {titleArea.props.snappedContent}
804
          </div>
805
        )}
806
      </header>
807
      {renderHeaderContentSection()}
808
      {headerArea && titleArea && (
16,769✔
809
        <div
810
          data-component-name="ObjectPageAnchorBar"
811
          ref={anchorBarRef}
812
          className={classNames.anchorBar}
813
          style={{
814
            top:
815
              scrolledHeaderExpanded || headerPinned
15,620✔
816
                ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight)}px`
1,142✔
817
                : `${topHeaderHeight + 5}px`
818
          }}
819
        >
820
          <ObjectPageAnchorBar
821
            headerContentVisible={headerArea && headerCollapsed !== true}
11,128✔
822
            hidePinButton={!!hidePinButton}
823
            headerPinned={headerPinned}
824
            accessibilityAttributes={accessibilityAttributes}
825
            onToggleHeaderContentVisibility={onToggleHeaderContentVisibility}
826
            setHeaderPinned={setHeaderPinned}
827
            onHoverToggleButton={onHoverToggleButton}
828
            onPinButtonToggle={onPinButtonToggle}
829
          />
830
        </div>
831
      )}
832
      {!placeholder && (
11,252✔
833
        <div
834
          ref={tabContainerContainerRef}
835
          className={classNames.tabContainer}
836
          data-component-name="ObjectPageTabContainer"
837
          style={{
838
            top:
839
              headerPinned || scrolledHeaderExpanded
16,474✔
840
                ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight)}px`
1,142✔
841
                : `${topHeaderHeight}px`
842
          }}
843
        >
844
          <TabContainer
845
            collapsed
846
            onTabSelect={onTabItemSelect}
847
            data-component-name="ObjectPageTabContainer"
848
            className={classNames.tabContainerComponent}
849
          >
850
            {safeGetChildrenArray<ReactElement<ObjectPageSectionPropTypes>>(children).map((section, index) => {
851
              if (!isValidElement(section) || !section.props) return null;
10,212!
852
              const subTabs = safeGetChildrenArray<ReactElement<ObjectPageSubSectionPropTypes>>(
10,212✔
853
                section.props.children
854
              ).filter(
855
                (subSection) =>
856
                  // @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.
857
                  isValidElement(subSection) && subSection?.type?.displayName === 'ObjectPageSubSection'
15,547✔
858
              );
859
              return (
10,212✔
860
                <Tab
861
                  key={`Anchor-${section.props?.id}`}
862
                  data-index={index}
863
                  data-section-id={section.props.id}
864
                  text={section.props.titleText}
865
                  selected={internalSelectedSectionId === section.props?.id || undefined}
14,942✔
866
                  items={subTabs.map((item) => {
867
                    if (!isValidElement(item)) {
6,111!
868
                      return null;
×
869
                    }
870
                    return (
6,111✔
871
                      <Tab
872
                        data-parent-id={section.props.id}
873
                        key={item.props.id}
874
                        data-is-sub-tab
875
                        data-section-id={item.props.id}
876
                        text={item.props.titleText}
877
                        selected={item.props.id === selectedSubSectionId || undefined}
11,902✔
878
                        data-index={index}
879
                      >
880
                        {/*ToDo: workaround for nested tab selection*/}
881
                        <span style={{ display: 'none' }} />
882
                      </Tab>
883
                    );
884
                  })}
885
                >
886
                  {/*ToDo: workaround for nested tab selection*/}
887
                  <span style={{ display: 'none' }} />
888
                </Tab>
889
              );
890
            })}
891
          </TabContainer>
892
        </div>
893
      )}
894
      <div data-component-name="ObjectPageContent" className={classNames.content} ref={objectPageContentRef}>
895
        <div style={{ height: headerCollapsed ? `${headerContentHeight}px` : 0 }} aria-hidden />
5,641✔
896
        {placeholder ? placeholder : sections}
5,641✔
897
        <div style={{ height: `${sectionSpacer}px` }} aria-hidden />
898
      </div>
899
      {footerArea && mode === ObjectPageMode.IconTabBar && !sectionSpacer && (
7,789✔
900
        <div className={classNames.footerSpacer} data-component-name="ObjectPageFooterSpacer" aria-hidden />
901
      )}
902
      {footerArea && (
6,725✔
903
        <footer className={classNames.footer} data-component-name="ObjectPageFooter">
904
          {footerArea}
905
        </footer>
906
      )}
907
    </div>
908
  );
909
});
910

911
ObjectPage.displayName = 'ObjectPage';
361✔
912

913
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