Skip to content

Commit ad7f3b4

Browse files
add carousel count for mobile
1 parent bec4a5a commit ad7f3b4

File tree

2 files changed

+109
-29
lines changed

2 files changed

+109
-29
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useEffect, useState } from 'react';
2+
import { createPortal } from 'react-dom';
3+
4+
export const CarouselCount = ({
5+
sectionId,
6+
count,
7+
total,
8+
}: {
9+
sectionId: string;
10+
count: number;
11+
total: number;
12+
}) => {
13+
const [portalNode, setPortalNode] = useState<HTMLElement | null>(null);
14+
15+
useEffect(() => {
16+
const node = document.getElementById(`${sectionId}-carousel-count`);
17+
if (!node) {
18+
console.warn(
19+
`Portal node with ID "${sectionId}-carousel-count" not found.`,
20+
);
21+
}
22+
setPortalNode(node);
23+
}, [sectionId]);
24+
25+
if (!portalNode) return null;
26+
27+
return createPortal(
28+
<div>
29+
{count} of {total}
30+
</div>,
31+
portalNode,
32+
);
33+
};

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

Lines changed: 76 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import type { SerializedStyles } from '@emotion/react';
22
import { css } from '@emotion/react';
33
import type { Breakpoint } from '@guardian/source/foundations';
4-
import { from, space } from '@guardian/source/foundations';
5-
import { useEffect, useRef, useState } from 'react';
4+
import { from, space, textSans14 } from '@guardian/source/foundations';
5+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
66
import type { ArticleFormat } from '../lib/articleFormat';
77
import { nestedOphanComponents } from '../lib/ophan-helpers';
88
import { palette } from '../palette';
99
import type { ProductBlockElement } from '../types/content';
10+
import { CarouselCount } from './CarouselCount';
1011
import { CarouselNavigationButtons } from './CarouselNavigationButtons';
1112
import { ProductCarouselCard } from './ProductCarouselCard';
1213
import { Subheading } from './Subheading';
@@ -20,6 +21,15 @@ const carouselHeader = css`
2021
justify-content: space-between;
2122
`;
2223

24+
const countStyles = css`
25+
${textSans14};
26+
color: ${palette('--card-trail-text')};
27+
display: block;
28+
${from.tablet} {
29+
display: none;
30+
}
31+
`;
32+
2333
export type FixedSlideWidth = {
2434
defaultWidth: number;
2535
widthFromBreakpoints: { breakpoint: Breakpoint; width: number }[];
@@ -110,6 +120,7 @@ export const ScrollableProduct = ({ products, format }: Props) => {
110120
const carouselRef = useRef<HTMLOListElement | null>(null);
111121
const [previousButtonEnabled, setPreviousButtonEnabled] = useState(false);
112122
const [nextButtonEnabled, setNextButtonEnabled] = useState(true);
123+
const [cardCount, setCardCount] = useState(1);
113124

114125
const carouselLength = products.length;
115126
const fixedSlideWidth: FixedSlideWidth = {
@@ -157,27 +168,6 @@ export const ScrollableProduct = ({ products, format }: Props) => {
157168
};
158169
};
159170

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-
181171
/**
182172
* Scrolls the carousel to a certain position when a card gains focus.
183173
*
@@ -232,22 +222,73 @@ export const ScrollableProduct = ({ products, format }: Props) => {
232222
}
233223
};
234224

235-
useEffect(() => {
225+
/**
226+
* Update the count of the first card / how far scrolled the carousel is
227+
*
228+
* This function checks how far along the carousel is scrolled and then
229+
* updates the state of cardCount. we use the half of a card because at
230+
* this scroll amount the carousel will snap to that card.
231+
*/
232+
const updateCardCountOnScroll = useCallback(() => {
236233
const carouselElement = carouselRef.current;
237234
if (!carouselElement) return;
238235

239-
carouselElement.addEventListener(
240-
'scroll',
241-
throttleEvent(updateButtonVisibilityOnScroll),
236+
const cardWidth = carouselElement.querySelector('li')?.offsetWidth ?? 0;
237+
if (!cardWidth) return;
238+
239+
const count = Math.ceil(
240+
(carouselElement.scrollLeft + cardWidth / 2) / cardWidth,
242241
);
243242

243+
setCardCount((prev) => (prev === count ? prev : count));
244+
}, []);
245+
246+
/**
247+
* Updates state of navigation buttons based on carousel's scroll position.
248+
*
249+
* This function checks the current scroll position of the carousel and sets
250+
* the styles of the previous and next buttons accordingly. The button state
251+
* is toggled when the midpoint of the first or last card has been scrolled
252+
* in or out of view.
253+
*/
254+
const updateButtonVisibilityOnScroll = useCallback(() => {
255+
const carouselElement = carouselRef.current;
256+
if (!carouselElement) return;
257+
258+
const scrollLeft = carouselElement.scrollLeft;
259+
const maxScrollLeft =
260+
carouselElement.scrollWidth - carouselElement.clientWidth;
261+
const cardWidth = carouselElement.querySelector('li')?.offsetWidth ?? 0;
262+
263+
setPreviousButtonEnabled(scrollLeft > cardWidth / 2);
264+
setNextButtonEnabled(scrollLeft < maxScrollLeft - cardWidth / 2);
265+
}, []);
266+
267+
const throttledCardCount = useMemo(
268+
() => throttleEvent(updateCardCountOnScroll),
269+
[updateCardCountOnScroll],
270+
);
271+
272+
const throttledButtonVisibility = useMemo(
273+
() => throttleEvent(updateButtonVisibilityOnScroll),
274+
[updateButtonVisibilityOnScroll],
275+
);
276+
277+
useEffect(() => {
278+
const carouselElement = carouselRef.current;
279+
if (!carouselElement) return;
280+
281+
carouselElement.addEventListener('scroll', throttledCardCount);
282+
carouselElement.addEventListener('scroll', throttledButtonVisibility);
283+
244284
return () => {
285+
carouselElement.removeEventListener('scroll', throttledCardCount);
245286
carouselElement.removeEventListener(
246287
'scroll',
247-
throttleEvent(updateButtonVisibilityOnScroll),
288+
throttledButtonVisibility,
248289
);
249290
};
250-
}, []);
291+
}, [throttledCardCount, throttledButtonVisibility]);
251292

252293
return (
253294
<>
@@ -263,6 +304,7 @@ export const ScrollableProduct = ({ products, format }: Props) => {
263304
css={navigationStyles}
264305
id={'at-a-glance-carousel-navigation'}
265306
></div>
307+
<div css={countStyles} id={'at-a-glance-carousel-count'}></div>
266308
</div>
267309
<div css={[baseContainerStyles]}>
268310
<ol
@@ -300,6 +342,11 @@ export const ScrollableProduct = ({ products, format }: Props) => {
300342
'next-button',
301343
)}
302344
/>
345+
<CarouselCount
346+
sectionId={'at-a-glance'}
347+
count={cardCount}
348+
total={carouselLength}
349+
/>
303350
</div>
304351
</>
305352
);

0 commit comments

Comments
 (0)