From aae5c4d33db211e7fe468a21507be2f9d45b20eb Mon Sep 17 00:00:00 2001 From: "Chuma Mcphoy (We Ahead)" Date: Fri, 5 Dec 2025 05:41:02 +0000 Subject: [PATCH] Merged in feat/LOY-495-Stays-Sidepeek-Scroll-to-Top (pull request #3279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feat/LOY-495 Scroll to Top Functionality in Previous Stays Sidepeek * feat(LOY-495): enable scroll to top functionality for past stays sidepeek Approved-by: Emma Zettervall Approved-by: Matilda Landström --- .../Previous/PreviousStaysSidePeek/index.tsx | 34 ++++++++++++++++++- .../previousStaysSidePeek.module.css | 1 + packages/common/hooks/useScrollToTop.ts | 34 +++++++++++++------ 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/PreviousStaysSidePeek/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/PreviousStaysSidePeek/index.tsx index 505a87003..31df3cc84 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/PreviousStaysSidePeek/index.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/PreviousStaysSidePeek/index.tsx @@ -1,7 +1,10 @@ "use client" +import { useState } from "react" import { useIntl } from "react-intl" +import { useScrollToTop } from "@scandic-hotels/common/hooks/useScrollToTop" +import { BackToTopButton } from "@scandic-hotels/design-system/BackToTopButton" import { LoadingSpinner } from "@scandic-hotels/design-system/LoadingSpinner" import SidePeekSelfControlled from "@scandic-hotels/design-system/SidePeekSelfControlled" import { Typography } from "@scandic-hotels/design-system/Typography" @@ -29,6 +32,25 @@ export function PreviousStaysSidePeek({ const intl = useIntl() const lang = useLang() + const [scrollContainer, setScrollContainer] = useState( + null + ) + + const scrollContainerRef = (node: HTMLDivElement | null) => { + const parent = node?.parentElement + // SidePeekSelfControlled renders children twice: in the modal & in an sr-only SEO wrapper. + // We filter out the SEO wrapper to get the actual scrollable container. + if (parent && !parent.classList.contains("sr-only")) { + setScrollContainer(parent) + } + } + + const { showBackToTop, scrollToTop } = useScrollToTop({ + threshold: 200, + element: scrollContainer, + refScrollable: true, + }) + const { data, isFetching, fetchNextPage, hasNextPage, isLoading } = trpc.user.stays.previous.useInfiniteQuery( { @@ -64,7 +86,7 @@ export function PreviousStaysSidePeek({ isOpen={isOpen} onClose={onClose} > -
+
{isLoading ? (
@@ -96,6 +118,16 @@ export function PreviousStaysSidePeek({ )} )} + {showBackToTop && !isLoading && ( + + )}
) diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/PreviousStaysSidePeek/previousStaysSidePeek.module.css b/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/PreviousStaysSidePeek/previousStaysSidePeek.module.css index f653a0768..aff4bc5ed 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/PreviousStaysSidePeek/previousStaysSidePeek.module.css +++ b/apps/scandic-web/components/Blocks/DynamicContent/Stays/Previous/PreviousStaysSidePeek/previousStaysSidePeek.module.css @@ -2,6 +2,7 @@ display: flex; flex-direction: column; gap: var(--Space-x3); + position: relative; } .loadingContainer { diff --git a/packages/common/hooks/useScrollToTop.ts b/packages/common/hooks/useScrollToTop.ts index 60ca9dfac..6a41cc5a8 100644 --- a/packages/common/hooks/useScrollToTop.ts +++ b/packages/common/hooks/useScrollToTop.ts @@ -2,41 +2,53 @@ import { type RefObject, useEffect, useState } from "react" interface UseScrollToTopProps { threshold: number + /** + * Direct element reference. Use this when the element is conditionally + * rendered or when you need to reference a parent element via state. + * Takes precedence over elementRef if both are provided. + */ + element?: HTMLElement | null + /** + * Ref to the element. Use this when you have a direct ref attachment + * to the scrollable element. + */ elementRef?: RefObject refScrollable?: boolean } export function useScrollToTop({ threshold, + element, elementRef, refScrollable, }: UseScrollToTopProps) { const [showBackToTop, setShowBackToTop] = useState(false) useEffect(() => { - const element = - refScrollable && elementRef?.current ? elementRef?.current : window + const targetElement = element ?? elementRef?.current + const scrollTarget = refScrollable && targetElement ? targetElement : window function handleScroll() { let position = window.scrollY - if (elementRef?.current) { + if (targetElement) { position = refScrollable - ? elementRef.current.scrollTop - : elementRef.current.getBoundingClientRect().top * -1 + ? targetElement.scrollTop + : targetElement.getBoundingClientRect().top * -1 } setShowBackToTop(position > threshold) } - element.addEventListener("scroll", handleScroll, { passive: true }) - return () => element.removeEventListener("scroll", handleScroll) - }, [threshold, elementRef, refScrollable]) + scrollTarget.addEventListener("scroll", handleScroll, { passive: true }) + return () => scrollTarget.removeEventListener("scroll", handleScroll) + }, [threshold, element, elementRef, refScrollable]) function scrollToTop() { - if (elementRef?.current) { + const targetElement = element ?? elementRef?.current + if (targetElement) { if (refScrollable) { - elementRef.current.scrollTo({ top: 0, behavior: "smooth" }) + targetElement.scrollTo({ top: 0, behavior: "smooth" }) } - window.scrollTo({ top: elementRef.current.offsetTop, behavior: "smooth" }) + window.scrollTo({ top: targetElement.offsetTop, behavior: "smooth" }) } else { window.scrollTo({ top: 0, behavior: "smooth" }) }