diff --git a/dotcom-rendering/src/components/Card/Card.tsx b/dotcom-rendering/src/components/Card/Card.tsx index 98c585fd5ae..27df29641ea 100644 --- a/dotcom-rendering/src/components/Card/Card.tsx +++ b/dotcom-rendering/src/components/Card/Card.tsx @@ -33,7 +33,7 @@ import type { DCRSupportingContent, } from '../../types/front'; import type { MainMedia } from '../../types/mainMedia'; -import type { OnwardsSource } from '../../types/onwards'; +import type { OnwardContainerType, OnwardsSource } from '../../types/onwards'; import { Avatar } from '../Avatar'; import { CardCommentCount } from '../CardCommentCount.importable'; import { CardHeadline, type ResponsiveFontSize } from '../CardHeadline'; @@ -124,7 +124,7 @@ export type Props = { supportingContentPosition?: Position; snapData?: DCRSnapType; containerPalette?: DCRContainerPalette; - containerType?: DCRContainerType; + containerType?: DCRContainerType | OnwardContainerType; showAge?: boolean; discussionApiUrl: string; discussionId?: string; @@ -150,6 +150,8 @@ export type Props = { uniqueId?: string; /** The Splash card in a flexible container gets a different visual treatment to other cards */ isFlexSplash?: boolean; + /** The Splash card in an onward container gets a different visual treatment to other cards */ + isOnwardSplash?: boolean; showTopBarDesktop?: boolean; showTopBarMobile?: boolean; trailTextSize?: TrailTextSize; @@ -393,6 +395,7 @@ export const Card = ({ index = 0, uniqueId = '', isFlexSplash, + isOnwardSplash, showTopBarDesktop = true, showTopBarMobile = true, trailTextSize, @@ -585,6 +588,8 @@ export const Card = ({ containerType === 'flexible/special' || containerType === 'flexible/general'; + const isOnwardContainer = containerType === 'more-galleries'; + const isSmallCard = containerType === 'scrollable/small' || containerType === 'scrollable/medium'; @@ -603,18 +608,26 @@ export const Card = ({ const hideTrailTextUntil = () => { if (isFlexibleContainer) { + return 'tablet'; + } + if (isOnwardSplash) { return undefined; - } else if ( + } + if ( mediaSize === 'large' && mediaPositionOnDesktop === 'right' && media?.type !== 'avatar' ) { return 'desktop'; - } else { - return 'tablet'; } + + return 'tablet'; }; + const shouldShowTrailText = isOnwardContainer + ? media?.type !== 'podcast' && isOnwardSplash + : media?.type !== 'podcast'; + /** * Determines the gap of between card components based on card properties * Order matters here as the logic is based on the card properties @@ -1191,7 +1204,7 @@ export const Card = ({ )} - {!!trailText && media?.type !== 'podcast' && ( + {!!trailText && shouldShowTrailText && ( )} diff --git a/dotcom-rendering/src/components/MoreGalleries.stories.tsx b/dotcom-rendering/src/components/MoreGalleries.stories.tsx new file mode 100644 index 00000000000..1eff4d2774f --- /dev/null +++ b/dotcom-rendering/src/components/MoreGalleries.stories.tsx @@ -0,0 +1,206 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat'; +import { getDataLinkNameCard } from '../lib/getDataLinkName'; +import { MoreGalleries as MoreGalleriesComponent } from './MoreGalleries'; + +const meta = { + title: 'Components/MoreGalleries', + component: MoreGalleriesComponent, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const MoreGalleries = { + args: { + absoluteServerTimes: false, + discussionApiUrl: 'https://discussion.theguardian.com/discussion-api', + headingLink: 'https://www.theguardian.com/inpictures/all', + trails: [ + { + url: 'https://www.theguardian.com/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: '2025-08-22T06: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', + }, + 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', + mainMedia: { type: 'Gallery', count: '6' }, + }, + { + url: 'https://www.theguardian.com/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: '2025-08-22T06: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' }, + }, + { + url: 'https://www.theguardian.com/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: '2025-08-22T12: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' }, + }, + { + url: 'https://www.theguardian.com/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' }, + }, + { + url: 'https://www.theguardian.com/artanddesign/gallery/2025/aug/21/psychedelic-rock-glass-mountain-michael-lundgren', + linkText: + 'Psychedelic rock! Formations that mess with your mind – in pictures ', + showByline: false, + image: { + src: 'https://media.guim.co.uk/2810af61b2d2d2d5f71ec01e56e6555e0a6d4635/55_0_2813_2250/master/2813.jpg', + altText: '', + }, + format: { + design: ArticleDesign.Gallery, + theme: Pillar.Culture, + display: ArticleDisplay.Standard, + }, + webPublicationDate: '2025-08-21T06:01:01.000Z', + headline: + 'Psychedelic rock! Formations that mess with your mind – in pictures ', + shortUrl: 'https://www.theguardian.com/p/x2p663', + discussion: { + isCommentable: false, + isClosedForComments: true, + discussionId: '/p/x2p663', + }, + dataLinkName: getDataLinkNameCard( + { + design: ArticleDesign.Gallery, + theme: Pillar.Culture, + display: ArticleDisplay.Standard, + }, + '0', + 1, + ), + trailText: + 'Politicians and their partners put on their best show at this year’s Midwinter Ball, an annual dinner hosted by the Federal Parliamentary Press Gallery in Canberra', + mainMedia: { type: 'Gallery', count: '6' }, + }, + ], + }, +} satisfies Story; diff --git a/dotcom-rendering/src/components/MoreGalleries.tsx b/dotcom-rendering/src/components/MoreGalleries.tsx new file mode 100644 index 00000000000..55e721018b0 --- /dev/null +++ b/dotcom-rendering/src/components/MoreGalleries.tsx @@ -0,0 +1,264 @@ +import { css } from '@emotion/react'; +import { + from, + headlineBold24, + headlineBold28, + space, + until, +} from '@guardian/source/foundations'; +import { StraightLines } from '@guardian/source-development-kitchen/react-components'; +import { formatAttrString } from '../lib/formatAttrString'; +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 { Section } from './Section'; + +type Props = { + absoluteServerTimes: boolean; + trails: TrailType[]; + discussionApiUrl: string; + headingLink?: string; +}; + +const wrapperStyle = css` + display: flex; + justify-content: space-between; + overflow: hidden; + ${from.desktop} { + padding-right: ${space[10]}px; + } +`; + +const containerStyles = css` + display: flex; + flex-direction: column; + position: relative; + + margin-top: ${space[2]}px; + padding-bottom: ${space[6]}px; + + margin-left: 0px; + margin-right: 0px; + + border-bottom: 1px solid ${palette('--onward-content-border')}; + + ${from.leftCol} { + margin-left: 10px; + margin-right: 100px; + } +`; + +const standardCardStyles = css` + flex: 1; + + position: relative; + display: flex; + padding: ${space[2]}px; + background-color: ${palette('--onward-card-background')}; + + :not(:first-child)::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: -10px; /* shift into the gap */ + width: 1px; + background: ${palette('--onward-content-border')}; + } +`; + +const standardCardsListStyles = css` + width: 100%; + display: flex; + flex-direction: row; + gap: 20px; + + ${from.tablet} { + padding-top: ${space[2]}px; + } + + ${until.tablet} { + flex-direction: column; + width: 100%; + } +`; + +const headerStyles = css` + color: ${palette('--carousel-text')}; + ${headlineBold24}; + padding-bottom: ${space[3]}px; + padding-top: ${space[1]}px; + margin-left: 0; + + ${from.tablet} { + ${headlineBold28}; + } +`; + +const headerStylesWithUrl = css` + :hover { + text-decoration: underline; + } +`; + +const titleStyle = css` + color: ${palette('--onward-text')}; + display: inline-block; +`; + +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: false, + containerType: 'more-galleries', + showTopBarDesktop: false, + showTopBarMobile: false, + aspectRatio: '5:4', + }; + return defaultProps; +}; + +export const MoreGalleries = (props: Props) => { + const [firstTrail, ...standardCards] = props.trails; + if (!firstTrail) return null; + + const heading = 'More galleries'; + const onwardsSource: OnwardsSource = 'more-galleries'; + + const defaultProps = getDefaultCardProps( + firstTrail, + props.absoluteServerTimes, + props.discussionApiUrl, + ); + + return ( +
+
+ + + </LeftColumn> + + <div + css={containerStyles} + data-component={onwardsSource} + data-link={formatAttrString(heading)} + > + <Hide when="above" breakpoint="leftCol"> + <Title title={heading} url={props.headingLink} /> + </Hide> + + <MoreGalleriesSplashCard defaultProps={defaultProps} /> + <Hide when="below" breakpoint="tablet"> + <StraightLines + count={1} + color={palette('--onward-content-border')} + /> + </Hide> + + <ul css={standardCardsListStyles}> + {standardCards.map((trail) => ( + <li key={trail.url} css={standardCardStyles}> + <Card + {...getDefaultCardProps( + trail, + props.absoluteServerTimes, + props.discussionApiUrl, + )} + mediaSize="medium" + /> + </li> + ))} + </ul> + </div> + </div> + </Section> + ); +}; + +const MoreGalleriesSplashCard = ({ + defaultProps, +}: { + defaultProps: CardProps; +}) => { + const cardProps: Partial<CardProps> = { + headlineSizes: { + desktop: 'medium', + tablet: 'medium', + mobile: 'medium', + }, + mediaPositionOnDesktop: 'right', + mediaPositionOnMobile: 'top', + mediaSize: 'medium', + isOnwardSplash: true, + }; + return ( + <div + css={css` + margin-bottom: ${space[6]}px; + background-color: ${palette('--onward-card-background')}; + padding: ${space[2]}px; + `} + > + <Card {...defaultProps} {...cardProps} /> + </div> + ); +}; + +const Title = ({ title, url }: { title: string; url?: string }) => + url ? ( + <a + css={css` + text-decoration: none; + `} + href={url} + data-link-name="section heading" + > + <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/YoutubeAtom/YoutubeAtomFeatureCardOverlay.tsx b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomFeatureCardOverlay.tsx index 8e1896001b9..451ffbeaf48 100644 --- a/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomFeatureCardOverlay.tsx +++ b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomFeatureCardOverlay.tsx @@ -252,6 +252,7 @@ export const YoutubeAtomFeatureCardOverlay = ({ )} trailTextSize="regular" padBottom={false} + hideUntil="tablet" /> </div> )} diff --git a/dotcom-rendering/src/paletteDeclarations.ts b/dotcom-rendering/src/paletteDeclarations.ts index e6e71c14f8a..1bd6cc0e4d0 100644 --- a/dotcom-rendering/src/paletteDeclarations.ts +++ b/dotcom-rendering/src/paletteDeclarations.ts @@ -7427,10 +7427,22 @@ const paletteColours = { light: numberedListTitleLight, dark: numberedListTitleDark, }, + '--onward-background': { + light: () => sourcePalette.neutral[100], + dark: () => sourcePalette.neutral[10], + }, + '--onward-card-background': { + light: () => sourcePalette.neutral[97], + dark: () => sourcePalette.neutral[20], + }, '--onward-content-border': { light: onwardContentBorderLight, dark: () => sourcePalette.neutral[20], }, + '--onward-text': { + light: () => sourcePalette.neutral[7], + dark: () => sourcePalette.neutral[86], + }, '--pagination-text': { light: paginationTextLight, dark: paginationTextDark, diff --git a/dotcom-rendering/src/types/onwards.ts b/dotcom-rendering/src/types/onwards.ts index 948ca173272..5d0ee426cca 100644 --- a/dotcom-rendering/src/types/onwards.ts +++ b/dotcom-rendering/src/types/onwards.ts @@ -24,3 +24,5 @@ export type OnwardsSource = | 'curated-content' | 'newsletters-page' | 'unknown-source'; // We should never see this in the analytics data! + +export type OnwardContainerType = 'more-galleries';