Skip to content

Commit e00530d

Browse files
Add navigation buttons to the product carousel
1 parent 283e495 commit e00530d

File tree

3 files changed

+170
-26
lines changed

3 files changed

+170
-26
lines changed

dotcom-rendering/src/components/CarouselNavigationButtons.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export const CarouselNavigationButtons = ({
105105
aria-label="previous"
106106
value="previous"
107107
data-link-name={dataLinkNamePreviousButton}
108+
data-testid="carousel-back-button"
108109
/>
109110

110111
<Button
@@ -119,6 +120,7 @@ export const CarouselNavigationButtons = ({
119120
aria-label="next"
120121
value="next"
121122
data-link-name={dataLinkNameNextButton}
123+
data-testid="carousel-forward-button"
122124
/>
123125
</div>,
124126
portalNode,

dotcom-rendering/src/components/ScrollableProduct.importable.tsx

Lines changed: 127 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,23 @@ import type { SerializedStyles } from '@emotion/react';
22
import { css } from '@emotion/react';
33
import type { Breakpoint } from '@guardian/source/foundations';
44
import { from, space } from '@guardian/source/foundations';
5-
import { useRef } from 'react';
5+
import { useEffect, useRef, useState } from 'react';
66
import type { ArticleFormat } from '../lib/articleFormat';
7+
import { nestedOphanComponents } from '../lib/ophan-helpers';
78
import { palette } from '../palette';
89
import type { ProductBlockElement } from '../types/content';
10+
import { CarouselNavigationButtons } from './CarouselNavigationButtons';
911
import { ProductCarouselCard } from './ProductCarouselCard';
12+
import { Subheading } from './Subheading';
13+
14+
const carouselHeader = css`
15+
padding-bottom: 10px;
16+
padding-top: ${space[6]}px;
17+
border-bottom: 1px solid ${palette('--card-border-supporting')};
18+
margin-bottom: 10px;
19+
display: flex;
20+
justify-content: space-between;
21+
`;
1022

1123
export type FixedSlideWidth = {
1224
defaultWidth: number;
@@ -21,6 +33,9 @@ type Props = {
2133
const baseContainerStyles = css`
2234
position: relative;
2335
`;
36+
const navigationStyles = css`
37+
padding-bottom: ${space[1]}px;
38+
`;
2439

2540
const subgridStyles = css`
2641
scroll-snap-align: start;
@@ -41,7 +56,7 @@ const leftBorderStyles = css`
4156
bottom: 0;
4257
left: -10px;
4358
width: 1px;
44-
background-color: ${palette('--card-border-top')};
59+
background-color: ${palette('--card-border-supporting')};
4560
transform: translateX(-50%);
4661
}
4762
`;
@@ -93,6 +108,9 @@ const generateFixedWidthColumStyles = ({
93108

94109
export const ScrollableProduct = ({ products, format }: Props) => {
95110
const carouselRef = useRef<HTMLOListElement | null>(null);
111+
const [previousButtonEnabled, setPreviousButtonEnabled] = useState(false);
112+
const [nextButtonEnabled, setNextButtonEnabled] = useState(true);
113+
96114
const carouselLength = products.length;
97115
const fixedSlideWidth: FixedSlideWidth = {
98116
defaultWidth: 240,
@@ -122,6 +140,44 @@ export const ScrollableProduct = ({ products, format }: Props) => {
122140
});
123141
};
124142

143+
/**
144+
* Throttle scroll events to optimise performance. As we're only using this
145+
* to toggle button state as the carousel is scrolled we don't need to
146+
* handle every event. This function ensures the callback is only called
147+
* once every 200ms, no matter how many scroll events are fired.
148+
*/
149+
const throttleEvent = (callback: () => void) => {
150+
let isThrottled: boolean = false;
151+
return function () {
152+
if (!isThrottled) {
153+
callback();
154+
isThrottled = true;
155+
setTimeout(() => (isThrottled = false), 200);
156+
}
157+
};
158+
};
159+
160+
/**
161+
* Updates state of navigation buttons based on carousel's scroll position.
162+
*
163+
* This function checks the current scroll position of the carousel and sets
164+
* the styles of the previous and next buttons accordingly. The button state
165+
* is toggled when the midpoint of the first or last card has been scrolled
166+
* in or out of view.
167+
*/
168+
const updateButtonVisibilityOnScroll = () => {
169+
const carouselElement = carouselRef.current;
170+
if (!carouselElement) return;
171+
172+
const scrollLeft = carouselElement.scrollLeft;
173+
const maxScrollLeft =
174+
carouselElement.scrollWidth - carouselElement.clientWidth;
175+
const cardWidth = carouselElement.querySelector('li')?.offsetWidth ?? 0;
176+
177+
setPreviousButtonEnabled(scrollLeft > cardWidth / 2);
178+
setNextButtonEnabled(scrollLeft < maxScrollLeft - cardWidth / 2);
179+
};
180+
125181
/**
126182
* Scrolls the carousel to a certain position when a card gains focus.
127183
*
@@ -176,26 +232,75 @@ export const ScrollableProduct = ({ products, format }: Props) => {
176232
}
177233
};
178234

235+
useEffect(() => {
236+
const carouselElement = carouselRef.current;
237+
if (!carouselElement) return;
238+
239+
carouselElement.addEventListener(
240+
'scroll',
241+
throttleEvent(updateButtonVisibilityOnScroll),
242+
);
243+
244+
return () => {
245+
carouselElement.removeEventListener(
246+
'scroll',
247+
throttleEvent(updateButtonVisibilityOnScroll),
248+
);
249+
};
250+
}, []);
251+
179252
return (
180-
<div css={[baseContainerStyles]}>
181-
<ol
182-
ref={carouselRef}
183-
css={carouselStyles}
184-
data-heatphan-type="carousel"
185-
onFocus={scrollToCardOnFocus}
186-
>
187-
{products.map((product: ProductBlockElement) => (
188-
<li
189-
key={product.productCtas[0]?.url ?? product.elementId}
190-
css={[subgridStyles, leftBorderStyles]}
191-
>
192-
<ProductCarouselCard
193-
product={product}
194-
format={format}
195-
/>
196-
</li>
197-
))}
198-
</ol>
199-
</div>
253+
<>
254+
<div css={carouselHeader}>
255+
<Subheading
256+
format={format}
257+
id={'at-a-glance'}
258+
topPadding={false}
259+
>
260+
At a glance
261+
</Subheading>
262+
<div
263+
css={navigationStyles}
264+
id={'at-a-glance-carousel-navigation'}
265+
></div>
266+
</div>
267+
<div css={[baseContainerStyles]}>
268+
<ol
269+
ref={carouselRef}
270+
css={carouselStyles}
271+
data-heatphan-type="carousel"
272+
onFocus={scrollToCardOnFocus}
273+
>
274+
{products.map((product: ProductBlockElement) => (
275+
<li
276+
key={
277+
product.productCtas[0]?.url ?? product.elementId
278+
}
279+
css={[subgridStyles, leftBorderStyles]}
280+
>
281+
<ProductCarouselCard
282+
product={product}
283+
format={format}
284+
/>
285+
</li>
286+
))}
287+
</ol>
288+
<CarouselNavigationButtons
289+
previousButtonEnabled={previousButtonEnabled}
290+
nextButtonEnabled={nextButtonEnabled}
291+
onClickPreviousButton={() => scrollTo('left')}
292+
onClickNextButton={() => scrollTo('right')}
293+
sectionId={'at-a-glance'}
294+
dataLinkNamePreviousButton={nestedOphanComponents(
295+
'carousel',
296+
'previous-button',
297+
)}
298+
dataLinkNameNextButton={nestedOphanComponents(
299+
'carousel',
300+
'next-button',
301+
)}
302+
/>
303+
</div>
304+
</>
200305
);
201306
};

dotcom-rendering/src/components/ScrollableProducts.stories.tsx

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,39 @@ const meta = {
1515
breakpoints.tablet,
1616
breakpoints.wide,
1717
],
18+
// Because this component uses react.createPortal
19+
// we delay to give the portals time to hydrate
20+
delay: 500,
1821
},
1922
},
2023
args: {
2124
products: [
22-
{ ...exampleProduct, h2Id: 'product' },
2325
{
2426
...exampleProduct,
27+
primaryHeadingHtml: '<em>Product 0</em>',
28+
h2Id: 'product',
29+
},
30+
{
31+
...exampleProduct,
32+
primaryHeadingHtml: '<em>Product 1</em>',
2533
h2Id: 'product-1',
2634
productName: 'Lorem ipsum dolor sit amet',
2735
},
28-
{ ...exampleProduct, h2Id: 'product-2' },
29-
{ ...exampleProduct, h2Id: 'product-3' },
30-
{ ...exampleProduct, h2Id: 'product-4' },
36+
{
37+
...exampleProduct,
38+
primaryHeadingHtml: '<em>Product 2</em>',
39+
h2Id: 'product-2',
40+
},
41+
{
42+
...exampleProduct,
43+
primaryHeadingHtml: '<em>Product 3</em>',
44+
h2Id: 'product-3',
45+
},
46+
{
47+
...exampleProduct,
48+
primaryHeadingHtml: '<em>Product 4</em>',
49+
h2Id: 'product-4',
50+
},
3151
],
3252
format: {
3353
design: ArticleDesign.Review,
@@ -78,3 +98,20 @@ export default meta;
7898
type Story = StoryObj<typeof meta>;
7999

80100
export const With5Cards = {} satisfies Story;
101+
102+
export const ClickForwardArrow = {
103+
play: async ({ canvas, userEvent }) => {
104+
await userEvent.click(canvas.getByTestId('carousel-forward-button'));
105+
},
106+
} satisfies Story;
107+
108+
export const ClickForwardTwiceAndBackOnce = {
109+
play: async ({ canvas, userEvent }) => {
110+
const user = userEvent.setup({
111+
delay: 500, // delay between each interaction
112+
});
113+
await user.click(canvas.getByTestId('carousel-forward-button'));
114+
await user.click(canvas.getByTestId('carousel-forward-button'));
115+
await user.click(canvas.getByTestId('carousel-back-button'));
116+
},
117+
} satisfies Story;

0 commit comments

Comments
 (0)