44 */
55import { css } from '@emotion/react' ;
66import {
7+ breakpoints ,
78 from ,
89 headlineBold28 ,
910 space ,
1011 textSans14 ,
1112 textSansBold14 ,
1213} from '@guardian/source/foundations' ;
14+ import { grid } from '../../../grid' ;
15+ import { generateImageURL } from '../../../lib/image' ;
1316import { nestedOphanComponents } from '../../../lib/ophan-helpers' ;
1417import { palette as themePalette } from '../../../palette' ;
15- import type { CustomSubnav , RenderingPage } from '../../../types/customSubnav' ;
18+ import type {
19+ CustomSubnav ,
20+ CustomSubnavImage ,
21+ RenderingPage ,
22+ } from '../../../types/customSubnav' ;
1623
1724type Props = {
1825 customSubNav : CustomSubnav ;
@@ -88,6 +95,122 @@ const articleHeaderStyles = css`
8895 border-right : 1px solid ${ themePalette ( '--masthead-nav-lines' ) } ;
8996` ;
9097
98+ /**
99+ * On fronts, when an image is present the subnav mirrors DirectoryPageNav: a
100+ * fixed-width, centred grid (blue) with the image spanning the full width on the
101+ * top row and the links beneath it. The surrounding row is white (set by the
102+ * Titlepiece wrapper), so only this padded container shows blue.
103+ */
104+ const imageNavStyles = css `
105+ ${ grid . paddedContainer }
106+ position : relative;
107+ background-color : ${ themePalette ( '--masthead-nav-background' ) } ;
108+ ` ;
109+
110+ const imageWrapperStyles = css `
111+ ${ grid . column . all }
112+ grid-row : 1 ;
113+ position : relative;
114+ display : block;
115+ border-bottom : 1px solid ${ themePalette ( '--masthead-nav-lines' ) } ;
116+ ` ;
117+
118+ const headerImageStyles = css `
119+ display : block;
120+ width : 100% ;
121+ height : 210px ;
122+ object-fit : cover;
123+
124+ ${ from . tablet } {
125+ height : 140px ;
126+ }
127+ ` ;
128+
129+ const imageHeaderTextStyles = css `
130+ ${ headlineBold28 }
131+ position : absolute;
132+ left : ${ space [ 3 ] } px;
133+ bottom : ${ space [ 1 ] } px;
134+ color : ${ themePalette ( '--masthead-nav-link-text' ) } ;
135+
136+ ${ from . mobileLandscape } {
137+ left : ${ space [ 5 ] } px;
138+ }
139+ ` ;
140+
141+ const imageListStyles = css `
142+ ${ grid . column . all }
143+ grid-row : 2 ;
144+ ${ textSans14 }
145+ display : flex;
146+ align-items : center;
147+ column-gap : ${ space [ 2 ] } px;
148+ min-height : 28px ;
149+ padding : 0 ${ space [ 3 ] } px;
150+
151+ ${ from . mobileLandscape } {
152+ padding : 0 ${ space [ 5 ] } px;
153+ }
154+ ${ from . tablet } {
155+ min-height : 30px ;
156+ }
157+ ` ;
158+
159+ /** Largest breakpoints first so the <source> media queries cascade correctly. */
160+ const byBreakpointWidthDesc = ( a : CustomSubnavImage , b : CustomSubnavImage ) =>
161+ breakpoints [ b . breakpoint ] - breakpoints [ a . breakpoint ] ;
162+
163+ /**
164+ * Builds a `1x, 2x` srcSet via the Fastly Image Optimiser so each breakpoint's
165+ * image is served at the right width and pixel density (mirrors DirectoryPageNav).
166+ */
167+ const buildSrcSet = ( { imageSrc, breakpoint } : CustomSubnavImage ) =>
168+ `${ generateImageURL ( {
169+ mainImage : imageSrc ,
170+ imageWidth : breakpoints [ breakpoint ] ,
171+ resolution : 'low' ,
172+ } ) } , ${ generateImageURL ( {
173+ mainImage : imageSrc ,
174+ imageWidth : breakpoints [ breakpoint ] ,
175+ resolution : 'high' ,
176+ } ) } 2x`;
177+
178+ const HeaderImage = ( {
179+ images,
180+ headerText,
181+ } : {
182+ images : CustomSubnavImage [ ] ;
183+ headerText : string ;
184+ } ) => {
185+ const sorted = [ ...images ] . sort ( byBreakpointWidthDesc ) ;
186+ /** Smallest breakpoint is the <img> fallback; the rest become <source>s. */
187+ const fallback = sorted . at ( - 1 ) ;
188+ if ( ! fallback ) {
189+ return null ;
190+ }
191+ return (
192+ < picture >
193+ { sorted . slice ( 0 , - 1 ) . map ( ( image ) => (
194+ < source
195+ key = { image . breakpoint }
196+ media = { `(min-width: ${ breakpoints [ image . breakpoint ] } px)` }
197+ srcSet = { buildSrcSet ( image ) }
198+ />
199+ ) ) }
200+ < img
201+ src = { generateImageURL ( {
202+ mainImage : fallback . imageSrc ,
203+ imageWidth : breakpoints [ fallback . breakpoint ] ,
204+ resolution : 'low' ,
205+ } ) }
206+ srcSet = { buildSrcSet ( fallback ) }
207+ alt = { `${ headerText } subnav` }
208+ css = { headerImageStyles }
209+ />
210+ </ picture >
211+ ) ;
212+ } ;
213+
91214/** Sets horizontal scrolling behaviour and removes the scrollbar */
92215const scrollableSubNavStyles = css `
93216 overflow-x : scroll;
@@ -136,6 +259,63 @@ export const CustomSubNav = ({
136259 hasPageSkin,
137260} : Props ) => {
138261 const isArticle = renderingPage === 'article' ;
262+ /** DCR receives images for all platforms; only web images are rendered here. */
263+ const webImages = ( customSubNav . images ?? [ ] ) . filter ( ( image ) =>
264+ image . platforms . includes ( 'web' ) ,
265+ ) ;
266+ const hasHeaderImage = ! isArticle && webImages . length > 0 ;
267+
268+ const linkItems = customSubNav . links . map ( ( { linkText, dotcomPath } ) => (
269+ < li key = { dotcomPath } css = { subnavListItemStyles } >
270+ < a
271+ css = { subnavLinkStyles }
272+ data-src-focus-disabled = { true }
273+ href = { dotcomPath }
274+ data-link-name = { nestedOphanComponents (
275+ 'header' ,
276+ 'custom subnav' ,
277+ linkText ,
278+ ) }
279+ >
280+ { linkText === currentNavLink ? (
281+ < span css = { selectedLink } > { linkText } </ span >
282+ ) : (
283+ linkText
284+ ) }
285+ </ a >
286+ </ li >
287+ ) ) ;
288+
289+ if ( hasHeaderImage ) {
290+ return (
291+ < div
292+ data-component = { `custom-subnav-${ customSubNav . header . headerText } ` }
293+ data-component-id = { customSubNav . id }
294+ data-rendering-page = { renderingPage }
295+ css = { imageNavStyles }
296+ >
297+ < div css = { imageWrapperStyles } >
298+ < HeaderImage
299+ images = { webImages }
300+ headerText = { customSubNav . header . headerText }
301+ />
302+ < span css = { imageHeaderTextStyles } >
303+ { customSubNav . header . headerText }
304+ </ span >
305+ </ div >
306+ < ul
307+ css = { [ imageListStyles , scrollableSubNavStyles ] }
308+ role = "list"
309+ style = { {
310+ '--sub-nav-link' : themePalette ( '--sub-nav-link-header' ) ,
311+ } }
312+ >
313+ { linkItems }
314+ </ ul >
315+ </ div >
316+ ) ;
317+ }
318+
139319 return (
140320 < div
141321 data-component = { `custom-subnav-${ customSubNav . header . headerText } ` }
@@ -162,26 +342,7 @@ export const CustomSubNav = ({
162342 '--sub-nav-link' : themePalette ( '--sub-nav-link-header' ) ,
163343 } }
164344 >
165- { customSubNav . links . map ( ( { linkText, dotcomPath } ) => (
166- < li key = { dotcomPath } css = { subnavListItemStyles } >
167- < a
168- css = { subnavLinkStyles }
169- data-src-focus-disabled = { true }
170- href = { dotcomPath }
171- data-link-name = { nestedOphanComponents (
172- 'header' ,
173- 'custom subnav' ,
174- linkText ,
175- ) }
176- >
177- { linkText === currentNavLink ? (
178- < span css = { selectedLink } > { linkText } </ span >
179- ) : (
180- linkText
181- ) }
182- </ a >
183- </ li >
184- ) ) }
345+ { linkItems }
185346 </ ul >
186347 </ div >
187348 ) ;
0 commit comments