Skip to content

Commit 6e71fcf

Browse files
authored
Images in custom subnav (#16664)
* Wire custom subnav to articles and style component correctly * Small tweaks, bundling together rendering page and custom subnav data in wiring the component * temporarily use images in fronts custom subnav * update type/schemas for subnav images and use actual data * prettier fix palette declaration * add image story * add alt text and use generate image url * add uploads.guimcode to iscodegrid url check * tweak story and mobile image height
1 parent 3f450c8 commit 6e71fcf

12 files changed

Lines changed: 479 additions & 83 deletions

File tree

dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.stories.tsx

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,20 +33,51 @@ const customSubNav: CustomSubnav = {
3333
pages: [],
3434
};
3535

36+
/** Distinct images per breakpoint so it's obvious which one is being served. */
37+
const customSubNavWithImages: CustomSubnav = {
38+
...customSubNav,
39+
images: [
40+
{
41+
breakpoint: 'mobile',
42+
platforms: ['web'],
43+
imageSrc:
44+
'https://media.guim.co.uk/6537e163c9164d25ec6102641f6a04fa5ba76560/0_210_5472_3283/master/5472.jpg?width=740&height=140&quality=85&fit=crop&s=none',
45+
},
46+
{
47+
breakpoint: 'tablet',
48+
platforms: ['web'],
49+
imageSrc:
50+
'https://media.guim.co.uk/56b42eef576bc04c820da710459acd91082bb37b/0_0_6720_4480/6720.jpg?width=980&height=140&quality=85&fit=crop&s=none',
51+
},
52+
{
53+
breakpoint: 'desktop',
54+
platforms: ['web'],
55+
imageSrc:
56+
'https://media.guim.co.uk/c981848745e482e03e23b2ec9402e1f5c5bee6a6/102_73_3282_1848/2000.jpg?width=1300&height=140&quality=85&fit=crop&s=none',
57+
},
58+
],
59+
};
60+
3661
const meta = {
3762
component: CustomSubNav,
3863
title: 'Components/Masthead/Titlepiece/CustomSubNav',
3964
decorators: [
40-
(Story) => (
41-
<div
42-
css={css`
43-
background-color: ${sourcePalette.brand[400]};
44-
padding: ${space[3]}px;
45-
`}
46-
>
47-
<Story />
48-
</div>
49-
),
65+
(Story, context) => {
66+
const hasImage =
67+
(context.args.customSubNav.images?.length ?? 0) > 0;
68+
return (
69+
<div
70+
css={css`
71+
background-color: ${hasImage
72+
? sourcePalette.neutral[100]
73+
: sourcePalette.brand[400]};
74+
padding: ${hasImage ? 0 : space[2]}px;
75+
`}
76+
>
77+
<Story />
78+
</div>
79+
);
80+
},
5081
],
5182
render: (args) => <CustomSubNav {...args} />,
5283
args: {
@@ -64,6 +95,14 @@ export const Front = {
6495
args: { renderingPage: 'front' },
6596
} satisfies Story;
6697

98+
/** On fronts, a web image is shown per breakpoint; resize the viewport to switch between mobile/tablet/desktop. */
99+
export const FrontWithImage = {
100+
args: {
101+
renderingPage: 'front',
102+
customSubNav: customSubNavWithImages,
103+
},
104+
} satisfies Story;
105+
67106
export const Article = {
68107
args: { renderingPage: 'article' },
69108
parameters: {

dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.tsx

Lines changed: 182 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,22 @@
44
*/
55
import { css } from '@emotion/react';
66
import {
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';
1316
import { nestedOphanComponents } from '../../../lib/ophan-helpers';
1417
import { 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

1724
type 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 */
92215
const 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
);

dotcom-rendering/src/components/Titlepiece.island.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { css, Global } from '@emotion/react';
22
import {
33
from,
44
headlineBold14,
5+
palette as sourcePalette,
56
space,
67
until,
78
visuallyHidden,
@@ -316,6 +317,14 @@ const fadeStyles = css`
316317
${themePalette('--masthead-nav-background')} 100%
317318
);
318319
`;
320+
321+
/** For the image custom subnav the row is white and full-bleed; CustomSubNav
322+
* centres its own blue padded container on top (mirrors DirectoryPageNav). */
323+
const customSubnavImageWrapper = css`
324+
grid-column: viewport-start / viewport-end;
325+
grid-row: 3;
326+
background-color: ${sourcePalette.neutral[100]};
327+
`;
319328
export const Titlepiece = ({
320329
nav,
321330
editionId,
@@ -327,6 +336,13 @@ export const Titlepiece = ({
327336
}: Props) => {
328337
const { showBanner } = useEditionSwitcherBanner(pageId, editionId);
329338

339+
const hasCustomSubnavImage =
340+
customSubnav?.renderingPage === 'front' &&
341+
(customSubnav.data.images?.some((image) =>
342+
image.platforms.includes('web'),
343+
) ??
344+
false);
345+
330346
return (
331347
<Grid
332348
type="nav"
@@ -590,7 +606,11 @@ export const Titlepiece = ({
590606

591607
{customSubnav && (
592608
<div
593-
css={subNavWrapper}
609+
css={
610+
hasCustomSubnavImage
611+
? customSubnavImageWrapper
612+
: subNavWrapper
613+
}
594614
data-print-layout="hide"
595615
data-testid="sub-nav"
596616
data-component="sub-nav"

0 commit comments

Comments
 (0)