diff --git a/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.stories.tsx b/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.stories.tsx index 8e8800f4f12..570fdb1bae0 100644 --- a/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.stories.tsx +++ b/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.stories.tsx @@ -33,20 +33,51 @@ const customSubNav: CustomSubnav = { pages: [], }; +/** Distinct images per breakpoint so it's obvious which one is being served. */ +const customSubNavWithImages: CustomSubnav = { + ...customSubNav, + images: [ + { + breakpoint: 'mobile', + platforms: ['web'], + imageSrc: + 'https://media.guim.co.uk/6537e163c9164d25ec6102641f6a04fa5ba76560/0_210_5472_3283/master/5472.jpg?width=740&height=140&quality=85&fit=crop&s=none', + }, + { + breakpoint: 'tablet', + platforms: ['web'], + imageSrc: + 'https://media.guim.co.uk/56b42eef576bc04c820da710459acd91082bb37b/0_0_6720_4480/6720.jpg?width=980&height=140&quality=85&fit=crop&s=none', + }, + { + breakpoint: 'desktop', + platforms: ['web'], + imageSrc: + 'https://media.guim.co.uk/c981848745e482e03e23b2ec9402e1f5c5bee6a6/102_73_3282_1848/2000.jpg?width=1300&height=140&quality=85&fit=crop&s=none', + }, + ], +}; + const meta = { component: CustomSubNav, title: 'Components/Masthead/Titlepiece/CustomSubNav', decorators: [ - (Story) => ( -
- -
- ), + (Story, context) => { + const hasImage = + (context.args.customSubNav.images?.length ?? 0) > 0; + return ( +
+ +
+ ); + }, ], render: (args) => , args: { @@ -64,6 +95,14 @@ export const Front = { args: { renderingPage: 'front' }, } satisfies Story; +/** On fronts, a web image is shown per breakpoint; resize the viewport to switch between mobile/tablet/desktop. */ +export const FrontWithImage = { + args: { + renderingPage: 'front', + customSubNav: customSubNavWithImages, + }, +} satisfies Story; + export const Article = { args: { renderingPage: 'article' }, parameters: { diff --git a/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.tsx b/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.tsx index fd6f63a1b27..4b3f54cd1ae 100644 --- a/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.tsx +++ b/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.tsx @@ -4,15 +4,22 @@ */ import { css } from '@emotion/react'; import { + breakpoints, from, headlineBold28, space, textSans14, textSansBold14, } from '@guardian/source/foundations'; +import { grid } from '../../../grid'; +import { generateImageURL } from '../../../lib/image'; import { nestedOphanComponents } from '../../../lib/ophan-helpers'; import { palette as themePalette } from '../../../palette'; -import type { CustomSubnav, RenderingPage } from '../../../types/customSubnav'; +import type { + CustomSubnav, + CustomSubnavImage, + RenderingPage, +} from '../../../types/customSubnav'; type Props = { customSubNav: CustomSubnav; @@ -88,6 +95,122 @@ const articleHeaderStyles = css` border-right: 1px solid ${themePalette('--masthead-nav-lines')}; `; +/** + * On fronts, when an image is present the subnav mirrors DirectoryPageNav: a + * fixed-width, centred grid (blue) with the image spanning the full width on the + * top row and the links beneath it. The surrounding row is white (set by the + * Titlepiece wrapper), so only this padded container shows blue. + */ +const imageNavStyles = css` + ${grid.paddedContainer} + position: relative; + background-color: ${themePalette('--masthead-nav-background')}; +`; + +const imageWrapperStyles = css` + ${grid.column.all} + grid-row: 1; + position: relative; + display: block; + border-bottom: 1px solid ${themePalette('--masthead-nav-lines')}; +`; + +const headerImageStyles = css` + display: block; + width: 100%; + height: 210px; + object-fit: cover; + + ${from.tablet} { + height: 140px; + } +`; + +const imageHeaderTextStyles = css` + ${headlineBold28} + position: absolute; + left: ${space[3]}px; + bottom: ${space[1]}px; + color: ${themePalette('--masthead-nav-link-text')}; + + ${from.mobileLandscape} { + left: ${space[5]}px; + } +`; + +const imageListStyles = css` + ${grid.column.all} + grid-row: 2; + ${textSans14} + display: flex; + align-items: center; + column-gap: ${space[2]}px; + min-height: 28px; + padding: 0 ${space[3]}px; + + ${from.mobileLandscape} { + padding: 0 ${space[5]}px; + } + ${from.tablet} { + min-height: 30px; + } +`; + +/** Largest breakpoints first so the media queries cascade correctly. */ +const byBreakpointWidthDesc = (a: CustomSubnavImage, b: CustomSubnavImage) => + breakpoints[b.breakpoint] - breakpoints[a.breakpoint]; + +/** + * Builds a `1x, 2x` srcSet via the Fastly Image Optimiser so each breakpoint's + * image is served at the right width and pixel density (mirrors DirectoryPageNav). + */ +const buildSrcSet = ({ imageSrc, breakpoint }: CustomSubnavImage) => + `${generateImageURL({ + mainImage: imageSrc, + imageWidth: breakpoints[breakpoint], + resolution: 'low', + })}, ${generateImageURL({ + mainImage: imageSrc, + imageWidth: breakpoints[breakpoint], + resolution: 'high', + })} 2x`; + +const HeaderImage = ({ + images, + headerText, +}: { + images: CustomSubnavImage[]; + headerText: string; +}) => { + const sorted = [...images].sort(byBreakpointWidthDesc); + /** Smallest breakpoint is the fallback; the rest become s. */ + const fallback = sorted.at(-1); + if (!fallback) { + return null; + } + return ( + + {sorted.slice(0, -1).map((image) => ( + + ))} + {`${headerText} + + ); +}; + /** Sets horizontal scrolling behaviour and removes the scrollbar */ const scrollableSubNavStyles = css` overflow-x: scroll; @@ -136,6 +259,63 @@ export const CustomSubNav = ({ hasPageSkin, }: Props) => { const isArticle = renderingPage === 'article'; + /** DCR receives images for all platforms; only web images are rendered here. */ + const webImages = (customSubNav.images ?? []).filter((image) => + image.platforms.includes('web'), + ); + const hasHeaderImage = !isArticle && webImages.length > 0; + + const linkItems = customSubNav.links.map(({ linkText, dotcomPath }) => ( +
  • + + {linkText === currentNavLink ? ( + {linkText} + ) : ( + linkText + )} + +
  • + )); + + if (hasHeaderImage) { + return ( +
    +
    + + + {customSubNav.header.headerText} + +
    + +
    + ); + } + return (
    - {customSubNav.links.map(({ linkText, dotcomPath }) => ( -
  • - - {linkText === currentNavLink ? ( - {linkText} - ) : ( - linkText - )} - -
  • - ))} + {linkItems}
    ); diff --git a/dotcom-rendering/src/components/Titlepiece.island.tsx b/dotcom-rendering/src/components/Titlepiece.island.tsx index 3a49e2971ce..cba079f21f3 100644 --- a/dotcom-rendering/src/components/Titlepiece.island.tsx +++ b/dotcom-rendering/src/components/Titlepiece.island.tsx @@ -2,6 +2,7 @@ import { css, Global } from '@emotion/react'; import { from, headlineBold14, + palette as sourcePalette, space, until, visuallyHidden, @@ -316,6 +317,14 @@ const fadeStyles = css` ${themePalette('--masthead-nav-background')} 100% ); `; + +/** For the image custom subnav the row is white and full-bleed; CustomSubNav + * centres its own blue padded container on top (mirrors DirectoryPageNav). */ +const customSubnavImageWrapper = css` + grid-column: viewport-start / viewport-end; + grid-row: 3; + background-color: ${sourcePalette.neutral[100]}; +`; export const Titlepiece = ({ nav, editionId, @@ -327,6 +336,13 @@ export const Titlepiece = ({ }: Props) => { const { showBanner } = useEditionSwitcherBanner(pageId, editionId); + const hasCustomSubnavImage = + customSubnav?.renderingPage === 'front' && + (customSubnav.data.images?.some((image) => + image.platforms.includes('web'), + ) ?? + false); + return ( { } }; -const isCodeGridUrl = (url: URL) => url.hostname === 'media.guimcode.co.uk'; +const isCodeGridUrl = (url: URL) => + url.hostname === 'media.guimcode.co.uk' || + url.hostname === 'uploads.guimcode.co.uk'; /** * Generates a URL for calling the Fastly Image Optimiser. diff --git a/dotcom-rendering/src/model/newsletter-page-schema.json b/dotcom-rendering/src/model/newsletter-page-schema.json index a01a2a4d791..0ad257140fc 100644 --- a/dotcom-rendering/src/model/newsletter-page-schema.json +++ b/dotcom-rendering/src/model/newsletter-page-schema.json @@ -497,19 +497,42 @@ "type": "string" }, "breakpoint": { - "enum": [ - "mobile", - "tablet", - "web" - ], - "type": "string" + "$ref": "#/definitions/CustomSubnavImageBreakpoint" + }, + "platforms": { + "type": "array", + "items": { + "$ref": "#/definitions/CustomSubnavImagePlatform" + } } }, "required": [ "breakpoint", - "imageSrc" + "imageSrc", + "platforms" ] }, + "CustomSubnavImageBreakpoint": { + "enum": [ + "desktop", + "leftCol", + "mobile", + "mobileLandscape", + "mobileMedium", + "phablet", + "tablet", + "wide" + ], + "type": "string" + }, + "CustomSubnavImagePlatform": { + "enum": [ + "android", + "ios", + "web" + ], + "type": "string" + }, "ReaderRevenuePositions": { "type": "object", "properties": { diff --git a/dotcom-rendering/src/types/customSubnav.ts b/dotcom-rendering/src/types/customSubnav.ts index d811bce2233..8ce07f183b8 100644 --- a/dotcom-rendering/src/types/customSubnav.ts +++ b/dotcom-rendering/src/types/customSubnav.ts @@ -15,9 +15,22 @@ export interface CustomSubnavHeader { dotcomPath?: string; copy: string; } +export type CustomSubnavImageBreakpoint = + | 'mobile' + | 'mobileMedium' + | 'mobileLandscape' + | 'phablet' + | 'tablet' + | 'desktop' + | 'leftCol' + | 'wide'; + +export type CustomSubnavImagePlatform = 'ios' | 'web' | 'android'; + export interface CustomSubnavImage { imageSrc: string; - breakpoint: 'mobile' | 'tablet' | 'web'; + breakpoint: CustomSubnavImageBreakpoint; + platforms: CustomSubnavImagePlatform[]; } export interface CustomSubnavTargetedPage {