From 229e32fde76ac38800b8cb0cead3be7d2e011d45 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Thu, 27 Mar 2025 14:52:48 +0100 Subject: [PATCH 01/10] fix(ObjectPage): improve focus and scroll behavior --- .../components/ObjectPage/ObjectPage.cy.tsx | 31 +- .../ObjectPage/ObjectPage.module.css | 4 - .../main/src/components/ObjectPage/index.tsx | 341 +++++------------- .../src/components/ObjectPage/types/index.ts | 148 ++++++++ .../ObjectPage/useHandleTabSelect.ts | 115 ++++++ 5 files changed, 388 insertions(+), 251 deletions(-) create mode 100644 packages/main/src/components/ObjectPage/types/index.ts create mode 100644 packages/main/src/components/ObjectPage/useHandleTabSelect.ts diff --git a/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx b/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx index 72d33d355b8..248dd8ac79f 100644 --- a/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx +++ b/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx @@ -343,7 +343,12 @@ describe('ObjectPage', () => { it('scroll to sections - default mode', () => { document.body.style.margin = '0px'; cy.mount( - + {OPContent} ); @@ -362,6 +367,16 @@ describe('ObjectPage', () => { cy.get('[ui5-tabcontainer]').findUi5TabByText('Goals').realClick(); cy.findByText('Test').should('be.visible'); + // no scroll when focusing something in the header area + cy.get('[ui5-tabcontainer]').findUi5TabByText('Employment').realClick(); + cy.findByText('Job Information').should('be.visible'); + cy.findByTestId('op').invoke('scrollTop').as('scrollTop'); + cy.wait(100); + cy.realPress('ArrowLeft'); + cy.get('@scrollTop').then((scrollTop) => { + cy.findByTestId('op').invoke('scrollTop').should('equal', scrollTop); + }); + cy.get('[ui5-tabcontainer]').findUi5TabByText('Employment').focus(); cy.realPress('ArrowDown'); cy.realPress('ArrowDown'); @@ -371,6 +386,7 @@ describe('ObjectPage', () => { cy.mount( { cy.wait(200); //fallback click - cy.get('[ui5-tabcontainer]').findUi5TabByText('Employment').click(); + cy.get('[ui5-tabcontainer]').findUi5TabByText('Employment').realClick(); cy.findByTestId('footer').should('be.visible'); cy.findByText('Employment').should('be.visible'); + cy.findByText('Job Information').should('be.visible'); cy.get('[ui5-tabcontainer]').findUi5TabByText('Goals').click(); cy.findByText('Test').should('be.visible'); @@ -419,6 +436,7 @@ describe('ObjectPage', () => { document.body.style.margin = '0px'; cy.mount( { cy.findByText('Job Information').should('not.exist'); cy.findByTestId('section 1').should('be.visible'); - cy.get('[ui5-tabcontainer]').findUi5TabByText('Employment').click(); + cy.get('[ui5-tabcontainer]').findUi5TabByText('Employment').realClick(); cy.findByText('Job Information').should('be.visible'); cy.findByTestId('section 1').should('not.exist'); + // no scroll when focusing something in the header area + cy.findByTestId('op').invoke('scrollTop').as('scrollTop'); + cy.wait(100); + cy.realPress('ArrowLeft'); + cy.get('@scrollTop').then((scrollTop) => { + cy.findByTestId('op').invoke('scrollTop').should('equal', scrollTop); + }); cy.get('[ui5-tabcontainer]').findUi5TabByText('Goals').click(); cy.findByText('section 2').should('not.exist'); diff --git a/packages/main/src/components/ObjectPage/ObjectPage.module.css b/packages/main/src/components/ObjectPage/ObjectPage.module.css index ea7373b6310..79daa00d29c 100644 --- a/packages/main/src/components/ObjectPage/ObjectPage.module.css +++ b/packages/main/src/components/ObjectPage/ObjectPage.module.css @@ -33,10 +33,6 @@ } } -.withFooter { - scroll-padding-block-end: calc(var(--_ui5wcr-BarHeight) + 0.5rem); -} - .iconTabBarMode section[data-component-name='ObjectPageSection'] > div[role='heading'] { display: none; } diff --git a/packages/main/src/components/ObjectPage/index.tsx b/packages/main/src/components/ObjectPage/index.tsx index 3a63942b3fb..ae5d6d22363 100644 --- a/packages/main/src/components/ObjectPage/index.tsx +++ b/packages/main/src/components/ObjectPage/index.tsx @@ -1,6 +1,5 @@ 'use client'; -import type { TabContainerTabSelectEventDetail } from '@ui5/webcomponents/dist/TabContainer.js'; import AvatarSize from '@ui5/webcomponents/dist/types/AvatarSize.js'; import { debounce, @@ -10,28 +9,23 @@ import { useSyncRef } from '@ui5/webcomponents-react-base'; import { clsx } from 'clsx'; -import type { CSSProperties, ReactElement, ReactNode } from 'react'; +import type { CSSProperties, MouseEventHandler, ReactElement, UIEventHandler } from 'react'; import { cloneElement, forwardRef, isValidElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { ObjectPageMode } from '../../enums/index.js'; +import { ObjectPageMode } from '../../enums/ObjectPageMode.js'; import { safeGetChildrenArray } from '../../internal/safeGetChildrenArray.js'; import { useObserveHeights } from '../../internal/useObserveHeights.js'; -import type { CommonProps, Ui5CustomEvent } from '../../types/index.js'; -import type { AvatarPropTypes, TabContainerDomRef } from '../../webComponents/index.js'; -import { Tab, TabContainer } from '../../webComponents/index.js'; +import type { AvatarPropTypes } from '../../webComponents/Avatar/index.js'; +import { Tab } from '../../webComponents/Tab/index.js'; +import { TabContainer } from '../../webComponents/TabContainer/index.js'; import { ObjectPageAnchorBar } from '../ObjectPageAnchorBar/index.js'; -import type { - InternalProps as ObjectPageHeaderPropTypesWithInternals, - ObjectPageHeaderPropTypes -} from '../ObjectPageHeader/index.js'; +import type { InternalProps as ObjectPageHeaderPropTypesWithInternals } from '../ObjectPageHeader/index.js'; import type { ObjectPageSectionPropTypes } from '../ObjectPageSection/index.js'; import type { ObjectPageSubSectionPropTypes } from '../ObjectPageSubSection/index.js'; -import type { - InternalProps as ObjectPageTitlePropTypesWithInternals, - ObjectPageTitlePropTypes -} from '../ObjectPageTitle/index.js'; import { CollapsedAvatar } from './CollapsedAvatar.js'; import { classNames, styleData } from './ObjectPage.module.css.js'; import { getSectionById, getSectionElementById } from './ObjectPageUtils.js'; +import type { ObjectPageTitlePropsWithDataAttributes, ObjectPagePropTypes, ObjectPageDomRef } from './types/index.js'; +import { useHandleTabSelect } from './useHandleTabSelect.js'; const ObjectPageCssVariables = { headerDisplay: '--_ui5wcr_ObjectPage_header_display', @@ -40,142 +34,6 @@ const ObjectPageCssVariables = { const TAB_CONTAINER_HEADER_HEIGHT = 44; -type ObjectPageSectionType = ReactElement | boolean; - -interface BeforeNavigateDetail { - sectionIndex: number; - sectionId: string; - subSectionId: string | undefined; -} - -type ObjectPageTabSelectEventDetail = TabContainerTabSelectEventDetail & BeforeNavigateDetail; - -type ObjectPageTitlePropsWithDataAttributes = ObjectPageTitlePropTypesWithInternals & { - 'data-not-clickable': boolean; - 'data-header-content-visible': boolean; - 'data-is-snapped-rendered-outside': boolean; -}; - -export interface ObjectPagePropTypes extends Omit { - /** - * Defines the upper, always static, title section of the `ObjectPage`. - * - * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `ObjectPageTitle` in order to preserve the intended design. - * - * __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! - */ - titleArea?: ReactElement; - /** - * Defines the `ObjectPageHeader` section of the `ObjectPage`. - * - * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `ObjectPageHeader` in order to preserve the intended design. - * - * __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! - */ - headerArea?: ReactElement; - /** - * React element which defines the footer content. - * - * __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. - */ - footerArea?: ReactElement; - /** - * Defines the image of the `ObjectPage`. You can pass a path to an image or an `Avatar` component. - */ - image?: string | ReactElement; - /** - * Defines the content area of the `ObjectPage`. It consists of sections and subsections. - * - * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `ObjectPageSection` in order to preserve the intended design. - */ - children?: ObjectPageSectionType | ObjectPageSectionType[]; - /** - * Sets the current selected `ObjectPageSection` by `id`. - * - * __Note:__ If a valid `selectedSubSectionId` is set, this prop has no effect. - */ - selectedSectionId?: string; - /** - * Sets the current selected `ObjectPageSubSection` by `id`. - */ - selectedSubSectionId?: string; - /** - * Defines whether the `headerArea` is pinned. - */ - headerPinned?: boolean; - /** - * Defines whether the image should be displayed in a circle or in a square.
- * __Note:__ If the `image` is not a `string`, this prop has no effect. - */ - imageShapeCircle?: boolean; - /** - * Defines the `ObjectPage` mode. - * - * - "Default": All `ObjectPageSections` and `ObjectPageSubSections` are displayed on one page. Selecting tabs will scroll to the corresponding section. - * - "IconTabBar": All `ObjectPageSections` are displayed on separate pages. Selecting tabs will lead to the corresponding page. - * - * @default `"Default"` - */ - mode?: ObjectPageMode | keyof typeof ObjectPageMode; - /** - * Defines if the pin button for the `headerArea` is hidden. - */ - hidePinButton?: boolean; - /** - * Determines whether the user can switch between the expanded/collapsed states of the `ObjectPageHeader` by clicking on the `ObjectPageTitle`. - * - * __Note:__ Per default the header is toggleable. - */ - preserveHeaderStateOnClick?: boolean; - /** - * Defines internally used accessibility properties/attributes. - */ - accessibilityAttributes?: { - objectPageTopHeader?: { - role?: string; - ariaRoledescription?: string; - }; - objectPageAnchorBar?: { - role?: string; - }; - }; - /** - * If set, only the specified placeholder will be displayed as content of the `ObjectPage`, no sections or sub-sections will be rendered. - * - * __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. - */ - placeholder?: ReactNode; - /** - * 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. - * - * __Note:__ This event is only fired when navigating via tab-bar. - */ - onBeforeNavigate?: (event: Ui5CustomEvent) => void; - /** - * Fired when the selected section changes. - */ - onSelectedSectionChange?: ( - event: CustomEvent<{ selectedSectionIndex: number; selectedSectionId: string; section: HTMLDivElement }> - ) => void; - /** - * Fired when the `headerArea` is expanded or collapsed. - */ - onToggleHeaderArea?: (visible: boolean) => void; - /** - * Fired when the `headerArea` changes its pinned state. - */ - onPinButtonToggle?: (pinned: boolean) => void; -} - -export interface ObjectPageDomRef extends HTMLDivElement { - /** - * Toggles the `headerArea` of the `ObjectPage`. - * - * __Note:__ If no argument is passed, the header state is toggled, otherwise the respective `snapped` state is applied. - */ - toggleHeaderArea: (snapped?: boolean) => void; -} - /** * A component that allows apps to easily display information related to a business object. * @@ -214,31 +72,32 @@ const ObjectPage = forwardRef((props, ref [children] ); const firstSectionId: string | undefined = childrenArray[0]?.props?.id; - const [internalSelectedSectionId, setInternalSelectedSectionId] = useState( selectedSectionId ?? firstSectionId ); - const [selectedSubSectionId, setSelectedSubSectionId] = useState(undefined); - const [headerPinned, setHeaderPinned] = useState(headerPinnedProp); - const isProgrammaticallyScrolled = useRef(false); - const [isMounted, setIsMounted] = useState(false); + const isProgrammaticallyScrolled = useRef(false); const [componentRef, objectPageRef] = useSyncRef(ref); const topHeaderRef = useRef(null); - const scrollEvent = useRef(undefined); const prevTopHeaderHeight = useRef(0); // @ts-expect-error: useSyncRef will create a ref if not present const [componentRefHeaderContent, headerContentRef] = useSyncRef(headerArea?.ref); const anchorBarRef = useRef(null); + const scrollEvent = useRef(undefined); const objectPageContentRef = useRef(null); const selectionScrollTimeout = useRef(null); const isToggledRef = useRef(false); const isInitial = useRef(true); + const scrollTimeout = useRef(0); + + const [selectedSubSectionId, setSelectedSubSectionId] = useState(undefined); + const [headerPinned, setHeaderPinned] = useState(headerPinnedProp); + const [isMounted, setIsMounted] = useState(false); const [headerCollapsedInternal, setHeaderCollapsedInternal] = useState(undefined); const [scrolledHeaderExpanded, setScrolledHeaderExpanded] = useState(false); - const scrollTimeout = useRef(0); const [sectionSpacer, setSectionSpacer] = useState(0); const [currentTabModeSection, setCurrentTabModeSection] = useState(null); + const [toggledCollapsedHeaderWasVisible, setToggledCollapsedHeaderWasVisible] = useState(false); const sections = mode === ObjectPageMode.IconTabBar ? currentTabModeSection : children; useEffect(() => { @@ -523,52 +382,34 @@ const ObjectPage = forwardRef((props, ref }; }, [topHeaderHeight, headerContentHeight, currentTabModeSection, children, mode, isHeaderPinnedAndExpanded]); - const onToggleHeaderContentVisibility = useCallback((e) => { + const onToggleHeaderContentVisibility = (e) => { isToggledRef.current = true; scrollTimeout.current = performance.now() + 500; + setToggledCollapsedHeaderWasVisible(false); if (!e.detail.visible) { + if (objectPageRef.current.scrollTop <= headerContentHeight) { + setToggledCollapsedHeaderWasVisible(true); + if (firstSectionId === internalSelectedSectionId || mode === ObjectPageMode.IconTabBar) { + objectPageRef.current.scrollTop = 0; + } + } setHeaderCollapsedInternal(true); - objectPageRef.current?.classList.add(classNames.headerCollapsed); + setScrolledHeaderExpanded(false); } else { setHeaderCollapsedInternal(false); - setScrolledHeaderExpanded(true); - objectPageRef.current?.classList.remove(classNames.headerCollapsed); - } - }, []); - - const handleOnSubSectionSelected = (e) => { - isProgrammaticallyScrolled.current = true; - if (mode === ObjectPageMode.IconTabBar) { - const sectionId = e.detail.sectionId; - setInternalSelectedSectionId(sectionId); - const sectionNodes = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]'); - const currentIndex = childrenArray.findIndex((objectPageSection) => { - return ( - isValidElement(objectPageSection) && - (objectPageSection as ReactElement).props?.id === sectionId - ); - }); - debouncedOnSectionChange(e, currentIndex, sectionId, sectionNodes[currentIndex]); + if (objectPageRef.current.scrollTop >= headerContentHeight) { + setScrolledHeaderExpanded(true); + } } - const subSectionId = e.detail.subSectionId; - scrollTimeout.current = performance.now() + 200; - setSelectedSubSectionId(subSectionId); }; - const objectPageClasses = clsx( - classNames.objectPage, - className, - mode === ObjectPageMode.IconTabBar && classNames.iconTabBarMode, - footerArea && classNames.withFooter - ); - const { onScroll: _0, selectedSubSectionId: _1, ...propsWithoutOmitted } = rest; const visibleSectionIds = useRef>(new Set()); useEffect(() => { const sectionNodes = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]'); // only the sticky part of the header must be added as margin - const rootMargin = `-${(headerPinned && !headerCollapsed ? totalHeaderHeight : topHeaderHeight) + TAB_CONTAINER_HEADER_HEIGHT}px 0px 0px 0px`; + const rootMargin = `-${((headerPinned && !headerCollapsed) || scrolledHeaderExpanded ? totalHeaderHeight : topHeaderHeight) + TAB_CONTAINER_HEADER_HEIGHT}px 0px 0px 0px`; const observer = new IntersectionObserver( (entries) => { @@ -610,7 +451,7 @@ const ObjectPage = forwardRef((props, ref return () => { observer.disconnect(); }; - }, [totalHeaderHeight, headerPinned, headerCollapsed, topHeaderHeight, childrenArray.length]); + }, [totalHeaderHeight, headerPinned, headerCollapsed, topHeaderHeight, childrenArray.length, scrolledHeaderExpanded]); const onTitleClick = (e) => { e.stopPropagation(); @@ -656,47 +497,17 @@ const ObjectPage = forwardRef((props, ref } }; - const onTabItemSelect = (event) => { - if (typeof onBeforeNavigate === 'function') { - const selectedTabDataset = event.detail.tab.dataset; - const sectionIndex = parseInt(selectedTabDataset.index, 10); - const sectionId = selectedTabDataset.parentId ?? selectedTabDataset.sectionId; - const subSectionId = Object.prototype.hasOwnProperty.call(selectedTabDataset, 'isSubTab') - ? selectedTabDataset.sectionId - : undefined; - onBeforeNavigate( - enrichEventWithDetails(event, { - sectionIndex, - sectionId, - subSectionId - }) - ); - if (event.defaultPrevented) { - return; - } - } - event.preventDefault(); - const { sectionId, index, isSubTab, parentId } = event.detail.tab.dataset; - - if (isSubTab !== undefined) { - handleOnSubSectionSelected(enrichEventWithDetails(event, { sectionId: parentId, subSectionId: sectionId })); - } else { - const section = childrenArray.find((el) => { - return el.props.id == sectionId; - }); - handleOnSectionSelected(event, section?.props?.id, index, section); - } - }; - const prevScrollTop = useRef(undefined); - const onObjectPageScroll = useCallback( + const onObjectPageScroll: UIEventHandler = useCallback( (e) => { + const target = e.target as HTMLDivElement; if (!isToggledRef.current) { isToggledRef.current = true; } if (scrollTimeout.current >= performance.now()) { return; } + setToggledCollapsedHeaderWasVisible(false); scrollEvent.current = e; if (typeof props.onScroll === 'function') { props.onScroll(e); @@ -707,14 +518,14 @@ const ObjectPage = forwardRef((props, ref if (selectionScrollTimeout.current) { clearTimeout(selectionScrollTimeout.current); } - if (!headerPinned || e.target.scrollTop === 0) { + if (!headerPinned || target.scrollTop === 0) { objectPageRef.current?.classList.remove(classNames.headerCollapsed); } - if (scrolledHeaderExpanded && e.target.scrollTop !== prevScrollTop.current) { - if (e.target.scrollHeight - e.target.scrollTop === e.target.clientHeight) { + if (scrolledHeaderExpanded && target.scrollTop !== prevScrollTop.current) { + if (target.scrollHeight - target.scrollTop === target.clientHeight) { return; } - prevScrollTop.current = e.target.scrollTop; + prevScrollTop.current = target.scrollTop; if (!headerPinned) { setHeaderCollapsedInternal(true); } @@ -724,20 +535,32 @@ const ObjectPage = forwardRef((props, ref [topHeaderHeight, headerPinned, props.onScroll, scrolledHeaderExpanded, selectedSubSectionId] ); - const onHoverToggleButton = useCallback( - (e) => { - if (e?.type === 'mouseover') { - topHeaderRef.current?.classList.add(classNames.headerHoverStyles); - } else { - topHeaderRef.current?.classList.remove(classNames.headerHoverStyles); - } - }, - [classNames.headerHoverStyles] - ); + const onHoverToggleButton: MouseEventHandler = useCallback((e) => { + if (e.type === 'mouseover') { + topHeaderRef.current?.classList.add(classNames.headerHoverStyles); + } else { + topHeaderRef.current?.classList.remove(classNames.headerHoverStyles); + } + }, []); + + const handleTabSelect = useHandleTabSelect({ + onBeforeNavigate, + headerPinned, + mode, + setHeaderCollapsedInternal, + setScrolledHeaderExpanded, + childrenArray, + handleOnSectionSelected, + isProgrammaticallyScrolled, + setInternalSelectedSectionId, + objectPageRef, + debouncedOnSectionChange, + scrollTimeout, + setSelectedSubSectionId + }); const objectPageStyles: CSSProperties = { - ...style, - scrollPaddingBlockStart: `${Math.ceil(topHeaderHeight + TAB_CONTAINER_HEADER_HEIGHT + (!headerCollapsed && headerPinned ? headerContentHeight : 0))}px` + ...style }; if (headerCollapsed === true && headerArea) { objectPageStyles[ObjectPageCssVariables.titleFontSize] = ThemingParameters.sapObjectHeader_Title_SnappedFontSize; @@ -745,11 +568,15 @@ const ObjectPage = forwardRef((props, ref return (
((props, ref > @@ -878,8 +705,33 @@ const ObjectPage = forwardRef((props, ref
)} -
-
+
{ + objectPageRef.current.style.scrollPaddingBlockStart = `${Math.ceil(topHeaderHeight + TAB_CONTAINER_HEADER_HEIGHT + (!headerCollapsed && headerPinned ? headerContentHeight : 0))}px`; + if (footerArea) { + objectPageRef.current.style.scrollPaddingBlockEnd = 'calc(var(--_ui5wcr-BarHeight) + 0.5rem)'; + } + }} + onBlur={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + objectPageRef.current.style.scrollPaddingBlockStart = '0px'; + objectPageRef.current.style.scrollPaddingBlockEnd = '0px'; + } + }} + > +
{placeholder ? placeholder : sections}
@@ -898,3 +750,4 @@ const ObjectPage = forwardRef((props, ref ObjectPage.displayName = 'ObjectPage'; export { ObjectPage }; +export type { ObjectPageDomRef, ObjectPagePropTypes }; diff --git a/packages/main/src/components/ObjectPage/types/index.ts b/packages/main/src/components/ObjectPage/types/index.ts new file mode 100644 index 00000000000..0b778996aa7 --- /dev/null +++ b/packages/main/src/components/ObjectPage/types/index.ts @@ -0,0 +1,148 @@ +import type { TabContainerTabSelectEventDetail } from '@ui5/webcomponents/dist/TabContainer.js'; +import type { CommonProps, Ui5CustomEvent } from '@ui5/webcomponents-react-base'; +import type { ReactElement, ReactNode } from 'react'; +import type { ObjectPageMode } from '../../../enums/ObjectPageMode.js'; +import type { AvatarPropTypes } from '../../../webComponents/Avatar/index.js'; +import type { TabContainerDomRef } from '../../../webComponents/TabContainer/index.js'; +import type { ObjectPageHeaderPropTypes } from '../../ObjectPageHeader/index.js'; +import type { ObjectPageSectionPropTypes } from '../../ObjectPageSection/index.js'; +import type { + InternalProps as ObjectPageTitlePropTypesWithInternals, + ObjectPageTitlePropTypes +} from '../../ObjectPageTitle/index.js'; + +type ObjectPageSectionType = ReactElement | boolean; + +interface BeforeNavigateDetail { + sectionIndex: number; + sectionId: string; + subSectionId: string | undefined; +} + +type ObjectPageTabSelectEventDetail = TabContainerTabSelectEventDetail & BeforeNavigateDetail; + +export type ObjectPageTitlePropsWithDataAttributes = ObjectPageTitlePropTypesWithInternals & { + 'data-not-clickable': boolean; + 'data-header-content-visible': boolean; + 'data-is-snapped-rendered-outside': boolean; +}; + +export interface ObjectPagePropTypes extends Omit { + /** + * Defines the upper, always static, title section of the `ObjectPage`. + * + * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `ObjectPageTitle` in order to preserve the intended design. + * + * __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! + */ + titleArea?: ReactElement; + /** + * Defines the `ObjectPageHeader` section of the `ObjectPage`. + * + * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `ObjectPageHeader` in order to preserve the intended design. + * + * __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! + */ + headerArea?: ReactElement; + /** + * React element which defines the footer content. + * + * __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. + */ + footerArea?: ReactElement; + /** + * Defines the image of the `ObjectPage`. You can pass a path to an image or an `Avatar` component. + */ + image?: string | ReactElement; + /** + * Defines the content area of the `ObjectPage`. It consists of sections and subsections. + * + * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `ObjectPageSection` in order to preserve the intended design. + */ + children?: ObjectPageSectionType | ObjectPageSectionType[]; + /** + * Sets the current selected `ObjectPageSection` by `id`. + * + * __Note:__ If a valid `selectedSubSectionId` is set, this prop has no effect. + */ + selectedSectionId?: string; + /** + * Sets the current selected `ObjectPageSubSection` by `id`. + */ + selectedSubSectionId?: string; + /** + * Defines whether the `headerArea` is pinned. + */ + headerPinned?: boolean; + /** + * Defines whether the image should be displayed in a circle or in a square.
+ * __Note:__ If the `image` is not a `string`, this prop has no effect. + */ + imageShapeCircle?: boolean; + /** + * Defines the `ObjectPage` mode. + * + * - "Default": All `ObjectPageSections` and `ObjectPageSubSections` are displayed on one page. Selecting tabs will scroll to the corresponding section. + * - "IconTabBar": All `ObjectPageSections` are displayed on separate pages. Selecting tabs will lead to the corresponding page. + * + * @default `"Default"` + */ + mode?: ObjectPageMode | keyof typeof ObjectPageMode; + /** + * Defines if the pin button for the `headerArea` is hidden. + */ + hidePinButton?: boolean; + /** + * Determines whether the user can switch between the expanded/collapsed states of the `ObjectPageHeader` by clicking on the `ObjectPageTitle`. + * + * __Note:__ Per default the header is toggleable. + */ + preserveHeaderStateOnClick?: boolean; + /** + * Defines internally used accessibility properties/attributes. + */ + accessibilityAttributes?: { + objectPageTopHeader?: { + role?: string; + ariaRoledescription?: string; + }; + objectPageAnchorBar?: { + role?: string; + }; + }; + /** + * If set, only the specified placeholder will be displayed as content of the `ObjectPage`, no sections or sub-sections will be rendered. + * + * __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. + */ + placeholder?: ReactNode; + /** + * 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. + * + * __Note:__ This event is only fired when navigating via tab-bar. + */ + onBeforeNavigate?: (event: Ui5CustomEvent) => void; + /** + * Fired when the selected section changes. + */ + onSelectedSectionChange?: ( + event: CustomEvent<{ selectedSectionIndex: number; selectedSectionId: string; section: HTMLDivElement }> + ) => void; + /** + * Fired when the `headerArea` is expanded or collapsed. + */ + onToggleHeaderArea?: (visible: boolean) => void; + /** + * Fired when the `headerArea` changes its pinned state. + */ + onPinButtonToggle?: (pinned: boolean) => void; +} + +export interface ObjectPageDomRef extends HTMLDivElement { + /** + * Toggles the `headerArea` of the `ObjectPage`. + * + * __Note:__ If no argument is passed, the header state is toggled, otherwise the respective `snapped` state is applied. + */ + toggleHeaderArea: (snapped?: boolean) => void; +} diff --git a/packages/main/src/components/ObjectPage/useHandleTabSelect.ts b/packages/main/src/components/ObjectPage/useHandleTabSelect.ts new file mode 100644 index 00000000000..e8c60f1ebab --- /dev/null +++ b/packages/main/src/components/ObjectPage/useHandleTabSelect.ts @@ -0,0 +1,115 @@ +import type { debounce } from '@ui5/webcomponents-react-base'; +import { enrichEventWithDetails } from '@ui5/webcomponents-react-base'; +import type { Dispatch, JSXElementConstructor, ReactElement, RefObject, SetStateAction } from 'react'; +import { isValidElement, useEffect, useState } from 'react'; +import { ObjectPageMode } from '../../enums/ObjectPageMode.js'; +import type { TabContainerPropTypes } from '../../webComponents/TabContainer/index.js'; +import type { ObjectPageSectionPropTypes } from '../ObjectPageSection/index.js'; +import type { ObjectPageDomRef, ObjectPagePropTypes } from './types/index.js'; + +interface UseHandleTabSelectProps { + onBeforeNavigate: ObjectPagePropTypes['onBeforeNavigate']; + headerPinned: boolean; + mode: ObjectPagePropTypes['mode']; + setHeaderCollapsedInternal: Dispatch>; + setScrolledHeaderExpanded: Dispatch>; + childrenArray: ReactElement>[]; + handleOnSectionSelected: any; + + isProgrammaticallyScrolled: RefObject; + setInternalSelectedSectionId: Dispatch>; + objectPageRef: RefObject; + debouncedOnSectionChange: ReturnType; + scrollTimeout: RefObject; + setSelectedSubSectionId: Dispatch>; +} + +export const useHandleTabSelect = ({ + onBeforeNavigate, + headerPinned, + mode, + setHeaderCollapsedInternal, + setScrolledHeaderExpanded, + childrenArray, + handleOnSectionSelected, + + isProgrammaticallyScrolled, + setInternalSelectedSectionId, + objectPageRef, + debouncedOnSectionChange, + scrollTimeout, + setSelectedSubSectionId +}: UseHandleTabSelectProps) => { + const [onSectionSelectedArgs, setOnSectionSelectedArgs] = useState< + | false + | [ + Parameters[0], + undefined | string, + string, + ReactElement + ] + >(false); + + const handleOnSubSectionSelected = (e) => { + isProgrammaticallyScrolled.current = true; + if (mode === ObjectPageMode.IconTabBar) { + const sectionId = e.detail.sectionId; + setInternalSelectedSectionId(sectionId); + const sectionNodes = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]'); + const currentIndex = childrenArray.findIndex((objectPageSection) => { + return ( + isValidElement(objectPageSection) && + (objectPageSection as ReactElement).props?.id === sectionId + ); + }); + debouncedOnSectionChange(e, currentIndex, sectionId, sectionNodes[currentIndex]); + } + const subSectionId = e.detail.subSectionId; + scrollTimeout.current = performance.now() + 200; + setSelectedSubSectionId(subSectionId); + }; + + const handleTabItemSelect: TabContainerPropTypes['onTabSelect'] = (event) => { + if (typeof onBeforeNavigate === 'function') { + const selectedTabDataset = event.detail.tab.dataset; + const sectionIndex = parseInt(selectedTabDataset.index, 10); + const sectionId = selectedTabDataset.parentId ?? selectedTabDataset.sectionId; + const subSectionId = Object.prototype.hasOwnProperty.call(selectedTabDataset, 'isSubTab') + ? selectedTabDataset.sectionId + : undefined; + onBeforeNavigate( + enrichEventWithDetails(event, { + sectionIndex, + sectionId, + subSectionId + }) + ); + if (event.defaultPrevented) { + return; + } + } + event.preventDefault(); + const { sectionId, index, isSubTab, parentId } = event.detail.tab.dataset; + if (parseInt(index) !== 0 && !headerPinned && mode !== ObjectPageMode.IconTabBar) { + setHeaderCollapsedInternal(true); + } + setScrolledHeaderExpanded(false); + if (isSubTab !== undefined) { + handleOnSubSectionSelected(enrichEventWithDetails(event, { sectionId: parentId, subSectionId: sectionId })); + } else { + const section = childrenArray.find((el) => { + return el.props.id == sectionId; + }); + setOnSectionSelectedArgs([event, section?.props?.id, index, section]); + } + }; + // effect required - if event is called in `handleTabItemSelect` it's invoked twice in StrictMode + useEffect(() => { + if (onSectionSelectedArgs) { + handleOnSectionSelected(...onSectionSelectedArgs); + setOnSectionSelectedArgs(false); + } + }, [onSectionSelectedArgs]); + + return handleTabItemSelect; +}; From 8d56733c7aafdf4803e12f9d902f32ba2a072f7b Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Thu, 27 Mar 2025 16:27:32 +0100 Subject: [PATCH 02/10] Update index.tsx --- packages/main/src/components/ObjectPage/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/main/src/components/ObjectPage/index.tsx b/packages/main/src/components/ObjectPage/index.tsx index ae5d6d22363..1c23062564f 100644 --- a/packages/main/src/components/ObjectPage/index.tsx +++ b/packages/main/src/components/ObjectPage/index.tsx @@ -576,7 +576,7 @@ const ObjectPage = forwardRef((props, ref className, mode === ObjectPageMode.IconTabBar && classNames.iconTabBarMode )} - style={style} + style={objectPageStyles} onScroll={onObjectPageScroll} data-in-iframe={window && window.self !== window.top} {...propsWithoutOmitted} From 735a102e0a998b0b1a819ca907961b2bc18eed7b Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Thu, 27 Mar 2025 16:39:11 +0100 Subject: [PATCH 03/10] fix(ObjectPage): correct position of snapped `image` --- .../ObjectPage/ObjectPage.module.css | 7 +- .../ObjectPage/ObjectPage.stories.tsx | 12 +- .../main/src/components/ObjectPage/index.tsx | 14 +- .../ObjectPageTitle.module.css | 9 + .../src/components/ObjectPageTitle/index.tsx | 159 ++++++------------ .../components/ObjectPageTitle/types/index.ts | 85 ++++++++++ 6 files changed, 159 insertions(+), 127 deletions(-) create mode 100644 packages/main/src/components/ObjectPageTitle/types/index.ts diff --git a/packages/main/src/components/ObjectPage/ObjectPage.module.css b/packages/main/src/components/ObjectPage/ObjectPage.module.css index 79daa00d29c..fa363f6e16b 100644 --- a/packages/main/src/components/ObjectPage/ObjectPage.module.css +++ b/packages/main/src/components/ObjectPage/ObjectPage.module.css @@ -47,11 +47,7 @@ z-index: 4; cursor: pointer; display: grid; - - [data-component-name='ObjectPageTitle'] { - grid-column: 2; - padding-inline: 0; - } + grid-auto-columns: 100%; } /*:has cannot be nested*/ @@ -185,7 +181,6 @@ } .snappedContent { - grid-column: 1 / span 2; padding-block-end: 0.5rem; } diff --git a/packages/main/src/components/ObjectPage/ObjectPage.stories.tsx b/packages/main/src/components/ObjectPage/ObjectPage.stories.tsx index 8b9090f8d46..2820c1f3e8e 100644 --- a/packages/main/src/components/ObjectPage/ObjectPage.stories.tsx +++ b/packages/main/src/components/ObjectPage/ObjectPage.stories.tsx @@ -94,8 +94,16 @@ const meta = { Employee Details } - expandedContent={Information (only visible if header content is expanded)} - snappedContent={Information (only visible if header content is collapsed/snapped)} + expandedContent={ + + Information (only visible if header content is expanded) + + } + snappedContent={ + + Information (only visible if header content is collapsed/snapped) + + } > employed diff --git a/packages/main/src/components/ObjectPage/index.tsx b/packages/main/src/components/ObjectPage/index.tsx index 1c23062564f..85f19a97253 100644 --- a/packages/main/src/components/ObjectPage/index.tsx +++ b/packages/main/src/components/ObjectPage/index.tsx @@ -590,27 +590,23 @@ const ObjectPage = forwardRef((props, ref data-not-clickable={!!preserveHeaderStateOnClick} aria-roledescription={accessibilityAttributes?.objectPageTopHeader?.ariaRoledescription ?? 'Object Page header'} className={classNames.header} - style={{ - gridAutoColumns: `min-content ${ - titleArea && image && headerCollapsed === true ? `calc(100% - 3rem - 1rem)` : '100%' - }` - }} > - {titleArea && image && headerCollapsed === true && ( - - )} {titleArea && cloneElement(titleArea as ReactElement, { className: clsx(titleArea?.props?.className), onToggleHeaderContentVisibility: onTitleClick, 'data-not-clickable': !!preserveHeaderStateOnClick, 'data-header-content-visible': headerArea && headerCollapsed !== true, - 'data-is-snapped-rendered-outside': snappedHeaderInObjPage + 'data-is-snapped-rendered-outside': snappedHeaderInObjPage, + _snappedAvatar: + titleArea && image && headerCollapsed === true ? ( + + ) : null })} {snappedHeaderInObjPage && (
diff --git a/packages/main/src/components/ObjectPageTitle/ObjectPageTitle.module.css b/packages/main/src/components/ObjectPageTitle/ObjectPageTitle.module.css index 4bfe21abf29..6ba131ffe6c 100644 --- a/packages/main/src/components/ObjectPageTitle/ObjectPageTitle.module.css +++ b/packages/main/src/components/ObjectPageTitle/ObjectPageTitle.module.css @@ -67,6 +67,10 @@ } } +.snappedAvatarContainer:has([data-component-name='ObjectPageCollapsedAvatar']) { + flex-shrink: 0; +} + @container (min-width: 600px) and (max-width: 1023px) { .title { padding-block-start: 0.6875rem; @@ -93,6 +97,11 @@ padding-inline-start: 0; } +.contentContainer { + flex-basis: 100%; + flex-grow: 1; +} + .content { display: flex; flex-shrink: 1.6; diff --git a/packages/main/src/components/ObjectPageTitle/index.tsx b/packages/main/src/components/ObjectPageTitle/index.tsx index dcc31a30285..640c1edcdf9 100644 --- a/packages/main/src/components/ObjectPageTitle/index.tsx +++ b/packages/main/src/components/ObjectPageTitle/index.tsx @@ -2,84 +2,13 @@ import { debounce, Device, useStylesheet, useSyncRef } from '@ui5/webcomponents-react-base'; import { clsx } from 'clsx'; -import type { ReactElement, ReactNode } from 'react'; import { cloneElement, forwardRef, isValidElement, useEffect, useRef, useState } from 'react'; import { FlexBoxAlignItems, FlexBoxDirection, FlexBoxJustifyContent } from '../../enums/index.js'; import { stopPropagation } from '../../internal/stopPropagation.js'; -import type { CommonProps } from '../../types/index.js'; import type { ToolbarDomRef } from '../../webComponents/index.js'; import { FlexBox } from '../FlexBox/index.js'; import { classNames, styleData } from './ObjectPageTitle.module.css.js'; - -export interface ObjectPageTitlePropTypes extends CommonProps { - /** - * Defines the actions bar of the `ObjectPageTitle`. - * - * __Note:__ Although this prop accepts all `ReactElement`s, it is strongly recommended that you only use the `Toolbar` component in order to preserve the intended design. - */ - actionsBar?: ReactElement; - - /** - * The `breadcrumbs` displayed in the `ObjectPageTitle` top-left area. - * - * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `Breadcrumbs` in order to preserve the intended design. - */ - breadcrumbs?: ReactNode | ReactNode[]; - - /** - * The content is positioned in the `ObjectPageTitle` middle area - */ - children?: ReactNode | ReactNode[]; - - /** - * The `header` is positioned in the `ObjectPageTitle` left area. - * Use this prop to display a `Title` (or any other component that serves as a heading). - */ - header?: ReactNode; - /** - * The `subHeader` is positioned in the `ObjectPageTitle` left area below the `header`. - * Use this aggregation to display a component like `Label` or any other component that serves as sub header. - */ - subHeader?: ReactNode; - /** - * Defines navigation-actions bar of the `ObjectPageTitle`. - * - * *Note*: The `navigationBar` position depends on the control size. - * If the control size is 1280px or bigger, they are rendered right next to the `actionsBar`. - * Otherwise, they are rendered in the top-right area (above the `actionsBar`). - * If a large number of elements(buttons) are used, there could be visual degradations as the space for the `navigationBar` is limited. - * - * __Note:__ Although this prop accepts all `ReactElement`s, it is strongly recommended that you only use the `Toolbar` component in order to preserve the intended design. - */ - navigationBar?: ReactElement; - /** - * The content displayed in the `ObjectPageTitle` in expanded state. - */ - expandedContent?: ReactNode | ReactNode[]; - /** - * The content displayed in the `ObjectPageTitle` in collapsed (snapped) state. - */ - snappedContent?: ReactNode | ReactNode[]; -} - -export interface InternalProps extends ObjectPageTitlePropTypes { - /** - * The onToggleHeaderContentVisibility show or hide the header section - */ - onToggleHeaderContentVisibility?: (e: any) => void; - /** - * Defines whether the content area can be toggled - */ - 'data-not-clickable'?: boolean; - /** - * Defines whether the content area is visible - */ - 'data-header-content-visible'?: boolean; - /** - * Defines if the `snappedContent` should be rendered by the `ObjectPageTitle` - */ - 'data-is-snapped-rendered-outside'?: boolean; -} +import type { InternalProps, ObjectPageTitlePropTypes } from './types/index.js'; /** * The `ObjectPageTitle` component is used to serve as title of the `ObjectPage`. @@ -99,6 +28,7 @@ const ObjectPageTitle = forwardRef((pr onToggleHeaderContentVisibility, expandedContent, snappedContent, + _snappedAvatar, ...rest } = props as InternalProps; @@ -218,47 +148,55 @@ const ObjectPageTitle = forwardRef((pr {showNavigationInTopArea && navigationBar &&
{navigationBar}
} )} - - - {header && ( -
- {header} -
- )} - {children && ( -
- {children} -
+ +
{_snappedAvatar}
+ + + + {header && ( +
+ {header} +
+ )} + {children && ( +
+ {children} +
+ )} +
+ {(actionsBar || (!showNavigationInTopArea && navigationBar)) && ( +
+ {actionsBar} + {!showNavigationInTopArea && actionsBar && navigationBar && ( +
+ )} + {!showNavigationInTopArea && (wcrNavToolbar ? wcrNavToolbar : navigationBar)} +
+ )} + + {subHeader && ( + +
+ {subHeader} +
+
)} - {(actionsBar || (!showNavigationInTopArea && navigationBar)) && ( -
- {actionsBar} - {!showNavigationInTopArea && actionsBar && navigationBar && ( -
- )} - {!showNavigationInTopArea && (wcrNavToolbar ? wcrNavToolbar : navigationBar)} -
- )} - {subHeader && ( - -
- {subHeader} -
-
- )} {props?.['data-header-content-visible'] ? expandedContent : props['data-is-snapped-rendered-outside'] @@ -271,3 +209,4 @@ const ObjectPageTitle = forwardRef((pr ObjectPageTitle.displayName = 'ObjectPageTitle'; export { ObjectPageTitle }; +export type { ObjectPageTitlePropTypes }; diff --git a/packages/main/src/components/ObjectPageTitle/types/index.ts b/packages/main/src/components/ObjectPageTitle/types/index.ts new file mode 100644 index 00000000000..56ab5d31389 --- /dev/null +++ b/packages/main/src/components/ObjectPageTitle/types/index.ts @@ -0,0 +1,85 @@ +import type { ReactElement, ReactNode } from 'react'; +import type { CommonProps } from '../../../types/index.js'; +import type { CollapsedAvatarPropTypes } from '../../ObjectPage/CollapsedAvatar.js'; + +export interface ObjectPageTitlePropTypes extends CommonProps { + /** + * Defines the actions bar of the `ObjectPageTitle`. + * + * __Note:__ Although this prop accepts all `ReactElement`s, it is strongly recommended that you only use the `Toolbar` component in order to preserve the intended design. + */ + actionsBar?: ReactElement; + + /** + * The `breadcrumbs` displayed in the `ObjectPageTitle` top-left area. + * + * __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use `Breadcrumbs` in order to preserve the intended design. + */ + breadcrumbs?: ReactNode | ReactNode[]; + + /** + * The content is positioned in the `ObjectPageTitle` middle area + */ + children?: ReactNode | ReactNode[]; + + /** + * The `header` is positioned in the `ObjectPageTitle` left area. + * Use this prop to display a `Title` (or any other component that serves as a heading). + */ + header?: ReactNode; + /** + * The `subHeader` is positioned in the `ObjectPageTitle` left area below the `header`. + * Use this aggregation to display a component like `Label` or any other component that serves as sub header. + */ + subHeader?: ReactNode; + /** + * Defines navigation-actions bar of the `ObjectPageTitle`. + * + * *Note*: The `navigationBar` position depends on the control size. + * If the control size is 1280px or bigger, they are rendered right next to the `actionsBar`. + * Otherwise, they are rendered in the top-right area (above the `actionsBar`). + * If a large number of elements(buttons) are used, there could be visual degradations as the space for the `navigationBar` is limited. + * + * __Note:__ Although this prop accepts all `ReactElement`s, it is strongly recommended that you only use the `Toolbar` component in order to preserve the intended design. + */ + navigationBar?: ReactElement; + /** + * The content displayed in the `ObjectPageTitle` in expanded state. + */ + expandedContent?: ReactNode | ReactNode[]; + /** + * The content displayed in the `ObjectPageTitle` in collapsed (snapped) state. + */ + snappedContent?: ReactNode | ReactNode[]; +} + +export interface InternalProps extends ObjectPageTitlePropTypes { + /** + * The onToggleHeaderContentVisibility show or hide the header section + * + * @private + */ + onToggleHeaderContentVisibility?: (e: any) => void; + /** + * Defines whether the content area can be toggled + * + * @private + */ + 'data-not-clickable'?: boolean; + /** + * Defines whether the content area is visible + * + * @private + */ + 'data-header-content-visible'?: boolean; + /** + * Defines if the `snappedContent` should be rendered by the `ObjectPageTitle` + * + * @private + */ + 'data-is-snapped-rendered-outside'?: boolean; + /** + * @private + */ + _snappedAvatar?: ReactElement; +} From 3211be8c41d5e2763b4a6a50f469825e7ca3999b Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Fri, 28 Mar 2025 11:37:55 +0100 Subject: [PATCH 04/10] fix collapse/expand header scroll behavior if `image` is defined --- .../main/src/components/ObjectPage/index.tsx | 17 ----------------- .../src/components/ObjectPageTitle/index.tsx | 6 +----- .../components/ObjectPageTitle/types/index.ts | 6 ------ 3 files changed, 1 insertion(+), 28 deletions(-) diff --git a/packages/main/src/components/ObjectPage/index.tsx b/packages/main/src/components/ObjectPage/index.tsx index 85f19a97253..513ec000587 100644 --- a/packages/main/src/components/ObjectPage/index.tsx +++ b/packages/main/src/components/ObjectPage/index.tsx @@ -87,7 +87,6 @@ const ObjectPage = forwardRef((props, ref const objectPageContentRef = useRef(null); const selectionScrollTimeout = useRef(null); const isToggledRef = useRef(false); - const isInitial = useRef(true); const scrollTimeout = useRef(0); const [selectedSubSectionId, setSelectedSubSectionId] = useState(undefined); @@ -460,16 +459,6 @@ const ObjectPage = forwardRef((props, ref } }; - const snappedHeaderInObjPage = titleArea && titleArea.props.snappedContent && headerCollapsed === true && !!image; - - useEffect(() => { - if (!isInitial.current) { - scrollTimeout.current = performance.now() + 200; - } else { - isInitial.current = false; - } - }, [snappedHeaderInObjPage]); - const renderHeaderContentSection = () => { if (headerArea?.props) { return cloneElement(headerArea as ReactElement, { @@ -602,17 +591,11 @@ const ObjectPage = forwardRef((props, ref onToggleHeaderContentVisibility: onTitleClick, 'data-not-clickable': !!preserveHeaderStateOnClick, 'data-header-content-visible': headerArea && headerCollapsed !== true, - 'data-is-snapped-rendered-outside': snappedHeaderInObjPage, _snappedAvatar: titleArea && image && headerCollapsed === true ? ( ) : null })} - {snappedHeaderInObjPage && ( -
- {titleArea.props.snappedContent} -
- )} {renderHeaderContentSection()} {headerArea && titleArea && ( diff --git a/packages/main/src/components/ObjectPageTitle/index.tsx b/packages/main/src/components/ObjectPageTitle/index.tsx index 640c1edcdf9..902c8eb3aa5 100644 --- a/packages/main/src/components/ObjectPageTitle/index.tsx +++ b/packages/main/src/components/ObjectPageTitle/index.tsx @@ -197,11 +197,7 @@ const ObjectPageTitle = forwardRef((pr )} - {props?.['data-header-content-visible'] - ? expandedContent - : props['data-is-snapped-rendered-outside'] - ? undefined - : snappedContent} + {props?.['data-header-content-visible'] ? expandedContent : snappedContent} ); }); diff --git a/packages/main/src/components/ObjectPageTitle/types/index.ts b/packages/main/src/components/ObjectPageTitle/types/index.ts index 56ab5d31389..288c59e5e4b 100644 --- a/packages/main/src/components/ObjectPageTitle/types/index.ts +++ b/packages/main/src/components/ObjectPageTitle/types/index.ts @@ -72,12 +72,6 @@ export interface InternalProps extends ObjectPageTitlePropTypes { * @private */ 'data-header-content-visible'?: boolean; - /** - * Defines if the `snappedContent` should be rendered by the `ObjectPageTitle` - * - * @private - */ - 'data-is-snapped-rendered-outside'?: boolean; /** * @private */ From 9e1d311f495ac8bab4e65cc9cff23877c039e110 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Fri, 28 Mar 2025 11:43:47 +0100 Subject: [PATCH 05/10] fix build & flaky test --- packages/main/src/components/ObjectPage/ObjectPage.cy.tsx | 1 + packages/main/src/components/ObjectPage/types/index.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx b/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx index 248dd8ac79f..be29b8a63f3 100644 --- a/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx +++ b/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx @@ -448,6 +448,7 @@ describe('ObjectPage', () => { cy.findByText('Job Information').should('not.exist'); cy.findByTestId('section 1').should('be.visible'); + cy.wait(100); cy.get('[ui5-tabcontainer]').findUi5TabByText('Employment').realClick(); cy.findByText('Job Information').should('be.visible'); cy.findByTestId('section 1').should('not.exist'); diff --git a/packages/main/src/components/ObjectPage/types/index.ts b/packages/main/src/components/ObjectPage/types/index.ts index 0b778996aa7..fbaa8f07241 100644 --- a/packages/main/src/components/ObjectPage/types/index.ts +++ b/packages/main/src/components/ObjectPage/types/index.ts @@ -9,7 +9,7 @@ import type { ObjectPageSectionPropTypes } from '../../ObjectPageSection/index.j import type { InternalProps as ObjectPageTitlePropTypesWithInternals, ObjectPageTitlePropTypes -} from '../../ObjectPageTitle/index.js'; +} from '../../ObjectPageTitle/types/index.js'; type ObjectPageSectionType = ReactElement | boolean; From aea05684c78ecc44c2c5d7255ccc4d783b9241b1 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Fri, 28 Mar 2025 12:07:05 +0100 Subject: [PATCH 06/10] always show snapped `image` if `headerArea` is not available --- packages/main/src/components/ObjectPage/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/main/src/components/ObjectPage/index.tsx b/packages/main/src/components/ObjectPage/index.tsx index 513ec000587..8b8aa071259 100644 --- a/packages/main/src/components/ObjectPage/index.tsx +++ b/packages/main/src/components/ObjectPage/index.tsx @@ -592,7 +592,7 @@ const ObjectPage = forwardRef((props, ref 'data-not-clickable': !!preserveHeaderStateOnClick, 'data-header-content-visible': headerArea && headerCollapsed !== true, _snappedAvatar: - titleArea && image && headerCollapsed === true ? ( + !headerArea || (titleArea && image && headerCollapsed === true) ? ( ) : null })} From a010526c84fdfe136a9297b61b30c84bdeeee5d1 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Fri, 28 Mar 2025 12:16:13 +0100 Subject: [PATCH 07/10] Update ObjectPageTitle.cy.tsx --- .../src/components/ObjectPageTitle/ObjectPageTitle.cy.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/main/src/components/ObjectPageTitle/ObjectPageTitle.cy.tsx b/packages/main/src/components/ObjectPageTitle/ObjectPageTitle.cy.tsx index 1ddeb884aa7..bd2cecf3e55 100644 --- a/packages/main/src/components/ObjectPageTitle/ObjectPageTitle.cy.tsx +++ b/packages/main/src/components/ObjectPageTitle/ObjectPageTitle.cy.tsx @@ -127,11 +127,6 @@ describe('ObjectPageTitle', () => { cy.findByTestId('page').scrollTo(0, 500); cy.findByText('snappedContent').should('exist'); cy.findByTestId('expandedContent').should('not.exist'); - if (headerContent && image) { - cy.get('[data-component-name="ATwithImageSnappedContentContainer"]').should('exist'); - } else { - cy.get('[data-component-name="ATwithImageSnappedContentContainer"]').should('not.exist'); - } }); }); }); From c64925124102fb6aae3847980490663fa4c1822d Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Fri, 28 Mar 2025 12:21:18 +0100 Subject: [PATCH 08/10] cleanup --- .../main/src/components/ObjectPage/ObjectPage.module.css | 9 --------- packages/main/src/components/ObjectPage/types/index.ts | 1 - 2 files changed, 10 deletions(-) diff --git a/packages/main/src/components/ObjectPage/ObjectPage.module.css b/packages/main/src/components/ObjectPage/ObjectPage.module.css index fa363f6e16b..837e9275353 100644 --- a/packages/main/src/components/ObjectPage/ObjectPage.module.css +++ b/packages/main/src/components/ObjectPage/ObjectPage.module.css @@ -50,11 +50,6 @@ grid-auto-columns: 100%; } -/*:has cannot be nested*/ -.header:has([data-component-name='ATwithImageSnappedContentContainer']) [data-component-name='ObjectPageTitle'] { - padding-block-end: 0; -} - .headerCollapsed { --_ui5wcr_ObjectPage_header_display: none; --_ui5wcr_ObjectPage_title_fontsize: var(--sapObjectHeader_Title_SnappedFontSize); @@ -180,10 +175,6 @@ padding: 0; } -.snappedContent { - padding-block-end: 0.5rem; -} - .clickArea { position: absolute; inset: 0; diff --git a/packages/main/src/components/ObjectPage/types/index.ts b/packages/main/src/components/ObjectPage/types/index.ts index fbaa8f07241..5beb014e15e 100644 --- a/packages/main/src/components/ObjectPage/types/index.ts +++ b/packages/main/src/components/ObjectPage/types/index.ts @@ -24,7 +24,6 @@ type ObjectPageTabSelectEventDetail = TabContainerTabSelectEventDetail & BeforeN export type ObjectPageTitlePropsWithDataAttributes = ObjectPageTitlePropTypesWithInternals & { 'data-not-clickable': boolean; 'data-header-content-visible': boolean; - 'data-is-snapped-rendered-outside': boolean; }; export interface ObjectPagePropTypes extends Omit { From 09751904891ffb93640bfd827bb75cc13563539a Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Mon, 31 Mar 2025 13:19:50 +0200 Subject: [PATCH 09/10] fix sub-section scroll --- .../components/ObjectPage/ObjectPage.cy.tsx | 77 +++++++++++++++++++ .../main/src/components/ObjectPage/index.tsx | 54 +++++++------ 2 files changed, 108 insertions(+), 23 deletions(-) diff --git a/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx b/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx index 248dd8ac79f..5dc76dd254c 100644 --- a/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx +++ b/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx @@ -430,10 +430,87 @@ describe('ObjectPage', () => { cy.findByText('Job Relationship').should('be.visible'); cy.findByTestId('footer').should('be.visible'); + + cy.mount( + + {OPContent} + + + + Start SubSection1 + End SubSection1 + + + + + Start SubSection2 + End SubSection2 + + + + + ); + cy.wait(100); + + cy.get('[ui5-tabcontainer]').findUi5TabByText('Long Section').focus(); + cy.realPress('ArrowDown'); + cy.get('[ui5-responsive-popover]').should('be.visible'); + cy.realPress('ArrowDown'); + cy.wait(50); + cy.realPress('ArrowDown'); + cy.realPress('Enter'); + // wait for scroll + cy.wait(200); + cy.findByText('Start SubSection2').should('be.visible'); }); it('scroll to sections - tab mode', () => { document.body.style.margin = '0px'; + + cy.mount( + + {OPContent} + + + + Start SubSection1 + End SubSection1 + + + + + Start SubSection2 + End SubSection2 + + + + + ); + cy.wait(100); + + cy.get('[ui5-tabcontainer]').findUi5TabByText('Long Section').focus(); + cy.realPress('ArrowDown'); + cy.get('[ui5-responsive-popover]').should('be.visible'); + cy.realPress('ArrowDown'); + cy.wait(50); + cy.realPress('ArrowDown'); + cy.realPress('Enter'); + // wait for scroll + cy.wait(200); + cy.findByText('Start SubSection2').should('be.visible'); + cy.mount( ((props, ref }, [image, imageShapeCircle]); const scrollToSectionById = (id: string | undefined, isSubSection = false) => { - const section = getSectionElementById(objectPageRef.current, isSubSection, id); - scrollTimeout.current = performance.now() + 500; - if (section) { - const safeTopHeaderHeight = topHeaderHeight || prevTopHeaderHeight.current; - - const scrollMargin = - -1 /* reduce margin-block so that intersection observer detects correct section*/ + - safeTopHeaderHeight + - anchorBarHeight + - TAB_CONTAINER_HEADER_HEIGHT + - (headerPinned && !headerCollapsed ? headerContentHeight : 0); - section.style.scrollMarginBlockStart = scrollMargin + 'px'; - if (isSubSection) { - section.focus(); - } + const scroll = () => { + const section = getSectionElementById(objectPageRef.current, isSubSection, id); + scrollTimeout.current = performance.now() + 500; + if (section) { + const safeTopHeaderHeight = topHeaderHeight || prevTopHeaderHeight.current; + + const scrollMargin = + -1 /* reduce margin-block so that intersection observer detects correct section*/ + + safeTopHeaderHeight + + anchorBarHeight + + TAB_CONTAINER_HEADER_HEIGHT + + (headerPinned && !headerCollapsed ? headerContentHeight : 0); + section.style.scrollMarginBlockStart = scrollMargin + 'px'; + if (isSubSection) { + section.focus(); + } - const sectionRect = section.getBoundingClientRect(); - const objectPageElement = objectPageRef.current; - const objectPageRect = objectPageElement.getBoundingClientRect(); + const sectionRect = section.getBoundingClientRect(); + const objectPageElement = objectPageRef.current; + const objectPageRect = objectPageElement.getBoundingClientRect(); - // Calculate the top position of the section relative to the container - objectPageElement.scrollTop = sectionRect.top - objectPageRect.top + objectPageElement.scrollTop - scrollMargin; + // Calculate the top position of the section relative to the container + objectPageElement.scrollTop = sectionRect.top - objectPageRect.top + objectPageElement.scrollTop - scrollMargin; - section.style.scrollMarginBlockStart = ''; + section.style.scrollMarginBlockStart = ''; + } + }; + // In TabBar mode the section is only rendered when selected: delay scroll for subsection + if (mode === ObjectPageMode.IconTabBar && isSubSection) { + setTimeout(scroll, 300); + } else { + scroll(); } }; @@ -263,7 +271,7 @@ const ObjectPage = forwardRef((props, ref // Scrolling for Sub Section Selection useEffect(() => { - if (selectedSubSectionId && isProgrammaticallyScrolled.current === true && sectionSpacer) { + if (selectedSubSectionId && isProgrammaticallyScrolled.current === true) { scrollToSectionById(selectedSubSectionId, true); isProgrammaticallyScrolled.current = false; } From f73a45fb2b95a30d2a81ebddcf3b229b747ee353 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Mon, 7 Apr 2025 09:22:26 +0200 Subject: [PATCH 10/10] Update eslint.config.mjs --- eslint.config.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 8a12f6e1e51..20ba1dc0d02 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -26,7 +26,8 @@ const ignorePatterns = { '**/shared', '**/examples', '**/templates', - '**/*.module.css.ts' + '**/*.module.css.ts', + '.yarn' ] };