-
Notifications
You must be signed in to change notification settings - Fork 203
[Clover-402][BpkCardList] Update BpkCardList scroll behaviour #3878
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This PR updates the BpkCardList’s row layout to page by full sets of cards, unify rendering logic across viewports through virtualization, and apply style tweaks to match design specs.
- Click and slide now advance by the number of
initiallyShownCards
instead of one card. - Virtual scrolling renders only the current view plus a buffer and previously seen cards on both mobile and desktop.
- SCSS adjustments refine overflow behavior, padding, and responsive sizing at breakpoints.
Reviewed Changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
File | Description |
---|---|
packages/bpk-component-card-list/src/common-types.ts | Removed setCurrentIndex prop since carousel state is now internal. |
packages/bpk-component-card-list/src/BpkCardListRowRail/utils.tsx | Dropped unused useUpdateCurrentIndexByVisibility hook. |
packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListRowRailContainer.tsx | Recalculated page count, renamed variables, and stopped passing the state setter into the carousel. |
packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx | Added virtual-scrolling logic, refactored ref callbacks, removed legacy visibility hook. |
packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.module.scss | Locked desktop overflow, added mobile-only scroll and sizing breakpoints. |
packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel-test.tsx | Updated mocks to reflect removed hook and refactored test setup. |
packages/bpk-component-card-list/src/BpkCardList.tsx | Introduced separate defaults for mobile and desktop initial card counts. |
packages/bpk-component-card-list/src/BpkCardList.module.scss | Added mobile-specific gap adjustments. |
examples/bpk-component-card-list/examples.tsx | Cleaned up outdated props and usage comments in examples. |
examples/bpk-component-card-list/examples.module.scss | Removed example-specific card min-width styling. |
Comments suppressed due to low confidence (2)
packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx:202
- The
aria-current
attribute was removed, which may make it harder for assistive technologies to identify the currently active slide. Consider re-addingaria-current="true"
on the card when it matchescurrentIndex
.
aria-label={slideLabel(index, childrenLength)}
packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel-test.tsx:29
- Tests for
BpkCardListCarousel
no longer cover the new virtualization logic (therenderList
and buffer behavior). Consider adding tests to verify that out-of-buffer cards render as placeholders and that in-buffer cards render normally.
useScrollToCard: jest.fn(),
@@ -18,13 +18,14 @@ | |||
import type { CSSProperties } from 'react'; | |||
import { useRef, useState, useEffect, isValidElement, Children } from 'react'; | |||
|
|||
import throttle from 'lodash/debounce'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code imports throttle
from 'lodash/debounce'
, which actually provides a debounce function. Update this to import throttle
from 'lodash/throttle'
or rename the import if debounce semantics were intended.
import throttle from 'lodash/debounce'; | |
import throttle from 'lodash/throttle'; |
Copilot uses AI. Check for mistakes.
box-sizing: border-box; | ||
gap: tokens.bpk-spacing-sm(); | ||
scroll-snap-stop: always; | ||
scroll-snap-type: x mandatory; | ||
scrollbar-width: none; | ||
|
||
@include breakpoints.bpk-breakpoint-mobile { | ||
overflow-x: scroll; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] There are two separate @include breakpoints.bpk-breakpoint-mobile
blocks that could be consolidated to reduce duplication and simplify the stylesheet.
overflow-x: scroll; | |
overflow-x: scroll; | |
&__card { | |
&:first-child { | |
padding-left: tokens.bpk-spacing-sm(); | |
@include utils.bpk-rtl { | |
padding-right: tokens.bpk-spacing-sm(); | |
padding-left: tokens.bpk-spacing-md(); | |
} | |
} | |
flex: 0 0 | |
calc( | |
( | |
100% - | |
( | |
tokens.bpk-spacing-md() * | |
(var(--initially-shown-cards, 3) - 1) | |
) | |
) / | |
(var(--initially-shown-cards, 3) - 0.5) | |
); | |
} |
Copilot uses AI. Check for mistakes.
Visit https://backpack.github.io/storybook-prs/3878 to see this build running in a browser. |
@@ -152,7 +151,6 @@ const snippetProps = { | |||
}; | |||
|
|||
const DestinationCard = (i: number) => ( | |||
// Usage Suggestion: define minWidth from consumer side by using className |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
|
||
.bpk-card-list { | ||
display: flex; | ||
flex-direction: column; | ||
gap: tokens.bpk-spacing-lg(); | ||
|
||
@include breakpoints.bpk-breakpoint-mobile { | ||
gap: tokens.bpk-spacing-base(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Based on design, if mobile gap change to tokens.bpk-spacing-base()
@@ -34,7 +34,8 @@ import STYLES from './BpkCardList.module.scss'; | |||
|
|||
const getClassName = cssModules(STYLES); | |||
|
|||
const DEFAULT_ITEMS = 3; | |||
const DEFAULT_ITEMS_DESKTOP = 3; | |||
const DEFAULT_ITEMS_MOBILE = 2; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Based on design DEFAULT_ITEMS_MOBILE
be 2
makes more sense.
|
||
.bpk-card-list-row-rail { | ||
&__row, | ||
&__rail { | ||
display: flex; | ||
padding-top: tokens.bpk-spacing-sm(); | ||
padding-bottom: tokens.bpk-spacing-sm(); | ||
overflow-x: scroll; | ||
overflow-x: hidden; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In desktop, scroll card by wheel/touchpad will change currentIndex, And pagination will also change the currentIndex. It is rather difficult to keep the two in perfect synchronization, and the functionality is redundant with that of pagination so disable this feature.
&::-webkit-scrollbar { | ||
display: none; | ||
} | ||
|
||
&__card { | ||
position: relative; | ||
padding: tokens.bpk-spacing-md(); | ||
padding: 0 tokens.bpk-spacing-md(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
|
||
@include breakpoints.bpk-breakpoint-mobile { | ||
&:first-child { | ||
padding-left: tokens.bpk-spacing-sm(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(var(--initially-shown-cards, 3) - 1) | ||
) | ||
) / | ||
(var(--initially-shown-cards, 3) - 0.5) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
0.5 means that when on mobile, half of the last card in the visible area will be hidden.
cardRefs.current[index] = el; | ||
observerVisibility(el, index); | ||
setA11yTabIndex(el, index, visibilityList); | ||
// record the first card's width and height when it becomes visible |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The width and height of all cards in the Cardlist are consistent, including the placeholders for unrendered cards.
} | ||
|
||
// Only render cards that are within the renderList range or have been visible before | ||
if (renderList[index] !== 1 && !hasBeenVisibleRef.current.has(index)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the current card has not entered the visible area, replace it with an empty element. The empty element should be consistent with the card width/height to avoid reflow.
openSetStateLockTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>, | ||
) => { | ||
useEffect(() => { | ||
if (isMobile) return; // No pagination on mobile, so no need to update the current index |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Visit https://backpack.github.io/storybook-prs/3878 to see this build running in a browser. |
.bpk-card { | ||
min-width: tokens.$bpk-one-pixel-rem * 250; | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx
Outdated
Show resolved
Hide resolved
@@ -69,10 +73,44 @@ const BpkCardListCarousel = (props: CardListCarouselProps) => { | |||
const openSetStateLockTimeoutRef = useRef<NodeJS.Timeout | null>(null); | |||
|
|||
const observerVisibility = useIntersectionObserver( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I took a quick look at the useIntersectionObserver
hook. It lacks the disconnect
logic.
Could you help add that for this hook? 🙇♀️
backpack/packages/bpk-component-card-list/src/BpkCardListRowRail/utils.tsx
Lines 116 to 168 in 15273e9
export const useIntersectionObserver = ( | |
{ root, threshold }: IntersectionObserverInit, | |
setVisibilityList: React.Dispatch<React.SetStateAction<number[]>>, | |
) => { | |
const callbackRef = useRef(setVisibilityList); | |
useEffect(() => { | |
callbackRef.current = setVisibilityList; | |
}); | |
const observeAll = useMemo< | |
(element: HTMLElement | null, index: number) => void | |
>(() => { | |
if (!root) return () => {}; | |
const observer = new IntersectionObserver( | |
(entries) => { | |
entries.forEach((entry) => { | |
const { index } = (entry.target as HTMLElement).dataset; | |
if (!index) return; | |
const currentIndex = parseInt(index, 10); | |
if (entry.isIntersecting) { | |
setVisibilityList((prevList) => { | |
const newList = [...prevList]; | |
newList[currentIndex] = 1; | |
return newList; | |
}); | |
} else { | |
setVisibilityList((prevList) => { | |
const newList = [...prevList]; | |
newList[currentIndex] = 0; | |
return newList; | |
}); | |
} | |
}); | |
}, | |
{ root, threshold }, | |
); | |
const observeElement = (element: HTMLElement | null, index: number) => { | |
if (element && observer) { | |
element.setAttribute('data-index', index.toString()); | |
observer.observe(element); | |
} | |
}; | |
return observeElement; | |
}, [root, threshold]); | |
return observeAll; | |
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
updated in 153ff48
packages/bpk-component-card-list/src/BpkCardListRowRail/BpkCardListCarousel.tsx
Outdated
Show resolved
Hide resolved
: 0, | ||
); | ||
|
||
const cardRefCallback = (index: number) => (el: HTMLDivElement | null) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: The cardRefCallback
generates a closure inside .map
as each render will return a new ref callback for each card while it's given to each element in the return function: ref={cardRefCallback}
.
Maybe we can use a single callback + data-index way like the ImageGallery
does in banana, or use useMemo
to pre-generate a callback array.
const cardRefFns = useMemo(
() => Array(childrenLength).fill(null).map((_, i) => (n: HTMLElement|null) => {
cardRefs.current[i] = n;
}),
[childrenLength],
);
...
ref={cardRefFns[index]}
Both methods allow the same callback instance to be reused for the same index, avoiding unnecessary function creation and cleanup.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
updated
@@ -102,7 +102,6 @@ type CardListCarouselProps = { | |||
initiallyShownCards: number; | |||
layout: typeof LAYOUTS.row | typeof LAYOUTS.rail; | |||
currentIndex: number; | |||
setCurrentIndex: Dispatch<SetStateAction<number>>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The setCurrentIndex
function has been removed from CardListCarouselProps
, but RowRailContainer
is still updating the index using an internal state along with the PageIndicator
. Currently, there is no way to pass the visible page number back to the container between components. Is this the expected behavior? 🤔
I’m considering a couple of use cases: for instance, when opening a link, the page should display a specific index card, but the current component can't achieve that. Additionally, for the New Relic operational event, we need to track the index of the displayed card.
I believe it would be best to maintain a dual mode of controlled and uncontrolled states with currentIndex
and onIndexChange
. Otherwise, adding these functionalities back later could compromise API stability.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Currently, there is no way to pass the visible page number back to the container between components. Is this the expected behavior?
Based on the previous implementation, this is expected. Passing setCurrentIndex to BpkCardListCarousel was for useUpdateCurrentIndexByVisibility, but we no longer need this logic.
I’m considering a couple of use cases: for instance, when opening a link, the page should display a specific index card, but the current component can't achieve that. Additionally, for the New Relic operational event, we need to track the index of the displayed card.
This is a valid concern. In fact, we've already received feedback to trigger events. But I think this is related to changing the API. However, before that, we should review the current cardlist again to see which callback functions we need to pass out. For example, besides onIndexChange
and onFilterChange
, should we also expose them? I suggest that we make unified modifications to the API after the discussion. This PR only fixes the current problems in CardList and does not change any API.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This PR only fixes the current problems in CardList and does not change any API.
That makes sense!
And don't forget this 👇
However, before that, we should review the current cardlist again to see which callback functions we need to pass out
key={`carousel-card-${index.toString()}`} | ||
role="group" | ||
aria-label={slideLabel(index, childrenLength)} | ||
aria-current={index === currentIndex ? 'true' : 'false'} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤔 why do we remove this?
By the way,
@gert-janvercauteren could you help review the a11y of the BpkCardList component? 🙇♀️
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since multiple cards can be displayed on one screen currently and these cards have no primary or secondary distinctions (for example, three cards are displayed on one screen and all three are equally important), the current aria-current is not accurate: it only marks the first one, but there are three "current" ones. If aria-current is added to all three actually displayed pictures, multiple "current" designations will also cause confusion.
But love to discuss more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removing this is ok, aria-current="page" can be used on the pagnation dots, but not on a single slide :D
Visit https://backpack.github.io/storybook-prs/3878 to see this build running in a browser. |
Visit https://backpack.github.io/storybook-prs/3878 to see this build running in a browser. |
1 similar comment
Visit https://backpack.github.io/storybook-prs/3878 to see this build running in a browser. |
Hi @xiaogliu, I compared this storybook with the production storybook cases and found some visual differences, as the below screenshots show. Are these changes what we expected? |
Also found another issue. The cards in the third-to-last cardlist in the
cardlist-production.mov
cardlist-pr.mov |
@@ -129,7 +129,6 @@ const commonProps = { | |||
), | |||
buttonContent: 'See more', | |||
buttonHref: 'https://www.skyscanner.net/', | |||
initiallyShownCardsMobile: 2, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You removed this value, but I saw there is an example of giving initiallyShownCardsMobile : 1
.
Forget to update other exmaples?
initiallyShownCardsMobile={1} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I removed it here is because I change mobile default initiallyShownCardsMobile
be 2, consistent with design, so need to pass 2.
But in
initiallyShownCardsMobile={1} |
Visit https://backpack.github.io/storybook-prs/3878 to see this build running in a browser. |
Hey @Faye-Xiao This comment contains two issues:
|
Visit https://backpack.github.io/storybook-prs/3878 to see this build running in a browser. |
) | ||
) / | ||
max(1, (var(--initially-shown-cards, 3) - 0.8)) | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you explain the key numbers in this code and provide a note to help others understand it for future iterations? 🙇♀️
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
updated
Visit https://backpack.github.io/storybook-prs/3878 to see this build running in a browser. |
Visit https://backpack.github.io/storybook-prs/3878 to see this build running in a browser. |
Approved the Percy failure as the cardlist case change is what we expected while other change are flaky tests. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM. 🚀
During the review and discussion, we also identified some other improvements outside of this PR scope. Will improve that in other PRs.
This PR mainly focuses on changes in the "row" layout scenario on desktop:
Remember to include the following changes:
[Clover-123][BpkButton] Updating the colour
README.md
(If you have created a new component)README.md