Skip to content

Commit 47ca401

Browse files
authored
Merge pull request #14135 from guardian/add-gallery-body-component
Add gallery body image component
2 parents 2383bc8 + c27b00d commit 47ca401

11 files changed

+391
-53
lines changed

dotcom-rendering/src/components/ArticleMeta.apps.stories.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,8 @@ export const GalleryDesign = {
207207
renderingTarget: 'Apps',
208208
},
209209
colourSchemeBackground: {
210-
light: palette('--article-background'),
211-
dark: palette('--article-background'),
210+
light: palette('--article-inner-background'),
211+
dark: palette('--article-inner-background'),
212212
},
213213
},
214214
} satisfies Story;
@@ -240,8 +240,8 @@ export const GalleryLabsWithBranding = {
240240
renderingTarget: 'Apps',
241241
},
242242
colourSchemeBackground: {
243-
light: palette('--article-background'),
244-
dark: palette('--article-background'),
243+
light: palette('--article-inner-background'),
244+
dark: palette('--article-inner-background'),
245245
},
246246
},
247247
} satisfies Story;

dotcom-rendering/src/components/ArticleMeta.web.stories.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -349,8 +349,8 @@ export const GalleryDesign = {
349349
design: ArticleDesign.Gallery,
350350
}),
351351
colourSchemeBackground: {
352-
light: palette('--article-background'),
353-
dark: palette('--article-background'),
352+
light: palette('--article-inner-background'),
353+
dark: palette('--article-inner-background'),
354354
},
355355
},
356356
decorators: [leftColumnDecorator],
@@ -383,8 +383,8 @@ export const GalleryLabsWithBranding = {
383383

384384
parameters: {
385385
colourSchemeBackground: {
386-
light: palette('--article-background'),
387-
dark: palette('--article-background'),
386+
light: palette('--article-inner-background'),
387+
dark: palette('--article-inner-background'),
388388
},
389389
},
390390
decorators: [leftColumnDecorator],
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { css } from '@emotion/react';
2+
import { headlineMedium17, space } from '@guardian/source/foundations';
3+
import { type ReactNode } from 'react';
4+
import sanitise, { type IOptions } from 'sanitize-html';
5+
import { getAttrs, parseHtml } from '../lib/domUtils';
6+
import { palette } from '../palette';
7+
8+
/**
9+
* https://www.npmjs.com/package/sanitize-html#default-options
10+
*/
11+
const sanitiserOptions: IOptions = {
12+
// We allow all tags, which includes script & style which are potentially vulnerable
13+
// `allowVulnerableTags: true` suppresses this warning
14+
allowVulnerableTags: true,
15+
allowedTags: false, // Leave tags from CAPI alone
16+
allowedAttributes: false, // Leave attributes from CAPI alone
17+
};
18+
19+
const renderTextElement = (node: Node, key: number): ReactNode => {
20+
const text = node.textContent?.trim() ?? '';
21+
const children = Array.from(node.childNodes).map(renderTextElement);
22+
23+
switch (node.nodeName) {
24+
case 'STRONG':
25+
return text === '' ? null : (
26+
<strong
27+
css={css`
28+
display: block;
29+
${headlineMedium17}
30+
padding: ${space[2]}px 0 ${space[3]}px;
31+
`}
32+
key={key}
33+
>
34+
{children}
35+
</strong>
36+
);
37+
case 'EM':
38+
return text === '' ? null : <em key={key}>{children}</em>;
39+
case 'A': {
40+
const attrs = getAttrs(node);
41+
42+
return (
43+
<a
44+
css={css`
45+
text-decoration: none;
46+
color: ${palette('--caption-link')};
47+
border-bottom: 1px solid
48+
${palette('--article-link-border')};
49+
:hover {
50+
border-bottom: 1px solid
51+
${palette('--article-link-border-hover')};
52+
}
53+
`}
54+
href={attrs?.getNamedItem('href')?.value}
55+
target={attrs?.getNamedItem('target')?.value}
56+
data-link-name={
57+
attrs?.getNamedItem('data-link-name')?.value
58+
}
59+
data-component={
60+
attrs?.getNamedItem('data-component')?.value
61+
}
62+
key={key}
63+
>
64+
{children}
65+
</a>
66+
);
67+
}
68+
case '#text':
69+
return node.textContent;
70+
default:
71+
return null;
72+
}
73+
};
74+
75+
type Props = {
76+
html: string;
77+
};
78+
79+
export const CaptionText = ({ html }: Props) => (
80+
<>
81+
{Array.from(parseHtml(sanitise(html, sanitiserOptions)).childNodes).map(
82+
renderTextElement,
83+
)}
84+
</>
85+
);
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { css } from '@emotion/react';
2+
import { between, from, space, textSans12 } from '@guardian/source/foundations';
3+
import { grid } from '../grid';
4+
import { type ArticleFormat } from '../lib/articleFormat';
5+
import { palette } from '../palette';
6+
import { CaptionText } from './CaptionText';
7+
import { Island } from './Island';
8+
import { ShareButton } from './ShareButton.importable';
9+
10+
type Props = {
11+
captionHtml?: string;
12+
credit?: string;
13+
displayCredit?: boolean;
14+
format: ArticleFormat;
15+
pageId: string;
16+
webTitle: string;
17+
};
18+
19+
const styles = css`
20+
${grid.column.centre}
21+
color: ${palette('--caption-text')};
22+
${textSans12}
23+
padding-bottom: ${space[6]}px;
24+
25+
${between.tablet.and.desktop} {
26+
padding-left: ${space[5]}px;
27+
padding-right: ${space[5]}px;
28+
}
29+
30+
${between.desktop.and.leftCol} {
31+
${grid.column.right}
32+
33+
position: relative; /* allows the ::before to be positioned relative to this */
34+
35+
&::before {
36+
content: '';
37+
position: absolute;
38+
left: -10px; /* 10px to the left of this element */
39+
top: 0;
40+
bottom: 0;
41+
width: 1px;
42+
background-color: ${palette('--article-border')};
43+
}
44+
}
45+
46+
${from.leftCol} {
47+
${grid.column.left}
48+
49+
position: relative; /* allows the ::before to be positioned relative to this */
50+
51+
&::after {
52+
content: '';
53+
position: absolute;
54+
right: -10px;
55+
top: 0;
56+
bottom: 0;
57+
width: 1px;
58+
background-color: ${palette('--article-border')};
59+
}
60+
}
61+
`;
62+
63+
export const GalleryCaption = ({
64+
captionHtml,
65+
credit,
66+
displayCredit,
67+
format,
68+
pageId,
69+
webTitle,
70+
}: Props) => {
71+
const emptyCaption = captionHtml === undefined || captionHtml.trim() === '';
72+
const hideCredit =
73+
displayCredit === false || credit === undefined || credit === '';
74+
75+
if (emptyCaption && hideCredit) {
76+
return null;
77+
}
78+
79+
return (
80+
<figcaption css={styles}>
81+
{emptyCaption ? null : <CaptionText html={captionHtml} />}
82+
{hideCredit ? null : (
83+
<small
84+
css={css`
85+
display: block;
86+
padding: ${space[2]}px 0 ${space[2]}px;
87+
`}
88+
>
89+
{credit}
90+
</small>
91+
)}
92+
<Island priority="feature" defer={{ until: 'visible' }}>
93+
<ShareButton
94+
format={format}
95+
pageId={pageId}
96+
webTitle={webTitle}
97+
context="ImageCaption"
98+
/>
99+
</Island>
100+
</figcaption>
101+
);
102+
};
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { css } from '@emotion/react';
2+
import { from, space, until } from '@guardian/source/foundations';
3+
import { grid } from '../grid';
4+
import { type ArticleFormat } from '../lib/articleFormat';
5+
import { getImage } from '../lib/image';
6+
import { palette } from '../palette';
7+
import { type ImageBlockElement } from '../types/content';
8+
import { GalleryCaption } from './GalleryCaption';
9+
import { Picture } from './Picture';
10+
11+
type Props = {
12+
format: ArticleFormat;
13+
image: ImageBlockElement;
14+
pageId: string;
15+
webTitle: string;
16+
};
17+
18+
const styles = css`
19+
${grid.paddedContainer}
20+
grid-auto-flow: row dense;
21+
background-color: ${palette('--article-inner-background')};
22+
23+
${until.tablet} {
24+
border-top: 1px solid ${palette('--article-border')};
25+
padding-top: ${space[1]}px;
26+
}
27+
28+
${from.tablet} {
29+
border-left: 1px solid ${palette('--article-border')};
30+
border-right: 1px solid ${palette('--article-border')};
31+
}
32+
`;
33+
34+
export const GalleryImage = ({ format, image, pageId, webTitle }: Props) => {
35+
const asset = getImage(image.media.allImages);
36+
37+
if (asset === undefined) {
38+
return null;
39+
}
40+
41+
const width = parseInt(asset.fields.width, 10);
42+
const height = parseInt(asset.fields.height, 10);
43+
44+
if (isNaN(width) || isNaN(height)) {
45+
return null;
46+
}
47+
48+
return (
49+
<figure css={styles}>
50+
<Picture
51+
alt={image.data.alt ?? ''}
52+
format={format}
53+
role={image.role}
54+
master={asset.url}
55+
width={width}
56+
height={height}
57+
loading="lazy"
58+
/>
59+
<GalleryCaption
60+
captionHtml={image.data.caption}
61+
credit={image.data.credit}
62+
displayCredit={image.displayCredit}
63+
format={format}
64+
pageId={pageId}
65+
webTitle={webTitle}
66+
/>
67+
</figure>
68+
);
69+
};

dotcom-rendering/src/components/Picture.tsx

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { css } from '@emotion/react';
2-
import { breakpoints } from '@guardian/source/foundations';
3-
import { Fragment, useCallback, useEffect, useState } from 'react';
2+
import { breakpoints, from, space } from '@guardian/source/foundations';
3+
import {
4+
type CSSProperties,
5+
Fragment,
6+
useCallback,
7+
useEffect,
8+
useState,
9+
} from 'react';
10+
import { grid } from '../grid';
411
import {
512
ArticleDesign,
613
ArticleDisplay,
@@ -465,15 +472,50 @@ export const Sources = ({ sources }: { sources: ImageSource[] }) => {
465472
);
466473
};
467474

468-
const styles = ({ design }: ArticleFormat, isLightbox: boolean) => {
475+
const galleryBodyImageStyles = css`
476+
${grid.column.all}
477+
478+
${from.tablet} {
479+
${grid.column.centre}
480+
}
481+
482+
${from.desktop} {
483+
padding-bottom: ${space[10]}px;
484+
}
485+
486+
${from.leftCol} {
487+
${grid.between('centre-column-start', 'right-column-end')}
488+
}
489+
`;
490+
491+
/**
492+
* This ensures that the image height never goes above 96vh.
493+
* The ratio parameter should be width:height.
494+
*/
495+
const imageMaxWidth = (
496+
design: ArticleDesign,
497+
ratio: number,
498+
): CSSProperties | undefined =>
499+
design === ArticleDesign.Gallery
500+
? { maxWidth: `calc(${ratio} * 96vh)` }
501+
: undefined;
502+
503+
const styles = (
504+
{ design }: ArticleFormat,
505+
isLightbox: boolean,
506+
isMainMedia: boolean,
507+
) => {
469508
if (design === ArticleDesign.Gallery) {
470-
return css`
471-
img {
472-
width: 100%;
473-
height: 100%;
474-
object-fit: cover;
475-
}
476-
`;
509+
return css(
510+
css`
511+
img {
512+
width: 100%;
513+
height: 100%;
514+
object-fit: cover;
515+
}
516+
`,
517+
isMainMedia ? undefined : galleryBodyImageStyles,
518+
);
477519
}
478520
return isLightbox ? flex : block;
479521
};
@@ -539,7 +581,10 @@ export const Picture = ({
539581
const fallbackSource = getFallbackSource(sources);
540582

541583
return (
542-
<picture css={styles(format, isLightbox)}>
584+
<picture
585+
css={styles(format, isLightbox, isMainMedia)}
586+
style={imageMaxWidth(format.design, 1 / ratio)}
587+
>
543588
{/* Immersive Main Media images get additional sources specifically for when in portrait orientation */}
544589
{format.display === ArticleDisplay.Immersive && isMainMedia && (
545590
<>

dotcom-rendering/src/components/ShareButton.importable.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ type Props = {
2424

2525
type ButtonKind = 'native' | 'copy' | 'email';
2626

27-
type Context = 'ArticleMeta' | 'LiveBlock' | 'SubMeta';
27+
type Context = 'ArticleMeta' | 'LiveBlock' | 'SubMeta' | 'ImageCaption';
2828

2929
const sharedButtonStyles = (sizeXSmall: boolean) => css`
3030
transition: none;

0 commit comments

Comments
 (0)