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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions examples/bpk-component-card-list/examples.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,6 @@

@use '../../packages/unstable__bpk-mixins/tokens';

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

👍

.bpk-destination {
img {
border-radius: tokens.$bpk-border-radius-md tokens.$bpk-border-radius-md 0 0;
Expand Down
2 changes: 0 additions & 2 deletions examples/bpk-component-card-list/examples.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

accessibilityLabels: {
indicatorLabel: 'Go to slide',
prevNavLabel: 'Previous slide',
Expand All @@ -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.

<BpkCard
key={`card-${i}`}
className={STYLES['bpk-card']}
Expand Down
5 changes: 5 additions & 0 deletions packages/bpk-component-card-list/src/BpkCardList.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,17 @@
*/

@use '../../unstable__bpk-mixins/tokens';
@use '../../unstable__bpk-mixins/breakpoints';

.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()

}

&--card-list {
display: flex;
flex-direction: column;
Expand Down
7 changes: 4 additions & 3 deletions packages/bpk-component-card-list/src/BpkCardList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.


const BpkCardList = (props: CardListProps) => {
const {
Expand All @@ -47,8 +48,8 @@ const BpkCardList = (props: CardListProps) => {
chipGroup,
description,
expandText,
initiallyShownCardsDesktop = DEFAULT_ITEMS,
initiallyShownCardsMobile = DEFAULT_ITEMS,
initiallyShownCardsDesktop = DEFAULT_ITEMS_DESKTOP,
initiallyShownCardsMobile = DEFAULT_ITEMS_MOBILE,
layoutDesktop,
layoutMobile,
onButtonClick,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import BpkCardListCarousel from './BpkCardListCarousel';

jest.mock('./utils', () => ({
setA11yTabIndex: jest.fn(),
useUpdateCurrentIndexByVisibility: jest.fn(),
useScrollToCard: jest.fn(),
useIntersectionObserver: jest.fn(() => jest.fn()),
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,27 +17,33 @@
*/

@use '../../../unstable__bpk-mixins/tokens';
@use '../../../unstable__bpk-mixins/breakpoints';
@use '../../../unstable__bpk-mixins/utils';

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

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.

}

&::-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

flex: 0 0
calc(
(
Expand All @@ -52,6 +58,30 @@
overflow: visible;
box-sizing: border-box;
scroll-snap-align: start;

@include breakpoints.bpk-breakpoint-mobile {
// On mobile, if there is more than one card in the viewport, the last card only shows partially, so subtract 0.8 when do the calculation
flex: 0 0
calc(
(
100% -
(
tokens.bpk-spacing-md() *
(var(--initially-shown-cards, 3) - 1)
)
) /
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


&:first-child {
padding-left: tokens.bpk-spacing-sm();

@include utils.bpk-rtl {
padding-right: tokens.bpk-spacing-sm();
padding-left: tokens.bpk-spacing-md();
}
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,23 @@
* limitations under the License.
*/
import type { CSSProperties } from 'react';
import { useRef, useState, useEffect, isValidElement, Children } from 'react';
import {
useRef,
useState,
useEffect,
isValidElement,
Children,
useMemo,
} from 'react';

import throttle from 'lodash/throttle';

import { cssModules } from '../../../bpk-react-utils';

import { RENDER_BUFFER_SIZE } from './constants';
import {
lockScroll,
setA11yTabIndex,
useUpdateCurrentIndexByVisibility,
useScrollToCard,
useIntersectionObserver,
} from './utils';
Expand All @@ -44,7 +52,6 @@ const BpkCardListCarousel = (props: CardListCarouselProps) => {
initiallyShownCards,
isMobile = false,
layout,
setCurrentIndex,
slideLabel = (index: number, childrenLength: number) =>
`slide ${index + 1} of ${childrenLength}`,
} = props;
Expand All @@ -59,7 +66,11 @@ const BpkCardListCarousel = (props: CardListCarouselProps) => {

const childrenLength = Children.count(children);
const [root, setRoot] = useState<HTMLElement | null>(null);
const [, forceUpdate] = useState(0);
const cardRefs = useRef<Array<HTMLDivElement | null>>([]);
const hasBeenVisibleRef = useRef<Set<number>>(new Set());
const firstCardWidthRef = useRef<number | null>(null);
const firstCardHeightRef = useRef<number | null>(null);

const [visibilityList, setVisibilityList] = useState<number[]>(
Array(childrenLength).fill(0),
Expand All @@ -73,6 +84,56 @@ const BpkCardListCarousel = (props: CardListCarouselProps) => {
setVisibilityList,
);

useScrollToCard(
currentIndex * initiallyShownCards,
root,
cardRefs,
stateScrollingLockRef,
);

// Similar to Virtual Scrolling to improve performance
const firstVisibleIndex = Math.max(0, visibilityList.indexOf(1));
const lastVisibleIndex = firstVisibleIndex + initiallyShownCards - 1;
const renderList = useMemo(
() =>
visibilityList.map((_, index) =>
index >= firstVisibleIndex - RENDER_BUFFER_SIZE &&
index <= lastVisibleIndex + RENDER_BUFFER_SIZE
? 1
: 0,
),
[visibilityList, firstVisibleIndex, lastVisibleIndex],
);

const cardRefFns = useMemo(
() =>
Array(childrenLength)
.fill(null)
.map((_, i) => (el: HTMLDivElement | null) => {
cardRefs.current[i] = el;
observerVisibility(el, i);
setA11yTabIndex(el, i, visibilityList);
// record the first card's width and height when it becomes visible
if (el && visibilityList[i] === 0) {
hasBeenVisibleRef.current.add(i);
if (firstCardWidthRef.current == null && el.offsetWidth) {
firstCardWidthRef.current = el.offsetWidth;
}
if (firstCardHeightRef.current == null && el.offsetHeight) {
firstCardHeightRef.current = el.offsetHeight;
}
}
}),
[
childrenLength,
observerVisibility,
visibilityList,
hasBeenVisibleRef,
firstCardWidthRef,
firstCardHeightRef,
],
);

useEffect(() => {
const container = root;
if (isMobile || !container) return undefined;
Expand All @@ -93,25 +154,25 @@ const BpkCardListCarousel = (props: CardListCarouselProps) => {
};
}, [root]);

useUpdateCurrentIndexByVisibility(
isMobile,
visibilityList,
setCurrentIndex,
stateScrollingLockRef,
openSetStateLockTimeoutRef,
);
useEffect(() => {
// update hasBeenVisibleRef to include the range of cards that should be visible
const start = currentIndex * initiallyShownCards;
const end = start + initiallyShownCards + RENDER_BUFFER_SIZE;
for (let i = start; i < end && i < childrenLength; i += 1) {
hasBeenVisibleRef.current.add(i);
}
}, [currentIndex, initiallyShownCards, childrenLength]);

useScrollToCard(currentIndex, root, cardRefs, stateScrollingLockRef);
useEffect(() => {
const handleResize = throttle(() => {
firstCardWidthRef.current = null;
firstCardHeightRef.current = null;
forceUpdate((n) => n + 1);
}, 200);

// Similar to Virtual Scrolling to improve performance
const firstVisibleIndex = Math.max(0, visibilityList.indexOf(1));
const lastVisibleIndex = firstVisibleIndex + initiallyShownCards - 1;
const renderList = visibilityList.map((_, index) =>
index >= firstVisibleIndex - RENDER_BUFFER_SIZE &&
index <= lastVisibleIndex + RENDER_BUFFER_SIZE
? 1
: 0,
);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);

return (
<div
Expand All @@ -127,30 +188,43 @@ const BpkCardListCarousel = (props: CardListCarouselProps) => {
{children.map((card, index) => {
if (!isValidElement(card)) return null;

const cardRefCallback = (el: HTMLDivElement | null) => {
cardRefs.current[index] = el;
observerVisibility(el, index);
setA11yTabIndex(el, index, visibilityList);
};

const cardStyle: CSSProperties = isMobile
? {
...shownNumberStyle,
visibility: renderList[index] === 1 ? 'visible' : 'hidden', // for mobile, {renderList[index] === 1 && card} will cause reflowing and bugs, implementing visibility improvement instead
}
: shownNumberStyle;
const cardDimensionStyle: CSSProperties = {};
if (firstCardWidthRef.current) {
cardDimensionStyle.width = `${firstCardWidthRef.current}px`;
}
if (firstCardHeightRef.current) {
cardDimensionStyle.height = `${firstCardHeightRef.current}px`;
}

// 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.

return (
<div
key={`placeholder-${index.toString()}`}
style={{
...shownNumberStyle,
...cardDimensionStyle,
flexShrink: 0,
visibility: 'hidden',
}}
aria-hidden="true"
/>
);
}

return (
<div
className={getClassName(`bpk-card-list-row-rail__${layout}__card`)}
ref={cardRefCallback}
style={cardStyle}
ref={cardRefFns[index]}
style={{
...shownNumberStyle,
...cardDimensionStyle,
}}
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

>
{isMobile ? card : renderList[index] === 1 && card}
{card}
</div>
);
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ const BpkCardListRowRailContainer = (props: CardListRowRailProps) => {
layout,
} = props;

const totalIndicators = Children.count(children);
const childrenCount = Children.count(children);
const totalIndicators = Math.ceil(childrenCount / initiallyShownCards);
const showAccessory = childrenCount > initiallyShownCards;

const [currentIndex, setCurrentIndex] = useState(0);
const showAccessory = totalIndicators > initiallyShownCards;

const accessoryContent =
layout === LAYOUTS.row &&
Expand All @@ -66,7 +68,6 @@ const BpkCardListRowRailContainer = (props: CardListRowRailProps) => {
initiallyShownCards={initiallyShownCards}
layout={layout}
currentIndex={currentIndex}
setCurrentIndex={setCurrentIndex}
isMobile={isMobile}
carouselLabel={accessibilityLabels?.carouselLabel}
slideLabel={accessibilityLabels?.slideLabel}
Expand Down
Loading
Loading