Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
48f8df7
Update css on product card to allow for aligning separate cards using…
oliverabrahams Dec 19, 2025
59030e6
Allow the ScrollableCarousel.tsx to define card width by a fraction o…
oliverabrahams Dec 19, 2025
a064393
Add scrollable carousel for product cards and story this is for a ful…
oliverabrahams Dec 19, 2025
d8ff3e6
fix miss naming of story
oliverabrahams Dec 22, 2025
a3497bc
Merge branch 'main' into mob/scrollable-products
oliverabrahams Jan 5, 2026
bc1c9bc
Merge branch 'main' into mob/scrollable-products
oliverabrahams Jan 5, 2026
a4ce1d7
merge main
oliverabrahams Jan 7, 2026
b8a28a0
revert changes to pnpm-lock
oliverabrahams Jan 7, 2026
ae0ebf9
rename kind to be `frontCarousel` and `productCarousel`. This makes i…
oliverabrahams Jan 9, 2026
f8ffc42
Move the subgrid out of the ScrollableCarousel.tsx and into the Scrol…
oliverabrahams Jan 9, 2026
5e75ea2
fix storybook to add the primaryHeadingText to the fixture
oliverabrahams Jan 9, 2026
59077dd
refactor name of front carousel styles prop
oliverabrahams Jan 9, 2026
8348a10
rename h2Id to headingId
oliverabrahams Jan 9, 2026
f03528f
split product carousel into its own component
oliverabrahams Jan 14, 2026
4659f21
make no changes to the ScrollableCarousel.tsx
oliverabrahams Jan 14, 2026
3a4363c
use link element from the DS
oliverabrahams Jan 14, 2026
283e495
remove product carousel component and add it to ScrollableProduct.imp…
oliverabrahams Jan 14, 2026
e00530d
Add navigation buttons to the product carousel
oliverabrahams Jan 15, 2026
bec4a5a
remove interaction stories
oliverabrahams Jan 15, 2026
57128f0
Merge branch 'main' into oa/navigation-arrows
oliverabrahams Jan 19, 2026
3cbeb24
merge main
oliverabrahams Jan 19, 2026
e24729e
add comment to say which logic is copied
oliverabrahams Jan 19, 2026
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
152 changes: 130 additions & 22 deletions dotcom-rendering/src/components/ScrollableProduct.importable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,23 @@ import type { SerializedStyles } from '@emotion/react';
import { css } from '@emotion/react';
import type { Breakpoint } from '@guardian/source/foundations';
import { from, space } from '@guardian/source/foundations';
import { useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { ArticleFormat } from '../lib/articleFormat';
import { nestedOphanComponents } from '../lib/ophan-helpers';
import { palette } from '../palette';
import type { ProductBlockElement } from '../types/content';
import { CarouselNavigationButtons } from './CarouselNavigationButtons';
import { ProductCarouselCard } from './ProductCarouselCard';
import { Subheading } from './Subheading';

const carouselHeader = css`
padding-bottom: 10px;
padding-top: ${space[6]}px;
border-bottom: 1px solid ${palette('--card-border-supporting')};
margin-bottom: 10px;
display: flex;
justify-content: space-between;
`;

export type FixedSlideWidth = {
defaultWidth: number;
Expand All @@ -21,6 +33,9 @@ type Props = {
const baseContainerStyles = css`
position: relative;
`;
const navigationStyles = css`
padding-bottom: ${space[1]}px;
`;

const subgridStyles = css`
scroll-snap-align: start;
Expand All @@ -41,7 +56,7 @@ const leftBorderStyles = css`
bottom: 0;
left: -10px;
width: 1px;
background-color: ${palette('--card-border-top')};
background-color: ${palette('--card-border-supporting')};
transform: translateX(-50%);
}
`;
Expand Down Expand Up @@ -90,6 +105,7 @@ const generateFixedWidthColumStyles = ({
}
return fixedWidths;
};

/**
* This product carousel has some functionality copied from the ScrollableCarousel.
* As this is part of an A/B/C test, we have decided to copy this functionality so
Expand All @@ -98,6 +114,9 @@ const generateFixedWidthColumStyles = ({
*/
export const ScrollableProduct = ({ products, format }: Props) => {
const carouselRef = useRef<HTMLOListElement | null>(null);
const [previousButtonEnabled, setPreviousButtonEnabled] = useState(false);
const [nextButtonEnabled, setNextButtonEnabled] = useState(true);

const carouselLength = products.length;
const fixedSlideWidth: FixedSlideWidth = {
defaultWidth: 240,
Expand Down Expand Up @@ -129,6 +148,46 @@ export const ScrollableProduct = ({ products, format }: Props) => {
});
};

/**
* --- COPIED FROM ScrollableCarousel ---
* Throttle scroll events to optimise performance. As we're only using this
* to toggle button state as the carousel is scrolled we don't need to
* handle every event. This function ensures the callback is only called
* once every 200ms, no matter how many scroll events are fired.
*/
const throttleEvent = (callback: () => void) => {
let isThrottled: boolean = false;
return function () {
if (!isThrottled) {
callback();
isThrottled = true;
setTimeout(() => (isThrottled = false), 200);
}
};
};

/**
* --- COPIED FROM ScrollableCarousel ---
*
* Updates state of navigation buttons based on carousel's scroll position.
* This function checks the current scroll position of the carousel and sets
* the styles of the previous and next buttons accordingly. The button state
* is toggled when the midpoint of the first or last card has been scrolled
* in or out of view.
*/
const updateButtonVisibilityOnScroll = () => {
const carouselElement = carouselRef.current;
if (!carouselElement) return;

const scrollLeft = carouselElement.scrollLeft;
const maxScrollLeft =
carouselElement.scrollWidth - carouselElement.clientWidth;
const cardWidth = carouselElement.querySelector('li')?.offsetWidth ?? 0;

setPreviousButtonEnabled(scrollLeft > cardWidth / 2);
setNextButtonEnabled(scrollLeft < maxScrollLeft - cardWidth / 2);
};

/**
* --- COPIED FROM ScrollableCarousel ---
* Scrolls the carousel to a certain position when a card gains focus.
Expand Down Expand Up @@ -184,26 +243,75 @@ export const ScrollableProduct = ({ products, format }: Props) => {
}
};

useEffect(() => {
const carouselElement = carouselRef.current;
if (!carouselElement) return;

carouselElement.addEventListener(
'scroll',
throttleEvent(updateButtonVisibilityOnScroll),
);

return () => {
carouselElement.removeEventListener(
'scroll',
throttleEvent(updateButtonVisibilityOnScroll),
);
};
}, []);

return (
<div css={[baseContainerStyles]}>
<ol
ref={carouselRef}
css={carouselStyles}
data-heatphan-type="carousel"
onFocus={scrollToCardOnFocus}
>
{products.map((product: ProductBlockElement) => (
<li
key={product.productCtas[0]?.url ?? product.elementId}
css={[subgridStyles, leftBorderStyles]}
>
<ProductCarouselCard
product={product}
format={format}
/>
</li>
))}
</ol>
</div>
<>
<div css={carouselHeader}>
<Subheading
format={format}
id={'at-a-glance'}
topPadding={false}
>
At a glance
</Subheading>
<div
css={navigationStyles}
id={'at-a-glance-carousel-navigation'}
></div>
</div>
<div css={[baseContainerStyles]}>
<ol
ref={carouselRef}
css={carouselStyles}
data-heatphan-type="carousel"
onFocus={scrollToCardOnFocus}
>
{products.map((product: ProductBlockElement) => (
<li
key={
product.productCtas[0]?.url ?? product.elementId
}
css={[subgridStyles, leftBorderStyles]}
>
<ProductCarouselCard
product={product}
format={format}
/>
</li>
))}
</ol>
<CarouselNavigationButtons
previousButtonEnabled={previousButtonEnabled}
nextButtonEnabled={nextButtonEnabled}
onClickPreviousButton={() => scrollTo('left')}
onClickNextButton={() => scrollTo('right')}
sectionId={'at-a-glance'}
dataLinkNamePreviousButton={nestedOphanComponents(
'carousel',
'previous-button',
)}
dataLinkNameNextButton={nestedOphanComponents(
'carousel',
'next-button',
)}
/>
</div>
</>
);
};
25 changes: 21 additions & 4 deletions dotcom-rendering/src/components/ScrollableProducts.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,32 @@ const meta = {
},
args: {
products: [
{ ...exampleProduct, h2Id: 'product' },
{
...exampleProduct,
primaryHeadingHtml: '<em>Product 0</em>',
h2Id: 'product',
},
{
...exampleProduct,
primaryHeadingHtml: '<em>Product 1</em>',
h2Id: 'product-1',
productName: 'Lorem ipsum dolor sit amet',
},
{ ...exampleProduct, h2Id: 'product-2' },
{ ...exampleProduct, h2Id: 'product-3' },
{ ...exampleProduct, h2Id: 'product-4' },
{
...exampleProduct,
primaryHeadingHtml: '<em>Product 2</em>',
h2Id: 'product-2',
},
{
...exampleProduct,
primaryHeadingHtml: '<em>Product 3</em>',
h2Id: 'product-3',
},
{
...exampleProduct,
primaryHeadingHtml: '<em>Product 4</em>',
h2Id: 'product-4',
},
],
format: {
design: ArticleDesign.Review,
Expand Down
Loading