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

SAP / ui5-webcomponents-react / 11457311437

22 Oct 2024 09:28AM CUT coverage: 87.098% (-0.07%) from 87.165%
11457311437

Pull #6535

github

web-flow
Merge d0d502ad6 into 9ab8b46e1
Pull Request #6535: fix(ObjectPage): support section selection in iframe

2885 of 3847 branches covered (74.99%)

4 of 4 new or added lines in 1 file covered. (100.0%)

4 existing lines in 3 files now uncovered.

5043 of 5790 relevant lines covered (87.1%)

94258.75 hits per line

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

86.25
/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 { safeGetChildrenArray } from '../../internal/safeGetChildrenArray.js';
17
import { useObserveHeights } from '../../internal/useObserveHeights.js';
18
import type { CommonProps, Ui5CustomEvent } from '../../types/index.js';
19
import type { AvatarPropTypes, TabContainerDomRef } from '../../webComponents/index.js';
20
import { Tab, TabContainer } from '../../webComponents/index.js';
21
import { ObjectPageAnchorBar } from '../ObjectPageAnchorBar/index.js';
22
import type {
23
  InternalProps as ObjectPageHeaderPropTypesWithInternals,
24
  ObjectPageHeaderPropTypes
25
} from '../ObjectPageHeader/index.js';
26
import type { ObjectPageSectionPropTypes } from '../ObjectPageSection/index.js';
27
import type { ObjectPageSubSectionPropTypes } from '../ObjectPageSubSection/index.js';
28
import type {
29
  InternalProps as ObjectPageTitlePropTypesWithInternals,
30
  ObjectPageTitlePropTypes
31
} from '../ObjectPageTitle/index.js';
32
import { CollapsedAvatar } from './CollapsedAvatar.js';
33
import { classNames, styleData } from './ObjectPage.module.css.js';
34
import { getSectionById } from './ObjectPageUtils.js';
35

36
const ObjectPageCssVariables = {
417✔
37
  headerDisplay: '--_ui5wcr_ObjectPage_header_display',
38
  titleFontSize: '--_ui5wcr_ObjectPage_title_fontsize'
39
};
40

41
const TAB_CONTAINER_HEADER_HEIGHT = 48;
417✔
42

43
type ObjectPageSectionType = ReactElement<ObjectPageSectionPropTypes> | boolean;
44

45
interface BeforeNavigateDetail {
46
  sectionIndex: number;
47
  sectionId: string;
48
  subSectionId: string | undefined;
49
}
50

51
type ObjectPageTabSelectEventDetail = TabContainerTabSelectEventDetail & BeforeNavigateDetail;
52

53
type ObjectPageTitlePropsWithDataAttributes = ObjectPageTitlePropTypesWithInternals & {
54
  'data-not-clickable': boolean;
55
  'data-header-content-visible': boolean;
56
  'data-is-snapped-rendered-outside': boolean;
57
};
58

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

170
export interface ObjectPageDomRef extends HTMLDivElement {
171
  /**
172
   * Toggles the `headerArea` of the `ObjectPage`.
173
   *
174
   * __Note:__ If no argument is passed, the header state is toggled, otherwise the respective `snapped` state is applied.
175
   */
176
  toggleHeaderArea: (snapped?: boolean) => void;
177
}
178

179
/**
180
 * A component that allows apps to easily display information related to a business object.
181
 *
182
 * The `ObjectPage` is composed of a header (title and content) and block content wrapped in sections and subsections that structure the information.
183
 */
184
const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref) => {
417✔
185
  const {
186
    titleArea,
187
    image,
188
    footerArea,
189
    mode = ObjectPageMode.Default,
2,608✔
190
    imageShapeCircle,
191
    className,
192
    style,
193
    slot,
194
    children,
195
    selectedSectionId,
196
    headerPinned: headerPinnedProp,
197
    headerArea,
198
    hidePinButton,
199
    preserveHeaderStateOnClick,
200
    accessibilityAttributes,
201
    placeholder,
202
    onSelectedSectionChange,
203
    onToggleHeaderArea,
204
    onPinButtonToggle,
205
    onBeforeNavigate,
206
    ...rest
207
  } = props;
5,256✔
208

209
  useStylesheet(styleData, ObjectPage.displayName);
5,256✔
210

211
  const firstSectionId: string | undefined =
212
    safeGetChildrenArray<ReactElement<ObjectPageSectionPropTypes>>(children)[0]?.props?.id;
5,256✔
213

214
  const [internalSelectedSectionId, setInternalSelectedSectionId] = useState<string | undefined>(
5,256✔
215
    selectedSectionId ?? firstSectionId
10,512✔
216
  );
217
  const [selectedSubSectionId, setSelectedSubSectionId] = useState(props.selectedSubSectionId);
5,256✔
218
  const [headerPinned, setHeaderPinned] = useState(headerPinnedProp);
5,256✔
219
  const isProgrammaticallyScrolled = useRef(false);
5,256✔
220
  const prevSelectedSectionId = useRef<string | undefined>(undefined);
5,256✔
221

222
  const [componentRef, objectPageRef] = useSyncRef(ref);
5,256✔
223
  const topHeaderRef = useRef<HTMLDivElement>(null);
5,256✔
224
  const scrollEvent = useRef(undefined);
5,256✔
225
  const prevTopHeaderHeight = useRef(0);
5,256✔
226
  // @ts-expect-error: useSyncRef will create a ref if not present
227
  const [componentRefHeaderContent, headerContentRef] = useSyncRef(headerArea?.ref);
5,256✔
228
  const anchorBarRef = useRef<HTMLDivElement>(null);
5,256✔
229
  const objectPageContentRef = useRef<HTMLDivElement>(null);
5,256✔
230
  const selectionScrollTimeout = useRef(null);
5,256✔
231
  const isToggledRef = useRef(false);
5,256✔
232
  const [headerCollapsedInternal, setHeaderCollapsedInternal] = useState<undefined | boolean>(undefined);
5,256✔
233
  const [scrolledHeaderExpanded, setScrolledHeaderExpanded] = useState(false);
5,256✔
234
  const scrollTimeout = useRef(0);
5,256✔
235
  const [sectionSpacer, setSectionSpacer] = useState(0);
5,256✔
236
  const [currentTabModeSection, setCurrentTabModeSection] = useState(null);
5,256✔
237
  const sections = mode === ObjectPageMode.IconTabBar ? currentTabModeSection : children;
5,256✔
238

239
  useEffect(() => {
5,256✔
240
    const currentSection =
241
      mode === ObjectPageMode.IconTabBar ? getSectionById(children, internalSelectedSectionId) : null;
1,244✔
242
    setCurrentTabModeSection(currentSection);
1,244✔
243
  }, [mode, children, internalSelectedSectionId]);
244

245
  const prevInternalSelectedSectionId = useRef(internalSelectedSectionId);
5,256✔
246
  const fireOnSelectedChangedEvent = (targetEvent, index, id, section) => {
5,256✔
247
    if (typeof onSelectedSectionChange === 'function' && targetEvent && prevInternalSelectedSectionId.current !== id) {
348!
248
      onSelectedSectionChange(
×
249
        enrichEventWithDetails(targetEvent, {
250
          selectedSectionIndex: parseInt(index, 10),
251
          selectedSectionId: id,
252
          section
253
        })
254
      );
255
      prevInternalSelectedSectionId.current = id;
×
256
    }
257
  };
258
  const debouncedOnSectionChange = useRef(debounce(fireOnSelectedChangedEvent, 500)).current;
5,256✔
259
  useEffect(() => {
5,256✔
260
    return () => {
386✔
261
      debouncedOnSectionChange.cancel();
363✔
262
      clearTimeout(selectionScrollTimeout.current);
363✔
263
    };
264
  }, []);
265

266
  // observe heights of header parts
267
  const { topHeaderHeight, headerContentHeight, anchorBarHeight, totalHeaderHeight, headerCollapsed } =
268
    useObserveHeights(
5,256✔
269
      objectPageRef,
270
      topHeaderRef,
271
      headerContentRef,
272
      anchorBarRef,
273
      [headerCollapsedInternal, setHeaderCollapsedInternal],
274
      {
275
        noHeader: !titleArea && !headerArea,
5,292✔
276
        fixedHeader: headerPinned,
277
        scrollTimeout
278
      }
279
    );
280

281
  useEffect(() => {
5,256✔
282
    if (typeof onToggleHeaderArea === 'function' && isToggledRef.current) {
791✔
283
      onToggleHeaderArea(headerCollapsed !== true);
76✔
284
    }
285
  }, [headerCollapsed]);
286

287
  useEffect(() => {
5,256✔
288
    const objectPageNode = objectPageRef.current;
791✔
289
    if (objectPageNode) {
791✔
290
      Object.assign(objectPageNode, {
791✔
291
        toggleHeaderArea(snapped?: boolean) {
292
          if (typeof snapped === 'boolean') {
×
293
            onToggleHeaderContentVisibility({ detail: { visible: !snapped } });
×
294
          } else {
295
            onToggleHeaderContentVisibility({ detail: { visible: !!headerCollapsed } });
×
296
          }
297
        }
298
      });
299
    }
300
  }, [headerCollapsed]);
301

302
  const avatar = useMemo(() => {
5,256✔
303
    if (!image) {
386✔
304
      return null;
364✔
305
    }
306

307
    if (typeof image === 'string') {
22✔
308
      return (
12✔
309
        <span
310
          className={classNames.headerImage}
311
          style={{ borderRadius: imageShapeCircle ? '50%' : 0, overflow: 'hidden' }}
12✔
312
        >
313
          <img src={image} className={classNames.image} alt="Company Logo" />
314
        </span>
315
      );
316
    } else {
317
      return cloneElement(image, {
10✔
318
        size: AvatarSize.L,
319
        className: clsx(classNames.headerImage, image.props?.className)
320
      } as AvatarPropTypes);
321
    }
322
  }, [image, classNames.headerImage, classNames.image, imageShapeCircle]);
323

324
  const scrollToSectionById = (id?: string, isSubSection = false) => {
5,256✔
325
    const section = objectPageRef.current?.querySelector<HTMLElement>(
177✔
326
      `#${isSubSection ? 'ObjectPageSubSection' : 'ObjectPageSection'}-${CSS.escape(id)}`
177✔
327
    );
328
    scrollTimeout.current = performance.now() + 500;
177✔
329
    if (section) {
177✔
330
      const safeTopHeaderHeight = topHeaderHeight || prevTopHeaderHeight.current;
177!
331
      section.style.scrollMarginBlockStart =
177✔
332
        safeTopHeaderHeight +
333
        anchorBarHeight +
334
        TAB_CONTAINER_HEADER_HEIGHT +
335
        (headerPinned && !headerCollapsed ? headerContentHeight : 0) +
354!
336
        'px';
337
      if (isSubSection) {
177✔
338
        section.focus();
95✔
339
      }
340
      section.scrollIntoView();
177✔
341
      section.style.scrollMarginBlockStart = '0px';
177✔
342
    }
343
  };
344

345
  const scrollToSection = (sectionId?: string) => {
5,256✔
346
    if (!sectionId) {
138!
347
      return;
×
348
    }
349
    if (firstSectionId === sectionId) {
138✔
350
      objectPageRef.current?.scrollTo({ top: 0 });
56✔
351
    } else {
352
      scrollToSectionById(sectionId);
82✔
353
    }
354
    isProgrammaticallyScrolled.current = false;
138✔
355
  };
356

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

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

399
  // section was selected by clicking on the tab bar buttons
400
  const handleOnSectionSelected = (targetEvent, newSelectionSectionId, index, section) => {
5,256✔
401
    isProgrammaticallyScrolled.current = true;
242✔
402
    debouncedOnSectionChange.cancel();
242✔
403
    setInternalSelectedSectionId((prevSelectedSection) => {
242✔
404
      if (prevSelectedSection === newSelectionSectionId) {
242✔
405
        scrollToSection(newSelectionSectionId);
15✔
406
      }
407
      return newSelectionSectionId;
242✔
408
    });
409
    scrollEvent.current = targetEvent;
242✔
410
    fireOnSelectedChangedEvent(targetEvent, index, newSelectionSectionId, section);
242✔
411
  };
412

413
  // do internal scrolling
414
  useEffect(() => {
5,256✔
415
    if (mode === ObjectPageMode.Default && isProgrammaticallyScrolled.current === true && !selectedSubSectionId) {
3,692✔
416
      scrollToSection(internalSelectedSectionId);
123✔
417
    }
418
  }, [internalSelectedSectionId, mode, isProgrammaticallyScrolled, scrollToSection, selectedSubSectionId]);
419

420
  // Scrolling for Sub Section Selection
421
  useEffect(() => {
5,256✔
422
    if (selectedSubSectionId && isProgrammaticallyScrolled.current === true && sectionSpacer) {
1,140✔
423
      scrollToSectionById(selectedSubSectionId, true);
95✔
424
      isProgrammaticallyScrolled.current = false;
95✔
425
    }
426
  }, [selectedSubSectionId, isProgrammaticallyScrolled.current, sectionSpacer]);
427

428
  useEffect(() => {
5,256✔
429
    if (headerPinnedProp !== undefined) {
505✔
430
      setHeaderPinned(headerPinnedProp);
136✔
431
    }
432
    if (headerPinnedProp) {
505✔
433
      onToggleHeaderContentVisibility({ detail: { visible: true } });
68✔
434
    }
435
  }, [headerPinnedProp]);
436

437
  const prevHeaderPinned = useRef(headerPinned);
5,256✔
438
  useEffect(() => {
5,256✔
439
    if (prevHeaderPinned.current && !headerPinned && objectPageRef.current.scrollTop > topHeaderHeight) {
1,161✔
440
      onToggleHeaderContentVisibility({ detail: { visible: false } });
68✔
441
      prevHeaderPinned.current = false;
68✔
442
    }
443
    if (!prevHeaderPinned.current && headerPinned) {
1,161✔
444
      prevHeaderPinned.current = true;
86✔
445
    }
446
  }, [headerPinned, topHeaderHeight]);
447

448
  useEffect(() => {
5,256✔
449
    setSelectedSubSectionId(props.selectedSubSectionId);
874✔
450
    if (props.selectedSubSectionId) {
874!
451
      isProgrammaticallyScrolled.current = true;
×
452
      if (mode === ObjectPageMode.IconTabBar) {
×
453
        let sectionId;
454
        safeGetChildrenArray<ReactElement<ObjectPageSectionPropTypes>>(children).forEach((section) => {
×
455
          if (isValidElement(section) && section.props && section.props.children) {
×
456
            safeGetChildrenArray(section.props.children).forEach((subSection) => {
×
457
              if (
×
458
                isValidElement(subSection) &&
×
459
                subSection.props &&
460
                (subSection as ReactElement<ObjectPageSubSectionPropTypes>).props.id === props.selectedSubSectionId
461
              ) {
462
                sectionId = section.props?.id;
×
463
              }
464
            });
465
          }
466
        });
467
        if (sectionId) {
×
468
          setInternalSelectedSectionId(sectionId);
×
469
        }
470
      }
471
    }
472
  }, [props.selectedSubSectionId, children, mode]);
473

474
  const tabContainerContainerRef = useRef(null);
5,256✔
475
  useEffect(() => {
5,256✔
476
    const objectPage = objectPageRef.current;
2,531✔
477
    const sectionNodes = objectPage.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSection"]');
2,531✔
478
    const lastSectionNode = sectionNodes[sectionNodes.length - 1];
2,531✔
479
    const tabContainerContainer = tabContainerContainerRef.current;
2,531✔
480

481
    const observer = new ResizeObserver(([sectionElement]) => {
2,531✔
482
      const subSections = lastSectionNode.querySelectorAll<HTMLDivElement>('[id^="ObjectPageSubSection"]');
2,041✔
483
      const lastSubSection = subSections[subSections.length - 1];
2,041✔
484
      const lastSubSectionOrSection = lastSubSection ?? sectionElement.target;
2,041✔
485
      if ((currentTabModeSection && !lastSubSection) || (sectionNodes.length === 1 && !lastSubSection)) {
2,041✔
486
        setSectionSpacer(0);
1,584✔
487
      } else if (!!tabContainerContainer) {
457✔
488
        setSectionSpacer(
457✔
489
          objectPage.getBoundingClientRect().bottom -
490
            tabContainerContainer.getBoundingClientRect().bottom -
491
            lastSubSectionOrSection.getBoundingClientRect().height -
492
            TAB_CONTAINER_HEADER_HEIGHT
493
        );
494
      }
495
    });
496

497
    if (objectPage && lastSectionNode) {
2,531✔
498
      observer.observe(lastSectionNode, { box: 'border-box' });
2,347✔
499
    }
500

501
    return () => {
2,531✔
502
      observer.disconnect();
2,508✔
503
    };
504
  }, [headerCollapsed, topHeaderHeight, headerContentHeight, currentTabModeSection, children]);
505

506
  const onToggleHeaderContentVisibility = useCallback((e) => {
5,256✔
507
    isToggledRef.current = true;
345✔
508
    scrollTimeout.current = performance.now() + 500;
345✔
509
    if (!e.detail.visible) {
345✔
510
      setHeaderCollapsedInternal(true);
197✔
511
      objectPageRef.current?.classList.add(classNames.headerCollapsed);
197✔
512
    } else {
513
      setHeaderCollapsedInternal(false);
148✔
514
      setScrolledHeaderExpanded(true);
148✔
515
      objectPageRef.current?.classList.remove(classNames.headerCollapsed);
148✔
516
    }
517
  }, []);
518

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

543
  const objectPageClasses = clsx(
5,256✔
544
    classNames.objectPage,
545
    className,
546
    mode === ObjectPageMode.IconTabBar && classNames.iconTabBarMode
6,933✔
547
  );
548

549
  const { onScroll: _0, selectedSubSectionId: _1, ...propsWithoutOmitted } = rest;
5,256✔
550

551
  const visibleSectionIds = useRef<Set<string>>(new Set());
5,256✔
552
  useEffect(() => {
5,256✔
553
    const sectionNodes = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
2,254✔
554
    // only the sticky part of the header must be added as margin
555
    const rootMargin = `-${(headerPinned && !headerCollapsed ? totalHeaderHeight : topHeaderHeight) + TAB_CONTAINER_HEADER_HEIGHT}px 0px 0px 0px`;
2,254✔
556

557
    const observer = new IntersectionObserver(
2,254✔
558
      (entries) => {
559
        entries.forEach((entry) => {
1,304✔
560
          const sectionId = entry.target.id;
1,769✔
561
          if (entry.isIntersecting) {
1,769✔
562
            visibleSectionIds.current.add(sectionId);
1,457✔
563
          } else {
564
            visibleSectionIds.current.delete(sectionId);
312✔
565
          }
566

567
          let currentIndex: undefined | number;
568
          const sortedVisibleSections = Array.from(sectionNodes).filter((section, index) => {
1,769✔
569
            const isVisibleSection = visibleSectionIds.current.has(section.id);
3,499✔
570
            if (currentIndex === undefined && isVisibleSection) {
3,499✔
571
              currentIndex = index;
1,666✔
572
            }
573
            return visibleSectionIds.current.has(section.id);
3,499✔
574
          });
575

576
          if (sortedVisibleSections.length > 0) {
1,769✔
577
            const section = sortedVisibleSections[0];
1,666✔
578
            const id = sortedVisibleSections[0].id.slice(18);
1,666✔
579
            setInternalSelectedSectionId(id);
1,666✔
580
            debouncedOnSectionChange(scrollEvent.current, currentIndex, id, section);
1,666✔
581
          }
582
        });
583
      },
584
      {
585
        root: objectPageRef.current,
586
        rootMargin,
587
        threshold: [0]
588
      }
589
    );
590
    sectionNodes.forEach((el) => {
2,254✔
591
      observer.observe(el);
2,613✔
592
    });
593

594
    return () => {
2,254✔
595
      observer.disconnect();
2,231✔
596
    };
597
  }, [children, totalHeaderHeight, setInternalSelectedSectionId, headerPinned, debouncedOnSectionChange]);
598

599
  const onTitleClick = (e) => {
5,256✔
600
    e.stopPropagation();
76✔
601
    if (!preserveHeaderStateOnClick) {
76✔
602
      onToggleHeaderContentVisibility(enrichEventWithDetails(e, { visible: headerCollapsed }));
38✔
603
    }
604
  };
605

606
  const snappedHeaderInObjPage = titleArea && titleArea.props.snappedContent && headerCollapsed === true && !!image;
5,256✔
607

608
  const isInitial = useRef(true);
5,256✔
609
  useEffect(() => {
5,256✔
610
    if (!isInitial.current) {
388✔
611
      scrollTimeout.current = performance.now() + 200;
2✔
612
    } else {
613
      isInitial.current = false;
386✔
614
    }
615
  }, [snappedHeaderInObjPage]);
616

617
  const renderHeaderContentSection = () => {
5,256✔
618
    if (headerArea?.props) {
5,256✔
619
      return cloneElement(headerArea as ReactElement<ObjectPageHeaderPropTypesWithInternals>, {
5,182✔
620
        ...headerArea.props,
621
        topHeaderHeight,
622
        style:
623
          headerCollapsed === true
5,182✔
624
            ? { position: 'absolute', visibility: 'hidden', flexShrink: 0 }
625
            : { ...headerArea.props.style, flexShrink: 0 },
626
        headerPinned: headerPinned || scrolledHeaderExpanded,
10,039✔
627
        //@ts-expect-error: todo remove me when forwardref has been replaced
628
        ref: componentRefHeaderContent,
629
        children: (
630
          <div className={classNames.headerContainer} data-component-name="ObjectPageHeaderContainer">
631
            {avatar}
632
            {headerArea.props.children && (
10,364✔
633
              <div data-component-name="ObjectPageHeaderContent">{headerArea.props.children}</div>
634
            )}
635
          </div>
636
        )
637
      });
638
    }
639
  };
640

641
  const onTabItemSelect = (event) => {
5,256✔
642
    if (typeof onBeforeNavigate === 'function') {
330✔
643
      const selectedTabDataset = event.detail.tab.dataset;
8✔
644
      const sectionIndex = parseInt(selectedTabDataset.index, 10);
8✔
645
      const sectionId = selectedTabDataset.parentId ?? selectedTabDataset.sectionId;
8✔
646
      const subSectionId = Object.prototype.hasOwnProperty.call(selectedTabDataset, 'isSubTab')
8✔
647
        ? selectedTabDataset.sectionId
648
        : undefined;
649
      onBeforeNavigate(
8✔
650
        enrichEventWithDetails(event, {
651
          sectionIndex,
652
          sectionId,
653
          subSectionId
654
        })
655
      );
656
      if (event.defaultPrevented) {
8✔
657
        return;
8✔
658
      }
659
    }
660
    event.preventDefault();
322✔
661
    const { sectionId, index, isSubTab, parentId } = event.detail.tab.dataset;
322✔
662
    if (isSubTab !== undefined) {
322✔
663
      handleOnSubSectionSelected(enrichEventWithDetails(event, { sectionId: parentId, subSectionId: sectionId }));
80✔
664
    } else {
665
      const section = safeGetChildrenArray<ReactElement<ObjectPageSectionPropTypes>>(children).find((el) => {
242✔
666
        return el.props.id == sectionId;
572✔
667
      });
668
      handleOnSectionSelected(event, section?.props?.id, index, section);
242✔
669
    }
670
  };
671

672
  const prevScrollTop = useRef(undefined);
5,256✔
673
  const onObjectPageScroll = useCallback(
5,256✔
674
    (e) => {
675
      if (!isToggledRef.current) {
3,664✔
676
        isToggledRef.current = true;
181✔
677
      }
678
      if (scrollTimeout.current >= performance.now()) {
3,664✔
679
        return;
1,870✔
680
      }
681
      scrollEvent.current = e;
1,794✔
682
      if (typeof props.onScroll === 'function') {
1,794!
683
        props.onScroll(e);
×
684
      }
685
      if (selectedSubSectionId) {
1,794!
UNCOV
686
        setSelectedSubSectionId(undefined);
×
687
      }
688
      if (selectionScrollTimeout.current) {
1,794!
689
        clearTimeout(selectionScrollTimeout.current);
×
690
      }
691
      if (!headerPinned || e.target.scrollTop === 0) {
1,794✔
692
        objectPageRef.current?.classList.remove(classNames.headerCollapsed);
1,704✔
693
      }
694
      if (scrolledHeaderExpanded && e.target.scrollTop !== prevScrollTop.current) {
1,794✔
695
        if (e.target.scrollHeight - e.target.scrollTop === e.target.clientHeight) {
17!
696
          return;
×
697
        }
698
        prevScrollTop.current = e.target.scrollTop;
17✔
699
        if (!headerPinned) {
17✔
700
          setHeaderCollapsedInternal(true);
17✔
701
        }
702
        setScrolledHeaderExpanded(false);
17✔
703
      }
704
    },
705
    [topHeaderHeight, headerPinned, props.onScroll, scrolledHeaderExpanded, selectedSubSectionId]
706
  );
707

708
  const onHoverToggleButton = useCallback(
5,256✔
709
    (e) => {
710
      if (e?.type === 'mouseover') {
289✔
711
        topHeaderRef.current?.classList.add(classNames.headerHoverStyles);
203✔
712
      } else {
713
        topHeaderRef.current?.classList.remove(classNames.headerHoverStyles);
86✔
714
      }
715
    },
716
    [classNames.headerHoverStyles]
717
  );
718

719
  const objectPageStyles: CSSProperties = {
5,256✔
720
    ...style
721
  };
722
  if (headerCollapsed === true && headerArea) {
5,256✔
723
    objectPageStyles[ObjectPageCssVariables.titleFontSize] = ThemingParameters.sapObjectHeader_Title_SnappedFontSize;
983✔
724
  }
725

726
  return (
5,256✔
727
    <div
728
      data-component-name="ObjectPage"
729
      slot={slot}
730
      className={objectPageClasses}
731
      style={objectPageStyles}
732
      ref={componentRef}
733
      onScroll={onObjectPageScroll}
734
      data-in-iframe={window && window.self !== window.top}
10,512✔
735
      {...propsWithoutOmitted}
736
    >
737
      <header
738
        onMouseOver={onHoverToggleButton}
739
        onMouseLeave={onHoverToggleButton}
740
        data-component-name="ObjectPageTopHeader"
741
        ref={topHeaderRef}
742
        role={accessibilityAttributes?.objectPageTopHeader?.role}
743
        data-not-clickable={!!preserveHeaderStateOnClick}
744
        aria-roledescription={accessibilityAttributes?.objectPageTopHeader?.ariaRoledescription ?? 'Object Page header'}
10,512✔
745
        className={classNames.header}
746
        style={{
747
          gridAutoColumns: `min-content ${
748
            titleArea && image && headerCollapsed === true ? `calc(100% - 3rem - 1rem)` : '100%'
15,791✔
749
          }`
750
        }}
751
      >
752
        <span
753
          className={classNames.clickArea}
754
          onClick={onTitleClick}
755
          data-component-name="ObjectPageTitleAreaClickElement"
756
        />
757
        {titleArea && image && headerCollapsed === true && (
10,542✔
758
          <CollapsedAvatar image={image} imageShapeCircle={imageShapeCircle} />
759
        )}
760
        {titleArea &&
10,476✔
761
          cloneElement(titleArea as ReactElement<ObjectPageTitlePropsWithDataAttributes>, {
762
            className: clsx(titleArea?.props?.className),
763
            onToggleHeaderContentVisibility: onTitleClick,
764
            'data-not-clickable': !!preserveHeaderStateOnClick,
765
            'data-header-content-visible': headerArea && headerCollapsed !== true,
10,402✔
766
            'data-is-snapped-rendered-outside': snappedHeaderInObjPage
767
          })}
768
        {snappedHeaderInObjPage && (
5,263✔
769
          <div className={classNames.snappedContent} data-component-name="ATwithImageSnappedContentContainer">
770
            {titleArea.props.snappedContent}
771
          </div>
772
        )}
773
      </header>
774
      {renderHeaderContentSection()}
775
      {headerArea && titleArea && (
15,620✔
776
        <div
777
          data-component-name="ObjectPageAnchorBar"
778
          ref={anchorBarRef}
779
          className={classNames.anchorBar}
780
          style={{
781
            top:
782
              scrolledHeaderExpanded || headerPinned
14,592✔
783
                ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight)}px`
1,024✔
784
                : `${topHeaderHeight + 5}px`
785
          }}
786
        >
787
          <ObjectPageAnchorBar
788
            headerContentVisible={headerArea && headerCollapsed !== true}
10,364✔
789
            hidePinButton={!!hidePinButton}
790
            headerPinned={headerPinned}
791
            accessibilityAttributes={accessibilityAttributes}
792
            onToggleHeaderContentVisibility={onToggleHeaderContentVisibility}
793
            setHeaderPinned={setHeaderPinned}
794
            onHoverToggleButton={onHoverToggleButton}
795
            onPinButtonToggle={onPinButtonToggle}
796
          />
797
        </div>
798
      )}
799
      {!placeholder && (
10,487✔
800
        <div
801
          ref={tabContainerContainerRef}
802
          className={classNames.tabContainer}
803
          data-component-name="ObjectPageTabContainer"
804
          style={{
805
            top:
806
              headerPinned || scrolledHeaderExpanded
15,368✔
807
                ? `${topHeaderHeight + (headerCollapsed === true ? 0 : headerContentHeight)}px`
1,024✔
808
                : `${topHeaderHeight}px`
809
          }}
810
        >
811
          <TabContainer
812
            collapsed
813
            onTabSelect={onTabItemSelect}
814
            data-component-name="ObjectPageTabContainer"
815
            className={classNames.tabContainerComponent}
816
          >
817
            {safeGetChildrenArray<ReactElement<ObjectPageSectionPropTypes>>(children).map((section, index) => {
818
              if (!isValidElement(section) || !section.props) return null;
9,640!
819
              const subTabs = safeGetChildrenArray<ReactElement<ObjectPageSubSectionPropTypes>>(
9,640✔
820
                section.props.children
821
              ).filter(
822
                (subSection) =>
823
                  // @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.
824
                  isValidElement(subSection) && subSection?.type?.displayName === 'ObjectPageSubSection'
14,665✔
825
              );
826
              return (
9,640✔
827
                <Tab
828
                  key={`Anchor-${section.props?.id}`}
829
                  data-index={index}
830
                  data-section-id={section.props.id}
831
                  text={section.props.titleText}
832
                  selected={internalSelectedSectionId === section.props?.id || undefined}
14,163✔
833
                  items={subTabs.map((item) => {
834
                    if (!isValidElement(item)) {
5,798!
835
                      return null;
×
836
                    }
837
                    return (
5,798✔
838
                      <Tab
839
                        data-parent-id={section.props.id}
840
                        key={item.props.id}
841
                        data-is-sub-tab
842
                        data-section-id={item.props.id}
843
                        text={item.props.titleText}
844
                        selected={item.props.id === selectedSubSectionId || undefined}
11,298✔
845
                        data-index={index}
846
                      >
847
                        {/*ToDo: workaround for nested tab selection*/}
848
                        <span style={{ display: 'none' }} />
849
                      </Tab>
850
                    );
851
                  })}
852
                >
853
                  {/*ToDo: workaround for nested tab selection*/}
854
                  <span style={{ display: 'none' }} />
855
                </Tab>
856
              );
857
            })}
858
          </TabContainer>
859
        </div>
860
      )}
861
      <div data-component-name="ObjectPageContent" className={classNames.content} ref={objectPageContentRef}>
862
        <div style={{ height: headerCollapsed && !headerPinned ? `${headerContentHeight}px` : 0 }} aria-hidden />
11,495✔
863
        {placeholder ? placeholder : sections}
5,256✔
864
        <div style={{ height: `${sectionSpacer}px` }} aria-hidden />
865
      </div>
866
      {footerArea && mode === ObjectPageMode.IconTabBar && !sectionSpacer && (
7,366✔
867
        <div className={classNames.footerSpacer} data-component-name="ObjectPageFooterSpacer" aria-hidden />
868
      )}
869
      {footerArea && (
6,302✔
870
        <footer className={classNames.footer} data-component-name="ObjectPageFooter">
871
          {footerArea}
872
        </footer>
873
      )}
874
    </div>
875
  );
876
});
877

878
ObjectPage.displayName = 'ObjectPage';
417✔
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