From 4381a0eb3e691021372477bcf95c1cf691def0e0 Mon Sep 17 00:00:00 2001 From: Alex Rabisse Date: Fri, 26 Sep 2025 22:11:59 -0500 Subject: [PATCH 1/6] IRN-6178 BpkCardList Enable Physical Scroll For Row --- .../BpkCardListCarousel.module.scss | 8 +--- .../BpkCardListCarousel.tsx | 37 ++++++++++++++++--- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.module.scss b/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.module.scss index 4895f264f0..e0ee4d1dec 100644 --- a/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.module.scss +++ b/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.module.scss @@ -26,16 +26,16 @@ display: flex; padding-top: tokens.bpk-spacing-sm(); padding-bottom: tokens.bpk-spacing-lg(); - overflow-x: hidden; + overflow-x: scroll; box-sizing: border-box; gap: tokens.bpk-spacing-sm(); + -webkit-overflow-scrolling: touch; scroll-snap-stop: always; scroll-snap-type: x mandatory; scrollbar-width: none; @include breakpoints.bpk-breakpoint-mobile { padding-bottom: tokens.bpk-spacing-base(); - overflow-x: scroll; } &::-webkit-scrollbar { @@ -85,8 +85,4 @@ } } } - - &__rail { - -webkit-overflow-scrolling: touch; - } } diff --git a/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx b/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx index 52a693a0d4..b8c1509008 100644 --- a/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx +++ b/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx @@ -82,6 +82,16 @@ const BpkCardListCarousel = (props: CardListCarouselProps) => { const stateScrollingLockRef = useRef(false); const openSetStateLockTimeoutRef = useRef(null); + // When a user clicks a nav button / page indicator the parent updates `currentIndex`. + // We then scroll programmatically (useScrollToCard). The IntersectionObserver still + // reports the old visible cards for a short time (until scrolling settles / threshold met). + // Without a guard, the visibility effect would see the old page as 'firstVisible' and + // revert `currentIndex`, causing the jump back you observed. We keep track of whether + // there's a pending programmatic navigation target and suppress visibility-driven + // updates until the observer reports the target page as visible. + const programmaticTargetIndexRef = useRef(null); + const prevCurrentIndexRef = useRef(currentIndex); + const observerVisibility = useIntersectionObserver( { root, threshold: 0.5 }, setVisibilityList, @@ -163,17 +173,20 @@ const BpkCardListCarousel = (props: CardListCarouselProps) => { lockScroll(stateScrollingLockRef, openSetStateLockTimeoutRef); }; + // Capture the current timeout ref for cleanup to satisfy lint rule about ref mutation. + const timeoutRefAtMount = openSetStateLockTimeoutRef; + container.addEventListener('wheel', lockScrollDuringInteraction); container.addEventListener('touchmove', lockScrollDuringInteraction); return () => { container.removeEventListener('touchmove', lockScrollDuringInteraction); container.removeEventListener('wheel', lockScrollDuringInteraction); - if (openSetStateLockTimeoutRef.current) { - clearTimeout(openSetStateLockTimeoutRef.current); + if (timeoutRefAtMount.current) { + clearTimeout(timeoutRefAtMount.current); } }; - }, [root]); + }, [root, isMobile]); useEffect(() => { // update hasBeenVisibleRef to include the range of cards that should be visible @@ -189,15 +202,29 @@ const BpkCardListCarousel = (props: CardListCarouselProps) => { dynamicRenderBufferSize, ]); + // Detect external (programmatic) index changes: parent set via click / nav. + useEffect(() => { + if (currentIndex !== prevCurrentIndexRef.current) { + programmaticTargetIndexRef.current = currentIndex; + prevCurrentIndexRef.current = currentIndex; + } + }, [currentIndex]); + useEffect(() => { const firstVisible = visibilityList.indexOf(1); if (firstVisible >= 0) { const newIndex = Math.floor(firstVisible / initiallyShownCards); - if (newIndex !== currentIndex) { + if (newIndex === currentIndex) { + // Programmatic target reached; clear suppression. + if (programmaticTargetIndexRef.current === currentIndex) { + programmaticTargetIndexRef.current = null; + } + } else if (programmaticTargetIndexRef.current == null) { + // Only update state from visibility when not waiting for a programmatic scroll to settle. setCurrentIndex(newIndex); } } - }, [initiallyShownCards]); + }, [visibilityList, initiallyShownCards, currentIndex, setCurrentIndex]); useEffect(() => { const handleResize = throttle(() => { From cd69b3e8ff00a11b62680da97c2d95c95a330c01 Mon Sep 17 00:00:00 2001 From: Alex Rabisse Date: Fri, 26 Sep 2025 22:19:29 -0500 Subject: [PATCH 2/6] === --- .../src/BpkCardListRowRail/BpkCardListCarousel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx b/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx index b8c1509008..1677760f58 100644 --- a/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx +++ b/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx @@ -219,7 +219,7 @@ const BpkCardListCarousel = (props: CardListCarouselProps) => { if (programmaticTargetIndexRef.current === currentIndex) { programmaticTargetIndexRef.current = null; } - } else if (programmaticTargetIndexRef.current == null) { + } else if (programmaticTargetIndexRef.current === null) { // Only update state from visibility when not waiting for a programmatic scroll to settle. setCurrentIndex(newIndex); } From 14d8e63fe72a235e0aa78901d2a18aa40edce436 Mon Sep 17 00:00:00 2001 From: Max Liefkes Date: Mon, 29 Sep 2025 12:54:27 +0100 Subject: [PATCH 3/6] use existing stateScrollingLockRef --- .../BpkCardListCarousel.tsx | 34 +++---------------- 1 file changed, 4 insertions(+), 30 deletions(-) diff --git a/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx b/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx index 1677760f58..94d3fe3b3f 100644 --- a/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx +++ b/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx @@ -82,16 +82,6 @@ const BpkCardListCarousel = (props: CardListCarouselProps) => { const stateScrollingLockRef = useRef(false); const openSetStateLockTimeoutRef = useRef(null); - // When a user clicks a nav button / page indicator the parent updates `currentIndex`. - // We then scroll programmatically (useScrollToCard). The IntersectionObserver still - // reports the old visible cards for a short time (until scrolling settles / threshold met). - // Without a guard, the visibility effect would see the old page as 'firstVisible' and - // revert `currentIndex`, causing the jump back you observed. We keep track of whether - // there's a pending programmatic navigation target and suppress visibility-driven - // updates until the observer reports the target page as visible. - const programmaticTargetIndexRef = useRef(null); - const prevCurrentIndexRef = useRef(currentIndex); - const observerVisibility = useIntersectionObserver( { root, threshold: 0.5 }, setVisibilityList, @@ -173,17 +163,14 @@ const BpkCardListCarousel = (props: CardListCarouselProps) => { lockScroll(stateScrollingLockRef, openSetStateLockTimeoutRef); }; - // Capture the current timeout ref for cleanup to satisfy lint rule about ref mutation. - const timeoutRefAtMount = openSetStateLockTimeoutRef; - container.addEventListener('wheel', lockScrollDuringInteraction); container.addEventListener('touchmove', lockScrollDuringInteraction); return () => { container.removeEventListener('touchmove', lockScrollDuringInteraction); container.removeEventListener('wheel', lockScrollDuringInteraction); - if (timeoutRefAtMount.current) { - clearTimeout(timeoutRefAtMount.current); + if (openSetStateLockTimeoutRef.current) { + clearTimeout(openSetStateLockTimeoutRef.current); } }; }, [root, isMobile]); @@ -202,25 +189,12 @@ const BpkCardListCarousel = (props: CardListCarouselProps) => { dynamicRenderBufferSize, ]); - // Detect external (programmatic) index changes: parent set via click / nav. - useEffect(() => { - if (currentIndex !== prevCurrentIndexRef.current) { - programmaticTargetIndexRef.current = currentIndex; - prevCurrentIndexRef.current = currentIndex; - } - }, [currentIndex]); - useEffect(() => { const firstVisible = visibilityList.indexOf(1); if (firstVisible >= 0) { const newIndex = Math.floor(firstVisible / initiallyShownCards); - if (newIndex === currentIndex) { - // Programmatic target reached; clear suppression. - if (programmaticTargetIndexRef.current === currentIndex) { - programmaticTargetIndexRef.current = null; - } - } else if (programmaticTargetIndexRef.current === null) { - // Only update state from visibility when not waiting for a programmatic scroll to settle. + + if (newIndex !== currentIndex && stateScrollingLockRef.current) { setCurrentIndex(newIndex); } } From 77ad06cfaf0ad92479dbb77c429e583312965bce Mon Sep 17 00:00:00 2001 From: Max Liefkes Date: Mon, 29 Sep 2025 13:34:31 +0100 Subject: [PATCH 4/6] original useEffect deps --- .../BpkCardListRowRail/BpkCardListCarousel.tsx | 15 +++++++++++---- .../src/BpkCardListRowRail/utils.tsx | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx b/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx index 94d3fe3b3f..d1600453da 100644 --- a/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx +++ b/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx @@ -99,11 +99,15 @@ const BpkCardListCarousel = (props: CardListCarouselProps) => { const lastVisibleIndex = firstVisibleIndex + initiallyShownCards - 1; const dynamicRenderBufferSize = useMemo(() => { - if (childrenLength === 0 || initiallyShownCards === 0 || isMobile) return RENDER_BUFFER_SIZE; + if (childrenLength === 0 || initiallyShownCards === 0 || isMobile) + return RENDER_BUFFER_SIZE; // Calculate how many cards to render based on the number of initially shown cards and total children const totalPages = Math.ceil(childrenLength / initiallyShownCards); - const shownIndicatorCount = Math.min(totalPages, PAGINATION_INDICATOR_MAX_SHOWN_COUNT); + const shownIndicatorCount = Math.min( + totalPages, + PAGINATION_INDICATOR_MAX_SHOWN_COUNT, + ); return Math.max( RENDER_BUFFER_SIZE, (shownIndicatorCount - 1) * initiallyShownCards, @@ -165,10 +169,13 @@ const BpkCardListCarousel = (props: CardListCarouselProps) => { container.addEventListener('wheel', lockScrollDuringInteraction); container.addEventListener('touchmove', lockScrollDuringInteraction); + // container.addEventListener('scroll', lockScrollDuringInteraction); return () => { container.removeEventListener('touchmove', lockScrollDuringInteraction); container.removeEventListener('wheel', lockScrollDuringInteraction); + // container.removeEventListener('scroll', lockScrollDuringInteraction); + if (openSetStateLockTimeoutRef.current) { clearTimeout(openSetStateLockTimeoutRef.current); } @@ -194,11 +201,11 @@ const BpkCardListCarousel = (props: CardListCarouselProps) => { if (firstVisible >= 0) { const newIndex = Math.floor(firstVisible / initiallyShownCards); - if (newIndex !== currentIndex && stateScrollingLockRef.current) { + if (newIndex !== currentIndex) { setCurrentIndex(newIndex); } } - }, [visibilityList, initiallyShownCards, currentIndex, setCurrentIndex]); + }, [initiallyShownCards]); useEffect(() => { const handleResize = throttle(() => { diff --git a/packages/bpk-component-card-list/src/BpkCardListRowRail/utils.tsx b/packages/bpk-component-card-list/src/BpkCardListRowRail/utils.tsx index 34d24b14ad..ba7e3bc9ff 100644 --- a/packages/bpk-component-card-list/src/BpkCardListRowRail/utils.tsx +++ b/packages/bpk-component-card-list/src/BpkCardListRowRail/utils.tsx @@ -16,7 +16,7 @@ * limitations under the License. */ -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useRef } from 'react'; import { RELEASE_LOCK_DELAY } from './constants'; From 7ed22a185c19f6cac31cf5d06f0813475932465c Mon Sep 17 00:00:00 2001 From: Max Liefkes Date: Mon, 29 Sep 2025 13:38:16 +0100 Subject: [PATCH 5/6] undo --- .../bpk-component-card-list/src/BpkCardListRowRail/utils.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bpk-component-card-list/src/BpkCardListRowRail/utils.tsx b/packages/bpk-component-card-list/src/BpkCardListRowRail/utils.tsx index 34d24b14ad..ba7e3bc9ff 100644 --- a/packages/bpk-component-card-list/src/BpkCardListRowRail/utils.tsx +++ b/packages/bpk-component-card-list/src/BpkCardListRowRail/utils.tsx @@ -16,7 +16,7 @@ * limitations under the License. */ -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useRef } from 'react'; import { RELEASE_LOCK_DELAY } from './constants'; From 3bf086f78bfdd02794393759b1cb191ee01e2e75 Mon Sep 17 00:00:00 2001 From: Max Liefkes Date: Mon, 29 Sep 2025 14:31:44 +0100 Subject: [PATCH 6/6] handle changes to initialShownCards as before --- .../BpkCardListRowRail/BpkCardListCarousel.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx b/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx index d1600453da..b9aee3a4f9 100644 --- a/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx +++ b/packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx @@ -74,6 +74,7 @@ const BpkCardListCarousel = (props: CardListCarouselProps) => { const hasBeenVisibleRef = useRef>(new Set()); const firstCardWidthRef = useRef(null); const firstCardHeightRef = useRef(null); + const prevInitiallyShownCardsRef = useRef(initiallyShownCards); const [visibilityList, setVisibilityList] = useState( Array(childrenLength).fill(0), @@ -169,13 +170,10 @@ const BpkCardListCarousel = (props: CardListCarouselProps) => { container.addEventListener('wheel', lockScrollDuringInteraction); container.addEventListener('touchmove', lockScrollDuringInteraction); - // container.addEventListener('scroll', lockScrollDuringInteraction); return () => { container.removeEventListener('touchmove', lockScrollDuringInteraction); container.removeEventListener('wheel', lockScrollDuringInteraction); - // container.removeEventListener('scroll', lockScrollDuringInteraction); - if (openSetStateLockTimeoutRef.current) { clearTimeout(openSetStateLockTimeoutRef.current); } @@ -201,11 +199,18 @@ const BpkCardListCarousel = (props: CardListCarouselProps) => { if (firstVisible >= 0) { const newIndex = Math.floor(firstVisible / initiallyShownCards); - if (newIndex !== currentIndex) { + if (newIndex === currentIndex) return; + + if (stateScrollingLockRef.current) { + setCurrentIndex(newIndex); + } + + if (prevInitiallyShownCardsRef.current !== initiallyShownCards) { setCurrentIndex(newIndex); + prevInitiallyShownCardsRef.current = initiallyShownCards; } } - }, [initiallyShownCards]); + }, [currentIndex, initiallyShownCards, setCurrentIndex, visibilityList]); useEffect(() => { const handleResize = throttle(() => {