Skip to content

[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

Merged
merged 18 commits into from
Jul 21, 2025

Conversation

xiaogliu
Copy link
Contributor

@xiaogliu xiaogliu commented Jul 11, 2025

This PR mainly focuses on changes in the "row" layout scenario on desktop:

  1. Click and slide behavior: Originally, clicking the next page would jump to one card. After the modification, it will jump to a whole set of cards (the specific number depends on initiallyShownCards).
  2. Card rendering: Before the modification, mobile would render all cards at once, while desktop only rendered the cards in the current view. After the modification, the rendering behavior of mobile and desktop is consistent: only render the current view + RENDER_BUFFER + already rendered cards.
  3. Fix the carousel shakes issue
  4. The style has been fine-tuned according to the design, and the specific modifications have been pasted into the PR code.

Remember to include the following changes:

  • Ensure the PR title includes the name of the component you are changing so it's clear in the release notes for consumers of the changes in the version e.g [Clover-123][BpkButton] Updating the colour
  • README.md (If you have created a new component)
  • Component README.md
  • Tests
  • Accessibility tests
    • The following checks were performed:
      • Ability to navigate using a keyboard only
      • Zoom functionality (Deque University explanation):
        • The page SHOULD be functional AND readable when only the text is magnified to 200% of its initial size
        • Pages must reflow as zoom increases up to 400% so that content continues to be presented in only one column i.e. Content MUST NOT require scrolling in two directions (both vertically and horizontally)
      • Ability to navigate using a screen reader only
  • Storybook examples created/updated
  • For breaking changes or deprecating components/properties, migration guides added to the description of the PR. If the guide has large changes, consider creating a new Markdown page inside the component's docs folder and link it here

@xiaogliu xiaogliu requested a review from Copilot July 11, 2025 09:04
Copy link
Contributor

@Copilot Copilot AI left a 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-adding aria-current="true" on the card when it matches currentIndex.
            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 (the renderList 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';
Copy link
Preview

Copilot AI Jul 11, 2025

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.

Suggested change
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;
Copy link
Preview

Copilot AI Jul 11, 2025

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.

Suggested change
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.

@skyscanner-backpack-bot
Copy link

Visit https://backpack.github.io/storybook-prs/3878 to see this build running in a browser.

@skyscanner-backpack-bot
Copy link

skyscanner-backpack-bot bot commented Jul 11, 2025

Warnings
⚠️

Package source files (e.g. packages/package-name/src/Component.js) were updated, but snapshots weren't. Have you checked that the tests still pass?

Browser support

If this is a visual change, make sure you've tested it in multiple browsers.

Generated by 🚫 dangerJS against 9f8b1c0

@@ -152,7 +151,6 @@ const snippetProps = {
};

const DestinationCard = (i: number) => (
// Usage Suggestion: define minWidth from consumer side by using className
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Card width is determined by the number of cards set to be displayed (desktop, mobile), so the minWidth cannot be specified, otherwise it will conflict with initiallyShownCardsDesktop/initiallyShownCardsMobile.


.bpk-card-list {
display: flex;
flex-direction: column;
gap: tokens.bpk-spacing-lg();

@include breakpoints.bpk-breakpoint-mobile {
gap: tokens.bpk-spacing-base();
Copy link
Contributor Author

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;
Copy link
Contributor Author

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;
Copy link
Contributor Author

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();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's parent element already set padding top/bottom, if add padding top/bottom here, will cause spacing much big than design.
image


@include breakpoints.bpk-breakpoint-mobile {
&:first-child {
padding-left: tokens.bpk-spacing-sm();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On mobile, the first card should be as close as possible to monetization. Considering the shadow, it cannot be set to 0 completely.
image

(var(--initially-shown-cards, 3) - 1)
)
) /
(var(--initially-shown-cards, 3) - 0.5)
Copy link
Contributor Author

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
Copy link
Contributor Author

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)) {
Copy link
Contributor Author

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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@xiaogliu xiaogliu added the minor Non breaking change label Jul 14, 2025
@skyscanner-backpack-bot
Copy link

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;
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@@ -69,10 +73,44 @@ const BpkCardListCarousel = (props: CardListCarouselProps) => {
const openSetStateLockTimeoutRef = useRef<NodeJS.Timeout | null>(null);

const observerVisibility = useIntersectionObserver(
Copy link
Contributor

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? 🙇‍♀️

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;
};

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated in 153ff48

: 0,
);

const cardRefCallback = (index: number) => (el: HTMLDivElement | null) => {
Copy link
Contributor

@Faye-Xiao Faye-Xiao Jul 15, 2025

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.

Copy link
Contributor Author

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>>;
Copy link
Contributor

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.

Copy link
Contributor Author

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.

image

Copy link
Contributor

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'}
Copy link
Contributor

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? 🙇‍♀️

Copy link
Contributor Author

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.

Copy link
Contributor

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

@skyscanner-backpack-bot
Copy link

Visit https://backpack.github.io/storybook-prs/3878 to see this build running in a browser.

@skyscanner-backpack-bot
Copy link

Visit https://backpack.github.io/storybook-prs/3878 to see this build running in a browser.

1 similar comment
@skyscanner-backpack-bot
Copy link

Visit https://backpack.github.io/storybook-prs/3878 to see this build running in a browser.

@Faye-Xiao
Copy link
Contributor

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?

Screenshot 2025-07-17 at 18 39 29 Screenshot 2025-07-17 at 18 41 45 Screenshot 2025-07-17 at 18 41 56

@Faye-Xiao
Copy link
Contributor

Also found another issue. The cards in the third-to-last cardlist in the multi-components-scrolling-test on mobile breakpoints(small and larger mobile, look good on tablet) use case have a larger width. 🤔

  • Production existing use case
cardlist-production.mov
  • Use case in this PR
cardlist-pr.mov

@@ -129,7 +129,6 @@ const commonProps = {
),
buttonContent: 'See more',
buttonHref: 'https://www.skyscanner.net/',
initiallyShownCardsMobile: 2,
Copy link
Contributor

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}

Copy link
Contributor Author

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}
this case, we want to test different initial cards so still keep it.
image

@skyscanner-backpack-bot
Copy link

Visit https://backpack.github.io/storybook-prs/3878 to see this build running in a browser.

@xiaogliu
Copy link
Contributor Author

xiaogliu commented Jul 18, 2025

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?

Screenshot 2025-07-17 at 18 39 29 Screenshot 2025-07-17 at 18 41 45 Screenshot 2025-07-17 at 18 41 56

Hey @Faye-Xiao This comment contains two issues:

  1. The content displayed on the last card in the mobile visible area has become larger, which is as expected. Below is the design.
    Before the change, we could only be hardcoded, so you maybe see different width for last in different screen.
    After change, I calculated with a ratio of 100. Currently, I've set it to 0.5, but it would be more appropriate to change it to 0.8 (the width of the last card shown is less than 50%, so it can only be estimated)
image
  1. When there is only one mobile card, its width becomes larger. This was a bug and has been fixed, if only one card in the viewport, set the width be 100%.

@skyscanner-backpack-bot
Copy link

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))
);
Copy link
Contributor

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? 🙇‍♀️

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

@skyscanner-backpack-bot
Copy link

Visit https://backpack.github.io/storybook-prs/3878 to see this build running in a browser.

@skyscanner-backpack-bot
Copy link

Visit https://backpack.github.io/storybook-prs/3878 to see this build running in a browser.

@Faye-Xiao
Copy link
Contributor

Approved the Percy failure as the cardlist case change is what we expected while other change are flaky tests.

Copy link
Contributor

@Faye-Xiao Faye-Xiao left a 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.

@xiaogliu xiaogliu merged commit af8490c into main Jul 21, 2025
10 checks passed
@xiaogliu xiaogliu deleted the fix-cardlist-issue branch July 21, 2025 06:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
minor Non breaking change
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants