Skip to content

Commit 35611dd

Browse files
marjisoundJamieB-gu
andcommitted
Add gallery body image component
Co-authored-by: Jamie B <[email protected]>
1 parent 41d0b51 commit 35611dd

File tree

6 files changed

+372
-38
lines changed

6 files changed

+372
-38
lines changed
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="ArticleMeta" // TODO: update context to GalleryImage
98+
/>
99+
</Island>
100+
</figcaption>
101+
);
102+
};
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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.container}
20+
grid-auto-flow: row dense;
21+
column-gap: ${space[5]}px;
22+
23+
${until.tablet} {
24+
border-top: 1px solid ${palette('--article-border')};
25+
padding-top: ${space[1]}px;
26+
}
27+
28+
${from.tablet} {
29+
&::before {
30+
${grid.between('grid-start', 'centre-column-start')}
31+
grid-row: span 2;
32+
content: '';
33+
background-color: ${palette('--article-background')};
34+
border-right: 1px solid ${palette('--article-border')};
35+
}
36+
37+
&::after {
38+
${grid.between('centre-column-end', 'grid-end')}
39+
grid-row: span 2;
40+
content: '';
41+
background-color: ${palette('--article-background')};
42+
border-left: 1px solid ${palette('--article-border')};
43+
}
44+
}
45+
46+
${from.desktop} {
47+
&::after {
48+
${grid.between('right-column-end', 'grid-end')}
49+
}
50+
}
51+
52+
${from.leftCol} {
53+
&::before {
54+
${grid.between('grid-start', 'left-column-start')}
55+
}
56+
}
57+
`;
58+
59+
export const GalleryImage = ({ format, image, pageId, webTitle }: Props) => {
60+
const asset = getImage(image.media.allImages);
61+
62+
if (asset === undefined) {
63+
return null;
64+
}
65+
66+
const width = parseInt(asset.fields.width, 10);
67+
const height = parseInt(asset.fields.height, 10);
68+
69+
if (isNaN(width) || isNaN(height)) {
70+
return null;
71+
}
72+
73+
return (
74+
<figure css={styles}>
75+
<Picture
76+
alt={image.data.alt ?? ''}
77+
format={format}
78+
role={image.role}
79+
master={asset.url}
80+
width={width}
81+
height={height}
82+
loading="lazy"
83+
/>
84+
<GalleryCaption
85+
captionHtml={image.data.caption}
86+
credit={image.data.credit}
87+
displayCredit={image.displayCredit}
88+
format={format}
89+
pageId={pageId}
90+
webTitle={webTitle}
91+
/>
92+
</figure>
93+
);
94+
};

dotcom-rendering/src/components/Picture.tsx

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { css } from '@emotion/react';
2-
import { breakpoints } from '@guardian/source/foundations';
2+
import { breakpoints, from, space } from '@guardian/source/foundations';
33
import { Fragment, useCallback, useEffect, useState } from 'react';
4+
import { grid } from '../grid';
45
import {
56
ArticleDesign,
67
ArticleDisplay,
@@ -465,15 +466,38 @@ export const Sources = ({ sources }: { sources: ImageSource[] }) => {
465466
);
466467
};
467468

468-
const styles = ({ design }: ArticleFormat, isLightbox: boolean) => {
469+
const galleryBodyImageStyles = css`
470+
${grid.column.all}
471+
472+
${from.tablet} {
473+
${grid.column.centre}
474+
}
475+
476+
${from.desktop} {
477+
padding-bottom: ${space[10]}px;
478+
}
479+
480+
${from.leftCol} {
481+
${grid.between('centre-column-start', 'right-column-end')}
482+
}
483+
`;
484+
485+
const styles = (
486+
{ design }: ArticleFormat,
487+
isLightbox: boolean,
488+
isMainMedia: boolean,
489+
) => {
469490
if (design === ArticleDesign.Gallery) {
470-
return css`
471-
img {
472-
width: 100%;
473-
height: 100%;
474-
object-fit: cover;
475-
}
476-
`;
491+
return css(
492+
css`
493+
img {
494+
width: 100%;
495+
height: 100%;
496+
object-fit: cover;
497+
}
498+
`,
499+
isMainMedia ? undefined : galleryBodyImageStyles,
500+
);
477501
}
478502
return isLightbox ? flex : block;
479503
};
@@ -539,7 +563,7 @@ export const Picture = ({
539563
const fallbackSource = getFallbackSource(sources);
540564

541565
return (
542-
<picture css={styles(format, isLightbox)}>
566+
<picture css={styles(format, isLightbox, isMainMedia)}>
543567
{/* Immersive Main Media images get additional sources specifically for when in portrait orientation */}
544568
{format.display === ArticleDisplay.Immersive && isMainMedia && (
545569
<>

dotcom-rendering/src/layouts/GalleryLayout.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ArticleHeadline } from '../components/ArticleHeadline';
44
import { ArticleMetaApps } from '../components/ArticleMeta.apps';
55
import { ArticleMeta } from '../components/ArticleMeta.web';
66
import { ArticleTitle } from '../components/ArticleTitle';
7+
import { GalleryImage } from '../components/GalleryImage';
78
import { MainMediaGallery } from '../components/MainMediaGallery';
89
import { Masthead } from '../components/Masthead/Masthead';
910
import { Standfirst } from '../components/Standfirst';
@@ -66,7 +67,7 @@ export const GalleryLayout = (props: WebProps | AppProps) => {
6667
)}
6768
<main
6869
css={{
69-
backgroundColor: palette('--article-background'),
70+
backgroundColor: palette('--article-inner-background'),
7071
}}
7172
>
7273
<div css={border}>Labs header</div>
@@ -146,7 +147,6 @@ export const GalleryLayout = (props: WebProps | AppProps) => {
146147
) : null}
147148
<div
148149
css={[
149-
border,
150150
css`
151151
${grid.column.centre}
152152
${from.leftCol} {
@@ -158,15 +158,20 @@ export const GalleryLayout = (props: WebProps | AppProps) => {
158158
Main media caption
159159
</div>
160160
<div
161-
css={[
162-
border,
163-
grid.between('centre-column-start', 'grid-end'),
164-
]}
161+
css={[grid.between('centre-column-start', 'grid-end')]}
165162
>
166163
Meta
167164
</div>
168165
</header>
169-
<div css={border}>Body</div>
166+
{gallery.images.map((element, idx) => (
167+
<GalleryImage
168+
image={element}
169+
format={format}
170+
pageId={frontendData.pageId}
171+
webTitle={frontendData.webTitle}
172+
key={idx}
173+
/>
174+
))}
170175
<div css={border}>Submeta</div>
171176
</main>
172177
</>

0 commit comments

Comments
 (0)