From 2056feb7fffa3b3b8d04ea3014531c1c4a061b76 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Wed, 29 Jan 2025 16:16:45 +0100 Subject: [PATCH] fix(ObjectPage): fix scroll-to/selection behavior The `height` for the `spacer` is now calculated initially and is only adjusted if one of the relevant elements or properties are changed. This way the initial content height is not updated when the header is collapsed, leading to a smoother scroll experience and preventing wrong scroll position. --- .../main/src/components/ObjectPage/index.tsx | 70 +++++++++++++------ 1 file changed, 48 insertions(+), 22 deletions(-) diff --git a/packages/main/src/components/ObjectPage/index.tsx b/packages/main/src/components/ObjectPage/index.tsx index b986ae167c5..08b35f7e58d 100644 --- a/packages/main/src/components/ObjectPage/index.tsx +++ b/packages/main/src/components/ObjectPage/index.tsx @@ -233,6 +233,7 @@ const ObjectPage = forwardRef((props, ref const objectPageContentRef = useRef(null); const selectionScrollTimeout = useRef(null); const isToggledRef = useRef(false); + const isInitial = useRef(true); const [headerCollapsedInternal, setHeaderCollapsedInternal] = useState(undefined); const [scrolledHeaderExpanded, setScrolledHeaderExpanded] = useState(false); const scrollTimeout = useRef(0); @@ -366,7 +367,7 @@ const ObjectPage = forwardRef((props, ref }; // section was selected by clicking on the tab bar buttons - const handleOnSectionSelected = (targetEvent, newSelectionSectionId, index, section) => { + const handleOnSectionSelected = (targetEvent, newSelectionSectionId, index: number | string, section) => { isProgrammaticallyScrolled.current = true; debouncedOnSectionChange.cancel(); setSelectedSubSectionId(undefined); @@ -458,42 +459,67 @@ const ObjectPage = forwardRef((props, ref }, [props.selectedSubSectionId, isMounted]); const tabContainerContainerRef = useRef(null); + const isHeaderPinnedAndExpanded = headerPinned && !headerCollapsed; useEffect(() => { const objectPage = objectPageRef.current; - const sectionNodes = objectPage.querySelectorAll('[id^="ObjectPageSection"]'); - const lastSectionNode = sectionNodes[sectionNodes.length - 1]; const tabContainerContainer = tabContainerContainerRef.current; - const observer = new ResizeObserver(([sectionElement]) => { + if (!objectPage || !tabContainerContainer) { + return; + } + + const footerElement = objectPage.querySelector('[data-component-name="ObjectPageFooter"]'); + const topHeaderElement = objectPage.querySelector('[data-component-name="ObjectPageTopHeader"]'); + + const calculateSpacer = ([lastSectionNodeEntry]: ResizeObserverEntry[]) => { + const lastSectionNode = lastSectionNodeEntry?.target; + + if (!lastSectionNode) { + setSectionSpacer(0); + return; + } + const subSections = lastSectionNode.querySelectorAll('[id^="ObjectPageSubSection"]'); const lastSubSection = subSections[subSections.length - 1]; - const lastSubSectionOrSection = lastSubSection ?? sectionElement.target; + const lastSubSectionOrSection = lastSubSection ?? lastSectionNode; + if ((currentTabModeSection && !lastSubSection) || (sectionNodes.length === 1 && !lastSubSection)) { setSectionSpacer(0); - } else if (tabContainerContainer) { - const footerHeight = - objectPage.querySelector('[data-component-name="ObjectPageFooter"]') - ?.offsetHeight ?? 0; - - setSectionSpacer( - objectPage.getBoundingClientRect().bottom - - tabContainerContainer.getBoundingClientRect().bottom - - lastSubSectionOrSection.getBoundingClientRect().height - - footerHeight - - // section padding - 8 - ); + return; } - }); - if (objectPage && lastSectionNode) { + // batching DOM-reads together minimizes reflow + const footerHeight = footerElement?.offsetHeight ?? 0; + const objectPageRect = objectPage.getBoundingClientRect(); + const tabContainerContainerRect = tabContainerContainer.getBoundingClientRect(); + const lastSubSectionOrSectionRect = lastSubSectionOrSection.getBoundingClientRect(); + + let stickyHeaderBottom = 0; + if (!isHeaderPinnedAndExpanded) { + const topHeaderBottom = topHeaderElement?.getBoundingClientRect().bottom ?? 0; + stickyHeaderBottom = topHeaderBottom + tabContainerContainerRect.height; + } else { + stickyHeaderBottom = tabContainerContainerRect.bottom; + } + + const spacer = Math.ceil( + objectPageRect.bottom - stickyHeaderBottom - lastSubSectionOrSectionRect.height - footerHeight // section padding (8px) not included, so that the intersection observer is triggered correctly + ); + setSectionSpacer(Math.max(spacer, 0)); + }; + + const observer = new ResizeObserver(calculateSpacer); + const sectionNodes = objectPage.querySelectorAll('[id^="ObjectPageSection"]'); + const lastSectionNode = sectionNodes[sectionNodes.length - 1]; + + if (lastSectionNode) { observer.observe(lastSectionNode, { box: 'border-box' }); } return () => { observer.disconnect(); }; - }, [headerCollapsed, topHeaderHeight, headerContentHeight, currentTabModeSection, children, mode]); + }, [topHeaderHeight, headerContentHeight, currentTabModeSection, children, mode, isHeaderPinnedAndExpanded]); const onToggleHeaderContentVisibility = useCallback((e) => { isToggledRef.current = true; @@ -592,7 +618,6 @@ const ObjectPage = forwardRef((props, ref const snappedHeaderInObjPage = titleArea && titleArea.props.snappedContent && headerCollapsed === true && !!image; - const isInitial = useRef(true); useEffect(() => { if (!isInitial.current) { scrollTimeout.current = performance.now() + 200; @@ -646,6 +671,7 @@ const ObjectPage = forwardRef((props, ref } event.preventDefault(); const { sectionId, index, isSubTab, parentId } = event.detail.tab.dataset; + if (isSubTab !== undefined) { handleOnSubSectionSelected(enrichEventWithDetails(event, { sectionId: parentId, subSectionId: sectionId })); } else {