From e4878a49dac0b17d8145300ff2128e5318157255 Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki Date: Wed, 24 Sep 2025 17:46:29 +0100 Subject: [PATCH 1/4] add CarousableSmallOnwards container which applies new design for onwards also update FetchOnwardsData component to use CarousableSmallOnwards for galleries --- dotcom-rendering/src/components/Card/Card.tsx | 3 +- .../CarousableSmallOnwards.stories.tsx | 174 +++++++++++++ .../src/components/CarousableSmallOnwards.tsx | 243 ++++++++++++++++++ .../FetchOnwardsData.importable.tsx | 43 ++-- .../components/OnwardsUpper.importable.tsx | 62 +++-- .../src/layouts/GalleryLayout.tsx | 48 +--- dotcom-rendering/src/lib/decideTrail.ts | 4 +- dotcom-rendering/src/paletteDeclarations.ts | 11 +- dotcom-rendering/src/types/onwards.ts | 2 +- 9 files changed, 507 insertions(+), 83 deletions(-) create mode 100644 dotcom-rendering/src/components/CarousableSmallOnwards.stories.tsx create mode 100644 dotcom-rendering/src/components/CarousableSmallOnwards.tsx diff --git a/dotcom-rendering/src/components/Card/Card.tsx b/dotcom-rendering/src/components/Card/Card.tsx index 27df29641ea..914d2edaf4c 100644 --- a/dotcom-rendering/src/components/Card/Card.tsx +++ b/dotcom-rendering/src/components/Card/Card.tsx @@ -545,7 +545,8 @@ export const Card = ({ - */ const isMediaCardOrNewsletter = isMediaCard(format) || isNewsletter; - const showPill = isMediaCardOrNewsletter; + const isOnwardRelatedContent = containerType === 'related-content'; + const showPill = isMediaCardOrNewsletter && !isOnwardRelatedContent; const media = getMedia({ imageUrl: image?.src, diff --git a/dotcom-rendering/src/components/CarousableSmallOnwards.stories.tsx b/dotcom-rendering/src/components/CarousableSmallOnwards.stories.tsx new file mode 100644 index 00000000000..ce2d2ebf7ef --- /dev/null +++ b/dotcom-rendering/src/components/CarousableSmallOnwards.stories.tsx @@ -0,0 +1,174 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat'; +import { getDataLinkNameCard } from '../lib/getDataLinkName'; +import { CarousableSmallOnwards } from './CarousableSmallOnwards'; + +const meta = { + title: 'Components/CarousableSmallOnwards', + component: CarousableSmallOnwards, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const CarousableSmallOnwardsStory = { + args: { + absoluteServerTimes: false, + discussionApiUrl: + 'https://discussion.code.dev-theguardian.com/discussion-api', + heading: 'More on this story', + url: 'http://localhost:9000/more-galleries', + onwardsSource: 'more-galleries', + trails: [ + { + url: 'http://localhost:9000/environment/gallery/2025/aug/22/week-in-wildlife-a-clumsy-fox-swinging-orangutang-and-rescued-jaguarundi-cub', + linkText: + 'Week in wildlife: a clumsy fox, a swinging orangutan and a rescued jaguarundi cub', + showByline: false, + byline: 'Pejman Faratin', + image: { + src: 'https://media.guim.co.uk/a81e974ffee6c8c88fa280c2d02eaf5dc2af863e/151_292_1020_816/master/1020.jpg', + altText: '', + }, + format: { + theme: Pillar.News, + design: ArticleDesign.Gallery, + display: ArticleDisplay.Standard, + }, + webPublicationDate: '2022-01-01T06:00:25.000Z', + headline: + 'Week in wildlife: a clumsy fox, a swinging orangutan and a rescued jaguarundi cub', + shortUrl: 'https://www.theguardian.com/p/x32n89', + discussion: { + isCommentable: false, + isClosedForComments: true, + discussionId: '/p/x32n89', + }, + discussionId: 'zHoBy6HNKsk', + dataLinkName: getDataLinkNameCard( + { + theme: Pillar.News, + design: ArticleDesign.Gallery, + display: ArticleDisplay.Standard, + }, + '0', + 0, + ), + trailText: + 'Guinness World Records is looking back at the extraordinary feats achieved since its inception - as well as unveiling 70 whacky and unclaimed records ', + kickerText: 'Politics', // Get data for this + mainMedia: { type: 'Gallery', count: '6' }, // TODO: get data for this + }, + { + url: 'http://localhost:9000/money/gallery/2025/aug/22/characterful-cottages-for-sale-in-england-in-pictures', + linkText: + 'Characterful cottages for sale in England – in pictures', + showByline: false, + byline: 'Anna White', + image: { + src: 'https://media.guim.co.uk/58cd9356e6d68e8efa6028162bb959f9798307d5/515_0_5000_4000/master/5000.jpg', + altText: '', + }, + format: { + design: ArticleDesign.Gallery, + theme: Pillar.Lifestyle, + display: ArticleDisplay.Standard, + }, + webPublicationDate: '2022-01-01T06:00:24.000Z', + headline: + 'Characterful cottages for sale in England – in pictures', + shortUrl: 'https://www.theguardian.com/p/x32gqj', + discussion: { + isCommentable: false, + isClosedForComments: true, + discussionId: '/p/x32gqj', + }, + dataLinkName: getDataLinkNameCard( + { + design: ArticleDesign.Gallery, + theme: Pillar.Lifestyle, + display: ArticleDisplay.Standard, + }, + '0', + 1, + ), + trailText: + 'Picked from a record 60,636 entries, the first images from the Natural History Museum’s wildlife photographer of the year competition have been released. The photographs, which range from a lion facing down a cobra to magnified mould spores, show the diversity, beauty and complexity of the natural world and humanity’s relationship with it', + mainMedia: { type: 'Gallery', count: '6' }, // TODO: get data for this + }, + { + url: 'http://localhost:9000/news/gallery/2025/aug/22/sunsets-aid-parachutes-and-giant-pandas-photos-of-the-day-friday', + linkText: + 'Sunsets, aid parachutes and giant pandas: photos of the day – Friday ', + showByline: false, + byline: 'Eithne Staunton', + image: { + src: 'https://media.guim.co.uk/4ce0b080206fe9b65b976c1acf219d81072cc814/0_0_2113_1690/master/2113.png', + altText: '', + }, + format: { + design: ArticleDesign.Gallery, + theme: Pillar.News, + display: ArticleDisplay.Standard, + }, + webPublicationDate: '2022-01-01T08:49:42.000Z', + headline: + 'Sunsets, aid parachutes and giant pandas: photos of the day – Friday ', + shortUrl: 'https://www.theguardian.com/p/x3359z', + discussion: { + isCommentable: false, + isClosedForComments: true, + discussionId: '/p/x3359z', + }, + dataLinkName: getDataLinkNameCard( + { + design: ArticleDesign.Gallery, + theme: Pillar.News, + display: ArticleDisplay.Standard, + }, + '0', + 2, + ), + trailText: + 'From the mock-Tudor fad of the 1920s to drivers refuelling on a roundabout, each era produces its own distinctive petrol stations – as photographer Philip Butler discovered', + mainMedia: { type: 'Gallery', count: '6' }, // TODO: get data for this + }, + { + url: 'http://localhost:9000/fashion/gallery/2025/aug/22/what-to-wear-to-notting-hill-carnival', + linkText: 'On parade: what to wear to Notting Hill carnival', + showByline: false, + byline: 'Melanie Wilkinson', + image: { + src: 'https://media.guim.co.uk/49a9656cd10c4f64f8bdd54380afb915c7a3648b/207_0_1500_1200/master/1500.jpg', + altText: '', + }, + format: { + design: ArticleDesign.Gallery, + theme: Pillar.Lifestyle, + display: ArticleDisplay.Standard, + }, + webPublicationDate: '2025-08-22T05:00:23.000Z', + headline: 'On parade: what to wear to Notting Hill carnival', + shortUrl: 'https://www.theguardian.com/p/x32mte', + discussion: { + isCommentable: false, + isClosedForComments: true, + discussionId: '/p/x32mte', + }, + dataLinkName: getDataLinkNameCard( + { + design: ArticleDesign.Gallery, + theme: Pillar.Lifestyle, + display: ArticleDisplay.Standard, + }, + '0', + 1, + ), + trailText: + 'The Guardian’s picture editors select photographs from around the world', + mainMedia: { type: 'Gallery', count: '6' }, // TODO: get data for thismainMedia: { type: 'Gallery', count: '6' }, // TODO: get data for this + }, + ], + }, +} satisfies Story; diff --git a/dotcom-rendering/src/components/CarousableSmallOnwards.tsx b/dotcom-rendering/src/components/CarousableSmallOnwards.tsx new file mode 100644 index 00000000000..31250d178c3 --- /dev/null +++ b/dotcom-rendering/src/components/CarousableSmallOnwards.tsx @@ -0,0 +1,243 @@ +import { css } from '@emotion/react'; +import { + from, + headlineBold20, + headlineBold24, + space, +} from '@guardian/source/foundations'; +import { formatAttrString } from '../lib/formatAttrString'; +import { palette as themePalette } from '../palette'; +import { type OnwardsSource } from '../types/onwards'; +import { type TrailType } from '../types/trails'; +import { Card } from './Card/Card'; +import type { Props as CardProps } from './Card/Card'; +import { Hide } from './Hide'; +import { LeftColumn } from './LeftColumn'; +import { ScrollableCarousel } from './ScrollableCarousel'; +import { Section } from './Section'; + +type Props = { + absoluteServerTimes: boolean; + trails: TrailType[]; + discussionApiUrl: string; + heading: string; + onwardsSource: OnwardsSource; + url?: string; +}; + +const wrapperStyle = css` + display: flex; + justify-content: space-between; + overflow: hidden; + ${from.desktop} { + padding-right: 40px; + } +`; + +const containerStyles = css` + display: flex; + flex-direction: column; + position: relative; + overflow: hidden; /* Needed for scrolling to work */ + + margin-top: ${space[2]}px; + padding-bottom: ${space[6]}px; + + margin-left: 0px; + margin-right: 0px; + + border-bottom: 1px solid ${themePalette('--onward-content-border')}; + + ${from.leftCol} { + margin-left: 10px; + margin-right: 100px; + } +`; + +const headerStyles = css` + color: ${themePalette('--carousel-text')}; + ${headlineBold24}; + padding-bottom: ${space[3]}px; + padding-top: ${space[1]}px; + margin-left: 0; + + ${from.tablet} { + ${headlineBold20}; + } +`; + +const headerStylesWithUrl = css` + :hover { + text-decoration: underline; + } +`; + +const titleStyle = css` + color: ${themePalette('--onward-text')}; + display: inline-block; + &::first-letter { + text-transform: capitalize; + } +`; + +const getDefaultCardProps = ( + trail: TrailType, + absoluteServerTimes: boolean, + discussionApiUrl: string, +) => { + const defaultProps: CardProps = { + linkTo: trail.url, + format: trail.format, + headlineText: trail.headline, + byline: trail.byline, + showByline: trail.showByline, + showQuotedHeadline: trail.showQuotedHeadline, + webPublicationDate: trail.webPublicationDate, + kickerText: trail.kickerText, + showPulsingDot: false, + showClock: false, + image: trail.image, + isCrossword: trail.isCrossword, + starRating: trail.starRating, + dataLinkName: trail.dataLinkName, + snapData: trail.snapData, + discussionApiUrl, + discussionId: trail.discussionId, + avatarUrl: trail.avatarUrl, + mainMedia: trail.mainMedia, + isExternalLink: false, + branding: trail.branding, + absoluteServerTimes, + imageLoading: 'lazy', + trailText: trail.trailText, + showAge: true, // TODO + containerType: 'related-content', + showTopBarDesktop: false, + showTopBarMobile: false, + aspectRatio: '5:4', + }; + return defaultProps; +}; + +export const CarousableSmallOnwards = (props: Props) => { + const trails = props.trails.slice(0, 4); // Limit to 4 cards + if (trails.length !== 4) return null; + + const mobileBottomCards = [1, 3]; + const desktopBottomCards = [2, 3]; + + return ( +
+
+ + + </LeftColumn> + + <div + css={containerStyles} + data-component={props.onwardsSource} + data-link={formatAttrString(props.heading)} + > + <Hide when="above" breakpoint="leftCol"> + <Title title={props.heading} url={props.url} /> + </Hide> + + <ScrollableCarousel + carouselLength={Math.ceil(trails.length / 2)} + visibleCarouselSlidesOnMobile={1} + visibleCarouselSlidesOnTablet={2} + sectionId={'some-section-id-12'} + shouldStackCards={{ + desktop: true, + mobile: true, + }} + gapSizes={{ column: 'large', row: 'medium' }} + > + {trails.map((trail, index) => { + return ( + <li + key={trail.url} + css={[ + css` + display: flex; + scroll-snap-align: start; + grid-area: span 1; + position: relative; + &::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: -10px; + width: 1px; + background-color: ${themePalette( + '--card-border-top', + )}; + transform: translateX(-50%); + } + `, + ]} + > + {Card({ + ...getDefaultCardProps( + trail, + props.absoluteServerTimes, + props.discussionApiUrl, + ), + mediaSize: 'small', + mediaPositionOnDesktop: 'left', + mediaPositionOnMobile: 'left', + headlineSizes: { + desktop: 'xxsmall', + tablet: 'xxsmall', + mobile: 'xxxsmall', + }, + trailText: undefined, + supportingContent: undefined, + showTopBarDesktop: + desktopBottomCards.includes(index), + showTopBarMobile: + mobileBottomCards.includes(index), + canPlayInline: false, + })} + </li> + ); + })} + </ScrollableCarousel> + </div> + </div> + </Section> + ); +}; + +const Title = ({ title, url }: { title: string; url?: string }) => + url ? ( + <a + css={css` + text-decoration: none; + `} + href={url} + data-link-name="section heading" // TODO + > + <h2 css={headerStyles}> + <span css={[headerStylesWithUrl, titleStyle]}>{title}</span> + </h2> + </a> + ) : ( + <h2 css={headerStyles}> + <span css={titleStyle}>{title}</span> + </h2> + ); diff --git a/dotcom-rendering/src/components/FetchOnwardsData.importable.tsx b/dotcom-rendering/src/components/FetchOnwardsData.importable.tsx index 17688d44b29..8d81ec5796d 100644 --- a/dotcom-rendering/src/components/FetchOnwardsData.importable.tsx +++ b/dotcom-rendering/src/components/FetchOnwardsData.importable.tsx @@ -8,6 +8,7 @@ import { palette } from '../palette'; import type { OnwardsSource } from '../types/onwards'; import type { RenderingTarget } from '../types/renderingTarget'; import type { FETrailType, TrailType } from '../types/trails'; +import { CarousableSmallOnwards } from './CarousableSmallOnwards'; import { Carousel } from './Carousel.importable'; import { Placeholder } from './Placeholder'; @@ -89,22 +90,32 @@ export const FetchOnwardsData = ({ return ( <div css={minHeight}> - <Carousel - heading={data.heading || data.displayname} // Sometimes the api returns heading as 'displayName' - trails={trails} - description={data.description} - onwardsSource={onwardsSource} - format={format} - leftColSize={ - format.design === ArticleDesign.LiveBlog || - format.design === ArticleDesign.DeadBlog - ? 'wide' - : 'compact' - } - discussionApiUrl={discussionApiUrl} - absoluteServerTimes={absoluteServerTimes} - renderingTarget={renderingTarget} - /> + {format.design === ArticleDesign.Gallery ? ( + <CarousableSmallOnwards + absoluteServerTimes={absoluteServerTimes} + trails={trails} + discussionApiUrl={discussionApiUrl} + heading={data.heading || data.displayname} + onwardsSource={onwardsSource} + /> + ) : ( + <Carousel + heading={data.heading || data.displayname} // Sometimes the api returns heading as 'displayName' + trails={trails} + description={data.description} + onwardsSource={onwardsSource} + format={format} + leftColSize={ + format.design === ArticleDesign.LiveBlog || + format.design === ArticleDesign.DeadBlog + ? 'wide' + : 'compact' + } + discussionApiUrl={discussionApiUrl} + absoluteServerTimes={absoluteServerTimes} + renderingTarget={renderingTarget} + /> + )} </div> ); }; diff --git a/dotcom-rendering/src/components/OnwardsUpper.importable.tsx b/dotcom-rendering/src/components/OnwardsUpper.importable.tsx index d9b88d00207..631871bc049 100644 --- a/dotcom-rendering/src/components/OnwardsUpper.importable.tsx +++ b/dotcom-rendering/src/components/OnwardsUpper.importable.tsx @@ -317,6 +317,15 @@ export const OnwardsUpper = ({ <Section fullWidth={true} borderColour={palette('--article-section-border')} + padSides={ + format.design === ArticleDesign.Gallery ? false : true + } + showTopBorder={ + format.design === ArticleDesign.Gallery ? false : true + } + showSideBorders={ + format.design === ArticleDesign.Gallery ? false : true + } > <FetchOnwardsData url={url} @@ -331,24 +340,41 @@ export const OnwardsUpper = ({ /> </Section> )} - {!!curatedDataUrl && !isPaidContent && canHaveCuratedContent && ( - <Section - fullWidth={true} - borderColour={palette('--article-section-border')} - > - <FetchOnwardsData - url={curatedDataUrl} - limit={20} - onwardsSource="curated-content" - format={format} - discussionApiUrl={discussionApiUrl} - absoluteServerTimes={absoluteServerTimes} - renderingTarget={renderingTarget} - isAdFreeUser={isAdFreeUser} - webURL={webURL} - /> - </Section> - )} + {!!curatedDataUrl && + !isPaidContent && + canHaveCuratedContent && ( + <Section + fullWidth={true} + borderColour={palette('--article-section-border')} + showTopBorder={ + format.design === ArticleDesign.Gallery + ? false + : true + } + showSideBorders={ + format.design === ArticleDesign.Gallery + ? false + : true + } + padSides={ + format.design === ArticleDesign.Gallery + ? false + : true + } + > + <FetchOnwardsData + url={curatedDataUrl} + limit={20} + onwardsSource="curated-content" + format={format} + discussionApiUrl={discussionApiUrl} + absoluteServerTimes={absoluteServerTimes} + renderingTarget={renderingTarget} + isAdFreeUser={isAdFreeUser} + webURL={webURL} + /> + </Section> + )} </div> ); }; diff --git a/dotcom-rendering/src/layouts/GalleryLayout.tsx b/dotcom-rendering/src/layouts/GalleryLayout.tsx index 9479de7e20d..9399963843c 100644 --- a/dotcom-rendering/src/layouts/GalleryLayout.tsx +++ b/dotcom-rendering/src/layouts/GalleryLayout.tsx @@ -16,7 +16,7 @@ import { ArticleMetaApps } from '../components/ArticleMeta.apps'; import { ArticleMeta } from '../components/ArticleMeta.web'; import { ArticleTitle } from '../components/ArticleTitle'; import { Caption } from '../components/Caption'; -import { Carousel } from '../components/Carousel.importable'; +import { CarousableSmallOnwards } from '../components/CarousableSmallOnwards'; import { DiscussionLayout } from '../components/DiscussionLayout'; import { FetchMoreGalleriesData } from '../components/FetchMoreGalleriesData.importable'; import { Footer } from '../components/Footer'; @@ -312,13 +312,12 @@ export const GalleryLayout = (props: WebProps | AppProps) => { show={showMerchandisingHigh} display={format.display} /> - <StoryPackage + <CarousableSmallOnwards absoluteServerTimes={absoluteServerTimes} + trails={gallery.storyPackage?.trails ?? []} discussionApiUrl={discussionApiUrl} - format={format} - renderingTarget={renderingTarget} - storyPackage={gallery.storyPackage} - topBorder={showMerchandisingHigh} + heading={gallery.storyPackage?.heading ?? 'Marjan'} + onwardsSource={'more-on-this-story'} /> <Island priority="feature" defer={{ until: 'visible' }}> <OnwardsUpper @@ -619,40 +618,3 @@ const MerchandisingHigh = (props: { /> </Section> ) : null; - -const StoryPackage = ({ - storyPackage, - format, - discussionApiUrl, - absoluteServerTimes, - renderingTarget, - topBorder, -}: { - storyPackage: Gallery['storyPackage']; - format: ArticleFormat; - discussionApiUrl: string; - absoluteServerTimes: boolean; - renderingTarget: RenderingTarget; - topBorder: boolean; -}) => - storyPackage === undefined ? null : ( - <Section - fullWidth={true} - backgroundColour={palette('--article-section-background')} - borderColour={palette('--onward-content-border')} - showTopBorder={topBorder} - > - <Island priority="feature" defer={{ until: 'visible' }}> - <Carousel - heading={storyPackage.heading} - trails={storyPackage.trails} - onwardsSource="more-on-this-story" - format={format} - leftColSize="compact" - discussionApiUrl={discussionApiUrl} - absoluteServerTimes={absoluteServerTimes} - renderingTarget={renderingTarget} - /> - </Island> - </Section> - ); diff --git a/dotcom-rendering/src/lib/decideTrail.ts b/dotcom-rendering/src/lib/decideTrail.ts index f9d313e6eb9..f426be7122f 100644 --- a/dotcom-rendering/src/lib/decideTrail.ts +++ b/dotcom-rendering/src/lib/decideTrail.ts @@ -18,9 +18,9 @@ export const decideStoryPackageTrails = ( export const decideTrail = (trail: FETrailType, index = 0): TrailType => { const format: ArticleFormat = decideFormat(trail.format); - const image: DCRFrontImage | undefined = trail.image + const image: DCRFrontImage | undefined = trail.masterImage // Fix this ? { - src: trail.image, + src: trail.masterImage, altText: '', // TODO: Do we get this from frontend? } : undefined; diff --git a/dotcom-rendering/src/paletteDeclarations.ts b/dotcom-rendering/src/paletteDeclarations.ts index 25ea4ec34a7..6502266d753 100644 --- a/dotcom-rendering/src/paletteDeclarations.ts +++ b/dotcom-rendering/src/paletteDeclarations.ts @@ -2522,8 +2522,15 @@ const cardMetaTextDark: PaletteFunction = () => sourcePalette.neutral[60]; const cardBackgroundLight: PaletteFunction = () => 'transparent'; const cardBackgroundDark: PaletteFunction = () => 'transparent'; -const cardMediaBackgroundLight: PaletteFunction = () => - sourcePalette.neutral[97]; +const cardMediaBackgroundLight: PaletteFunction = (format) => { + switch (format.design) { + case ArticleDesign.Gallery: + return sourcePalette.neutral[100]; + default: + return sourcePalette.neutral[97]; + } +}; + const cardMediaBackgroundDark: PaletteFunction = () => sourcePalette.neutral[20]; diff --git a/dotcom-rendering/src/types/onwards.ts b/dotcom-rendering/src/types/onwards.ts index 5d0ee426cca..0a75eaab3cb 100644 --- a/dotcom-rendering/src/types/onwards.ts +++ b/dotcom-rendering/src/types/onwards.ts @@ -25,4 +25,4 @@ export type OnwardsSource = | 'newsletters-page' | 'unknown-source'; // We should never see this in the analytics data! -export type OnwardContainerType = 'more-galleries'; +export type OnwardContainerType = 'more-galleries' | 'related-content'; From f2d0ff50a2839ad229f037bb459bf5351d54c4d9 Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki <marjan.kalanaki@guardian.co.uk> Date: Mon, 6 Oct 2025 09:39:38 +0100 Subject: [PATCH 2/4] clean up after rebase --- .../components/OnwardsUpper.importable.tsx | 62 ++++++++----------- 1 file changed, 27 insertions(+), 35 deletions(-) diff --git a/dotcom-rendering/src/components/OnwardsUpper.importable.tsx b/dotcom-rendering/src/components/OnwardsUpper.importable.tsx index 631871bc049..15ad6562e1c 100644 --- a/dotcom-rendering/src/components/OnwardsUpper.importable.tsx +++ b/dotcom-rendering/src/components/OnwardsUpper.importable.tsx @@ -340,41 +340,33 @@ export const OnwardsUpper = ({ /> </Section> )} - {!!curatedDataUrl && - !isPaidContent && - canHaveCuratedContent && ( - <Section - fullWidth={true} - borderColour={palette('--article-section-border')} - showTopBorder={ - format.design === ArticleDesign.Gallery - ? false - : true - } - showSideBorders={ - format.design === ArticleDesign.Gallery - ? false - : true - } - padSides={ - format.design === ArticleDesign.Gallery - ? false - : true - } - > - <FetchOnwardsData - url={curatedDataUrl} - limit={20} - onwardsSource="curated-content" - format={format} - discussionApiUrl={discussionApiUrl} - absoluteServerTimes={absoluteServerTimes} - renderingTarget={renderingTarget} - isAdFreeUser={isAdFreeUser} - webURL={webURL} - /> - </Section> - )} + {!!curatedDataUrl && !isPaidContent && canHaveCuratedContent && ( + <Section + fullWidth={true} + borderColour={palette('--article-section-border')} + showTopBorder={ + format.design === ArticleDesign.Gallery ? false : true + } + showSideBorders={ + format.design === ArticleDesign.Gallery ? false : true + } + padSides={ + format.design === ArticleDesign.Gallery ? false : true + } + > + <FetchOnwardsData + url={curatedDataUrl} + limit={20} + onwardsSource="curated-content" + format={format} + discussionApiUrl={discussionApiUrl} + absoluteServerTimes={absoluteServerTimes} + renderingTarget={renderingTarget} + isAdFreeUser={isAdFreeUser} + webURL={webURL} + /> + </Section> + )} </div> ); }; From 2dbb5d6ac456ecde38409a42b61bd4cabf627d76 Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki <marjan.kalanaki@guardian.co.uk> Date: Tue, 7 Oct 2025 17:08:13 +0100 Subject: [PATCH 3/4] rename the component --- .../src/components/FetchOnwardsData.importable.tsx | 4 ++-- ....stories.tsx => ScrollableSmallOnwards.stories.tsx} | 10 +++++----- ...ableSmallOnwards.tsx => ScrollableSmallOnwards.tsx} | 2 +- dotcom-rendering/src/layouts/GalleryLayout.tsx | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) rename dotcom-rendering/src/components/{CarousableSmallOnwards.stories.tsx => ScrollableSmallOnwards.stories.tsx} (96%) rename dotcom-rendering/src/components/{CarousableSmallOnwards.tsx => ScrollableSmallOnwards.tsx} (99%) diff --git a/dotcom-rendering/src/components/FetchOnwardsData.importable.tsx b/dotcom-rendering/src/components/FetchOnwardsData.importable.tsx index 8d81ec5796d..b6fc7a7e10a 100644 --- a/dotcom-rendering/src/components/FetchOnwardsData.importable.tsx +++ b/dotcom-rendering/src/components/FetchOnwardsData.importable.tsx @@ -8,9 +8,9 @@ import { palette } from '../palette'; import type { OnwardsSource } from '../types/onwards'; import type { RenderingTarget } from '../types/renderingTarget'; import type { FETrailType, TrailType } from '../types/trails'; -import { CarousableSmallOnwards } from './CarousableSmallOnwards'; import { Carousel } from './Carousel.importable'; import { Placeholder } from './Placeholder'; +import { ScrollableSmallOnwards } from './ScrollableSmallOnwards'; type Props = { url: string; @@ -91,7 +91,7 @@ export const FetchOnwardsData = ({ return ( <div css={minHeight}> {format.design === ArticleDesign.Gallery ? ( - <CarousableSmallOnwards + <ScrollableSmallOnwards absoluteServerTimes={absoluteServerTimes} trails={trails} discussionApiUrl={discussionApiUrl} diff --git a/dotcom-rendering/src/components/CarousableSmallOnwards.stories.tsx b/dotcom-rendering/src/components/ScrollableSmallOnwards.stories.tsx similarity index 96% rename from dotcom-rendering/src/components/CarousableSmallOnwards.stories.tsx rename to dotcom-rendering/src/components/ScrollableSmallOnwards.stories.tsx index ce2d2ebf7ef..988c4cb3561 100644 --- a/dotcom-rendering/src/components/CarousableSmallOnwards.stories.tsx +++ b/dotcom-rendering/src/components/ScrollableSmallOnwards.stories.tsx @@ -1,18 +1,18 @@ import type { Meta, StoryObj } from '@storybook/react'; import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat'; import { getDataLinkNameCard } from '../lib/getDataLinkName'; -import { CarousableSmallOnwards } from './CarousableSmallOnwards'; +import { ScrollableSmallOnwards } from './ScrollableSmallOnwards'; const meta = { - title: 'Components/CarousableSmallOnwards', - component: CarousableSmallOnwards, -} satisfies Meta<typeof CarousableSmallOnwards>; + title: 'Components/ScrollableSmallOnwards', + component: ScrollableSmallOnwards, +} satisfies Meta<typeof ScrollableSmallOnwards>; export default meta; type Story = StoryObj<typeof meta>; -export const CarousableSmallOnwardsStory = { +export const ScrollableSmallOnwardsStory = { args: { absoluteServerTimes: false, discussionApiUrl: diff --git a/dotcom-rendering/src/components/CarousableSmallOnwards.tsx b/dotcom-rendering/src/components/ScrollableSmallOnwards.tsx similarity index 99% rename from dotcom-rendering/src/components/CarousableSmallOnwards.tsx rename to dotcom-rendering/src/components/ScrollableSmallOnwards.tsx index 31250d178c3..e1466940024 100644 --- a/dotcom-rendering/src/components/CarousableSmallOnwards.tsx +++ b/dotcom-rendering/src/components/ScrollableSmallOnwards.tsx @@ -119,7 +119,7 @@ const getDefaultCardProps = ( return defaultProps; }; -export const CarousableSmallOnwards = (props: Props) => { +export const ScrollableSmallOnwards = (props: Props) => { const trails = props.trails.slice(0, 4); // Limit to 4 cards if (trails.length !== 4) return null; diff --git a/dotcom-rendering/src/layouts/GalleryLayout.tsx b/dotcom-rendering/src/layouts/GalleryLayout.tsx index 9399963843c..d7d94bcc933 100644 --- a/dotcom-rendering/src/layouts/GalleryLayout.tsx +++ b/dotcom-rendering/src/layouts/GalleryLayout.tsx @@ -16,7 +16,6 @@ import { ArticleMetaApps } from '../components/ArticleMeta.apps'; import { ArticleMeta } from '../components/ArticleMeta.web'; import { ArticleTitle } from '../components/ArticleTitle'; import { Caption } from '../components/Caption'; -import { CarousableSmallOnwards } from '../components/CarousableSmallOnwards'; import { DiscussionLayout } from '../components/DiscussionLayout'; import { FetchMoreGalleriesData } from '../components/FetchMoreGalleriesData.importable'; import { Footer } from '../components/Footer'; @@ -30,6 +29,7 @@ import { Masthead } from '../components/Masthead/Masthead'; import { MostViewedFooterData } from '../components/MostViewedFooterData.importable'; import { MostViewedFooterLayout } from '../components/MostViewedFooterLayout'; import { OnwardsUpper } from '../components/OnwardsUpper.importable'; +import { ScrollableSmallOnwards } from '../components/ScrollableSmallOnwards'; import { Section } from '../components/Section'; import { Standfirst } from '../components/Standfirst'; import { StickyBottomBanner } from '../components/StickyBottomBanner.importable'; @@ -312,7 +312,7 @@ export const GalleryLayout = (props: WebProps | AppProps) => { show={showMerchandisingHigh} display={format.display} /> - <CarousableSmallOnwards + <ScrollableSmallOnwards absoluteServerTimes={absoluteServerTimes} trails={gallery.storyPackage?.trails ?? []} discussionApiUrl={discussionApiUrl} From cc0479ab04db3e40d3185d3a5352263961c56d46 Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki <marjan.kalanaki@guardian.co.uk> Date: Wed, 8 Oct 2025 13:14:45 +0100 Subject: [PATCH 4/4] clean up the ScrollableSmallOnwards component --- .../components/OnwardsUpper.importable.tsx | 2 +- .../src/components/ScrollableCarousel.tsx | 23 +- .../ScrollableSmallOnwards.stories.tsx | 2 +- .../src/components/ScrollableSmallOnwards.tsx | 368 ++++++++---------- 4 files changed, 187 insertions(+), 208 deletions(-) diff --git a/dotcom-rendering/src/components/OnwardsUpper.importable.tsx b/dotcom-rendering/src/components/OnwardsUpper.importable.tsx index 15ad6562e1c..ed361abc162 100644 --- a/dotcom-rendering/src/components/OnwardsUpper.importable.tsx +++ b/dotcom-rendering/src/components/OnwardsUpper.importable.tsx @@ -312,7 +312,7 @@ export const OnwardsUpper = ({ format.design === ArticleDesign.Gallery ? isUndefined(url) : true; return ( - <div css={onwardsWrapper}> + <div id="marjan" css={onwardsWrapper}> {!!url && ( <Section fullWidth={true} diff --git a/dotcom-rendering/src/components/ScrollableCarousel.tsx b/dotcom-rendering/src/components/ScrollableCarousel.tsx index 9601c2c6e42..b25a5a1be2e 100644 --- a/dotcom-rendering/src/components/ScrollableCarousel.tsx +++ b/dotcom-rendering/src/components/ScrollableCarousel.tsx @@ -104,32 +104,34 @@ const itemStyles = css` position: relative; `; -const leftBorderStyles = css` +const leftBorderStyles = (isOnwardContent?: boolean) => css` content: ''; position: absolute; top: 0; bottom: 0; left: -10px; width: 1px; - background-color: ${palette('--card-border-top')}; + background-color: ${isOnwardContent + ? palette('--onward-content-border') + : palette('--card-border-top')}; transform: translateX(-50%); `; -const singleRowLeftBorderStyles = css` +const singleRowLeftBorderStyles = (isOnwardContent?: boolean) => css` :not(:first-child)::before { - ${leftBorderStyles} + ${leftBorderStyles(isOnwardContent)} } `; -const stackedRowLeftBorderStyles = css` +const stackedRowLeftBorderStyles = (isOnwardContent?: boolean) => css` ${from.tablet} { :not(:first-child)::before { - ${leftBorderStyles} + ${leftBorderStyles(isOnwardContent)} } } ${until.tablet} { :not(:first-child):not(:nth-child(2))::before { - ${leftBorderStyles} + ${leftBorderStyles(isOnwardContent)} } } `; @@ -410,17 +412,20 @@ export const ScrollableCarousel = ({ ScrollableCarousel.Item = ({ isStackingCarousel = false, + isOnwardContent = false, children, }: { isStackingCarousel?: boolean; children: React.ReactNode; + /** The colour of borders can be overriden */ + isOnwardContent?: boolean; }) => ( <li css={[ itemStyles, isStackingCarousel - ? stackedRowLeftBorderStyles - : singleRowLeftBorderStyles, + ? stackedRowLeftBorderStyles(isOnwardContent) + : singleRowLeftBorderStyles(isOnwardContent), ]} > {children} diff --git a/dotcom-rendering/src/components/ScrollableSmallOnwards.stories.tsx b/dotcom-rendering/src/components/ScrollableSmallOnwards.stories.tsx index 988c4cb3561..693d6ff5869 100644 --- a/dotcom-rendering/src/components/ScrollableSmallOnwards.stories.tsx +++ b/dotcom-rendering/src/components/ScrollableSmallOnwards.stories.tsx @@ -18,7 +18,7 @@ export const ScrollableSmallOnwardsStory = { discussionApiUrl: 'https://discussion.code.dev-theguardian.com/discussion-api', heading: 'More on this story', - url: 'http://localhost:9000/more-galleries', + headingUrl: 'http://localhost:9000/more-galleries', onwardsSource: 'more-galleries', trails: [ { diff --git a/dotcom-rendering/src/components/ScrollableSmallOnwards.tsx b/dotcom-rendering/src/components/ScrollableSmallOnwards.tsx index e1466940024..6301fb25647 100644 --- a/dotcom-rendering/src/components/ScrollableSmallOnwards.tsx +++ b/dotcom-rendering/src/components/ScrollableSmallOnwards.tsx @@ -1,20 +1,19 @@ import { css } from '@emotion/react'; import { + between, from, - headlineBold20, - headlineBold24, space, + textSansBold17, + textSansBold20, + until, } from '@guardian/source/foundations'; -import { formatAttrString } from '../lib/formatAttrString'; -import { palette as themePalette } from '../palette'; +import { StraightLines } from '@guardian/source-development-kitchen/react-components'; +import { grid } from '../grid'; +import { palette } from '../palette'; import { type OnwardsSource } from '../types/onwards'; import { type TrailType } from '../types/trails'; import { Card } from './Card/Card'; -import type { Props as CardProps } from './Card/Card'; -import { Hide } from './Hide'; -import { LeftColumn } from './LeftColumn'; import { ScrollableCarousel } from './ScrollableCarousel'; -import { Section } from './Section'; type Props = { absoluteServerTimes: boolean; @@ -22,103 +21,38 @@ type Props = { discussionApiUrl: string; heading: string; onwardsSource: OnwardsSource; - url?: string; + headingUrl?: string; }; -const wrapperStyle = css` - display: flex; - justify-content: space-between; - overflow: hidden; - ${from.desktop} { - padding-right: 40px; - } -`; - -const containerStyles = css` - display: flex; - flex-direction: column; +const cardsContainerStyles = css` + ${grid.column.centre} position: relative; - overflow: hidden; /* Needed for scrolling to work */ - - margin-top: ${space[2]}px; - padding-bottom: ${space[6]}px; - - margin-left: 0px; - margin-right: 0px; - - border-bottom: 1px solid ${themePalette('--onward-content-border')}; - - ${from.leftCol} { - margin-left: 10px; - margin-right: 100px; - } -`; - -const headerStyles = css` - color: ${themePalette('--carousel-text')}; - ${headlineBold24}; - padding-bottom: ${space[3]}px; - padding-top: ${space[1]}px; - margin-left: 0; - ${from.tablet} { - ${headlineBold20}; + ${from.desktop} { + ${grid.between('centre-column-start', 'right-column-end')} } -`; -const headerStylesWithUrl = css` - :hover { - text-decoration: underline; + ${from.leftCol} { + ${grid.between('centre-column-start', 'right-column-end')} } -`; -const titleStyle = css` - color: ${themePalette('--onward-text')}; - display: inline-block; - &::first-letter { - text-transform: capitalize; + ${from.leftCol} { + &::before { + content: ''; + position: absolute; + left: -11px; + top: 0; + bottom: 0; + width: 1px; + background-color: ${palette('--onward-content-border')}; + } + + ol { + padding-left: 0; + } } `; -const getDefaultCardProps = ( - trail: TrailType, - absoluteServerTimes: boolean, - discussionApiUrl: string, -) => { - const defaultProps: CardProps = { - linkTo: trail.url, - format: trail.format, - headlineText: trail.headline, - byline: trail.byline, - showByline: trail.showByline, - showQuotedHeadline: trail.showQuotedHeadline, - webPublicationDate: trail.webPublicationDate, - kickerText: trail.kickerText, - showPulsingDot: false, - showClock: false, - image: trail.image, - isCrossword: trail.isCrossword, - starRating: trail.starRating, - dataLinkName: trail.dataLinkName, - snapData: trail.snapData, - discussionApiUrl, - discussionId: trail.discussionId, - avatarUrl: trail.avatarUrl, - mainMedia: trail.mainMedia, - isExternalLink: false, - branding: trail.branding, - absoluteServerTimes, - imageLoading: 'lazy', - trailText: trail.trailText, - showAge: true, // TODO - containerType: 'related-content', - showTopBarDesktop: false, - showTopBarMobile: false, - aspectRatio: '5:4', - }; - return defaultProps; -}; - export const ScrollableSmallOnwards = (props: Props) => { const trails = props.trails.slice(0, 4); // Limit to 4 cards if (trails.length !== 4) return null; @@ -127,117 +61,157 @@ export const ScrollableSmallOnwards = (props: Props) => { const desktopBottomCards = [2, 3]; return ( - <Section - fullWidth={true} - borderColour={themePalette('--onward-content-border')} - backgroundColour={themePalette('--onward-background')} - showTopBorder={false} - showSideBorders={true} + <section + data-component={props.onwardsSource} + data-link="more-galleries" + css={css` + ${grid.paddedContainer} + background-color: ${palette('--onward-background')}; + padding-top: ${space[1]}px; + padding-bottom: ${space[6]}px; + + ${from.tablet} { + padding-top: 0; + border-left: 1px solid ${palette('--onward-content-border')}; + border-right: 1px solid + ${palette('--onward-content-border')}; + } + `} > - <div - css={wrapperStyle} - data-link-name={formatAttrString(props.heading)} - > - <LeftColumn - size={'compact'} - borderColour={themePalette('--onward-content-border')} - hasPageSkin={false} // TODO - > - <Title title={props.heading} url={props.url} /> - </LeftColumn> - - <div - css={containerStyles} - data-component={props.onwardsSource} - data-link={formatAttrString(props.heading)} + <StraightLines + cssOverrides={[ + css` + ${grid.column.all} + padding-left: ${space[5]}px; + padding-right: ${space[5]}px; + margin-bottom: ${space[2]}px; + ${until.tablet} { + display: none; + } + `, + ]} + count={1} + color={palette('--card-border-top')} + /> + <Title title={props.heading} headingUrl={props.headingUrl} /> + <div css={cardsContainerStyles}> + <ScrollableCarousel + carouselLength={Math.ceil(trails.length / 2)} + visibleCarouselSlidesOnMobile={1} + visibleCarouselSlidesOnTablet={2} + sectionId={'some-section-id-12'} + shouldStackCards={{ + desktop: true, + mobile: true, + }} + gapSizes={{ column: 'large', row: 'medium' }} > - <Hide when="above" breakpoint="leftCol"> - <Title title={props.heading} url={props.url} /> - </Hide> - - <ScrollableCarousel - carouselLength={Math.ceil(trails.length / 2)} - visibleCarouselSlidesOnMobile={1} - visibleCarouselSlidesOnTablet={2} - sectionId={'some-section-id-12'} - shouldStackCards={{ - desktop: true, - mobile: true, - }} - gapSizes={{ column: 'large', row: 'medium' }} - > - {trails.map((trail, index) => { - return ( - <li - key={trail.url} - css={[ - css` - display: flex; - scroll-snap-align: start; - grid-area: span 1; - position: relative; - &::before { - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: -10px; - width: 1px; - background-color: ${themePalette( - '--card-border-top', - )}; - transform: translateX(-50%); - } - `, - ]} - > - {Card({ - ...getDefaultCardProps( - trail, - props.absoluteServerTimes, - props.discussionApiUrl, - ), - mediaSize: 'small', - mediaPositionOnDesktop: 'left', - mediaPositionOnMobile: 'left', - headlineSizes: { - desktop: 'xxsmall', - tablet: 'xxsmall', - mobile: 'xxxsmall', - }, - trailText: undefined, - supportingContent: undefined, - showTopBarDesktop: - desktopBottomCards.includes(index), - showTopBarMobile: - mobileBottomCards.includes(index), - canPlayInline: false, - })} - </li> - ); - })} - </ScrollableCarousel> - </div> + {trails.map((trail, index) => { + return ( + <ScrollableCarousel.Item + key={trail.url} + isStackingCarousel={true} + isOnwardContent={true} + > + {Card({ + linkTo: trail.url, + format: trail.format, + headlineText: trail.headline, + byline: trail.byline, + showByline: trail.showByline, + showQuotedHeadline: + trail.showQuotedHeadline, + webPublicationDate: + trail.webPublicationDate, + kickerText: trail.kickerText, + showPulsingDot: false, + showClock: false, + image: trail.image, + isCrossword: trail.isCrossword, + starRating: trail.starRating, + dataLinkName: trail.dataLinkName, + snapData: trail.snapData, + discussionApiUrl: props.discussionApiUrl, + discussionId: + trail.discussion?.discussionId, + avatarUrl: trail.avatarUrl, + mainMedia: trail.mainMedia, + isExternalLink: false, + branding: trail.branding, + absoluteServerTimes: + props.absoluteServerTimes, + imageLoading: 'lazy', + showAge: true, + containerType: 'related-content', + aspectRatio: '5:4', + mediaSize: 'small', + mediaPositionOnDesktop: 'left', + mediaPositionOnMobile: 'left', + headlineSizes: { + desktop: 'xxsmall', + tablet: 'xxsmall', + mobile: 'xxxsmall', + }, + trailText: undefined, + supportingContent: undefined, + showTopBarDesktop: + desktopBottomCards.includes(index), + showTopBarMobile: + mobileBottomCards.includes(index), + canPlayInline: false, + })} + </ScrollableCarousel.Item> + ); + })} + </ScrollableCarousel> </div> - </Section> + </section> ); }; -const Title = ({ title, url }: { title: string; url?: string }) => - url ? ( +const Title = ({ + title, + headingUrl, +}: { + title: string; + headingUrl?: string; +}) => + headingUrl ? ( <a - css={css` - text-decoration: none; - `} - href={url} - data-link-name="section heading" // TODO + css={headerGridStyles} + href={`${headingUrl}/inpictures/all`} + data-link-name="section heading" > - <h2 css={headerStyles}> - <span css={[headerStylesWithUrl, titleStyle]}>{title}</span> - </h2> + <h2 css={headerStyles}>{title}</h2> </a> ) : ( - <h2 css={headerStyles}> - <span css={titleStyle}>{title}</span> - </h2> + <h2 css={[headerGridStyles, headerStyles]}>{title}</h2> ); + +const headerGridStyles = css` + ${grid.column.centre} + color: ${palette('--caption-text')}; + text-decoration: none; + align-self: start; + ${between.tablet.and.leftCol} { + margin-left: 10px; + } + ${from.leftCol} { + ${grid.column.left} + } +`; + +const headerStyles = css` + color: ${palette('--carousel-text')}; + ${textSansBold17}; + padding-bottom: ${space[3]}px; + padding-top: ${space[1]}px; + + :hover { + text-decoration: underline; + } + + ${from.tablet} { + ${textSansBold20}; + } +`;