@@ -2,11 +2,23 @@ import type { SerializedStyles } from '@emotion/react';
22import { css } from '@emotion/react' ;
33import type { Breakpoint } from '@guardian/source/foundations' ;
44import { from , space } from '@guardian/source/foundations' ;
5- import { useRef } from 'react' ;
5+ import { useEffect , useRef , useState } from 'react' ;
66import type { ArticleFormat } from '../lib/articleFormat' ;
7+ import { nestedOphanComponents } from '../lib/ophan-helpers' ;
78import { palette } from '../palette' ;
89import type { ProductBlockElement } from '../types/content' ;
10+ import { CarouselNavigationButtons } from './CarouselNavigationButtons' ;
911import { 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
1123export type FixedSlideWidth = {
1224 defaultWidth : number ;
@@ -21,6 +33,9 @@ type Props = {
2133const baseContainerStyles = css `
2234 position : relative;
2335` ;
36+ const navigationStyles = css `
37+ padding-bottom : ${ space [ 1 ] } px;
38+ ` ;
2439
2540const 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
94109export 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} ;
0 commit comments