diff --git a/dotcom-rendering/src/grid.ts b/dotcom-rendering/src/grid.ts
index 8121c5c5397..9fe131c12a6 100644
--- a/dotcom-rendering/src/grid.ts
+++ b/dotcom-rendering/src/grid.ts
@@ -1,9 +1,11 @@
// ----- Imports ----- //
import {
+ between as betweenBreakpoint,
breakpoints,
from as fromBreakpoint,
} from '@guardian/source/foundations';
+import { palette as themePalette } from './palette';
// ----- Columns & Lines ----- //
@@ -83,25 +85,78 @@ const paddedContainer = `
}
`;
-// ----- API ----- //
+// ----- Vertical Rules ----- //
+
+type VerticalRuleOptions = {
+ centre?: boolean;
+};
/**
- * Ask the element to span all grid columns between two grid lines. The lines
- * can be specified either by `Line` name or by number.
- * @param from The grid line to start from, either a `Line` name or a number.
- * @param to The grid line to end at, either a `Line` name or a number.
- * @returns {string} CSS to place the element on the grid.
+ * Render Guardian grid vertical rules.
*
- * @example
Will place the element in the centre column.
- * const styles = css`
- * ${grid.between('centre-column-start', 'centre-column-end')}
- * `;
+ * Left and right rules are always present.
+ * A centre rule can optionally be enabled.
*
- * @example Will place the element between lines 3 and 5.
- * const styles = css`
- * ${grid.between(3, 5)}
- * `;
+ * Usage:
+ * css([grid.container, grid.verticalRules()])
+ * css([grid.container, grid.verticalRules({ centre: true })])
*/
+const optionalCentreRule = `/* CENTRE RULE */
+ & > *:first-child::before {
+ grid-column: centre-column-start;
+ justify-self: start;
+ transform: var(--centre-transform);
+ }`;
+
+const verticalRules = (options: VerticalRuleOptions = {}): string => `
+ ${fromBreakpoint.tablet} {
+ position: relative;
+
+ --centre-transform: translateX(-${columnGap});
+
+ ${fromBreakpoint.leftCol} {
+ --centre-transform: translateX(calc(-${columnGap} / 2));
+ }
+
+ &::before,
+ &::after
+ ${options.centre ? ', & > *:first-child::before' : ''} {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 1px;
+ background-color: ${themePalette('--article-border')};
+ pointer-events: none;
+ content: '';
+ }
+
+ /* LEFT OUTER RULE */
+ &::before {
+ grid-column: centre-column-start;
+ justify-self: start;
+ transform: translateX(-${columnGap});
+
+ ${fromBreakpoint.leftCol} {
+ grid-column: left-column-start;
+ }
+ }
+
+ /* RIGHT OUTER RULE */
+ &::after {
+ grid-column: right-column-end;
+ justify-self: start;
+ transform: translateX(-1px);
+
+ ${betweenBreakpoint.tablet.and.desktop} {
+ grid-column: centre-column-end;
+ }
+ }
+
+ ${options.centre ? optionalCentreRule : ''}
+`;
+
+// ----- API ----- //
+
const between = (from: Line | number, to: Line | number): string => `
grid-column: ${from} / ${to};
`;
@@ -182,8 +237,14 @@ const grid = {
* breakpoint.
*/
mobileColumnGap,
+
+ verticalRules,
} as const;
+// ----- Types ----- //
+type ColumnPreset = keyof typeof grid.column;
+
// ----- Exports ----- //
+export type { Line, ColumnPreset };
export { grid };
diff --git a/dotcom-rendering/src/layouts/StandardLayout.tsx b/dotcom-rendering/src/layouts/StandardLayout.tsx
index ebe454b335d..23801b43ebf 100644
--- a/dotcom-rendering/src/layouts/StandardLayout.tsx
+++ b/dotcom-rendering/src/layouts/StandardLayout.tsx
@@ -1,7 +1,6 @@
-import { css } from '@emotion/react';
+import { css, type SerializedStyles } from '@emotion/react';
import { log } from '@guardian/libs';
import {
- from,
palette as sourcePalette,
space,
until,
@@ -19,7 +18,6 @@ import { ArticleHeadline } from '../components/ArticleHeadline';
import { ArticleMetaApps } from '../components/ArticleMeta.apps';
import { ArticleMeta } from '../components/ArticleMeta.web';
import { ArticleTitle } from '../components/ArticleTitle';
-import { Border } from '../components/Border';
import { Carousel } from '../components/Carousel.importable';
import { DecideLines } from '../components/DecideLines';
import { DirectoryPageNav } from '../components/DirectoryPageNav';
@@ -30,7 +28,6 @@ import { Footer } from '../components/Footer';
import { GetMatchNav } from '../components/GetMatchNav.importable';
import { GetMatchStats } from '../components/GetMatchStats.importable';
import { GetMatchTabs } from '../components/GetMatchTabs.importable';
-import { GridItem } from '../components/GridItem';
import { GuardianLabsLines } from '../components/GuardianLabsLines';
import { HeaderAdSlot } from '../components/HeaderAdSlot';
import { Island } from '../components/Island';
@@ -42,13 +39,13 @@ import { MostViewedFooterData } from '../components/MostViewedFooterData.importa
import { MostViewedFooterLayout } from '../components/MostViewedFooterLayout';
import { MostViewedRightWithAd } from '../components/MostViewedRightWithAd.importable';
import { OnwardsUpper } from '../components/OnwardsUpper.importable';
-import { RightColumn } from '../components/RightColumn';
import { Section } from '../components/Section';
import { SlotBodyEnd } from '../components/SlotBodyEnd.importable';
import { Standfirst } from '../components/Standfirst';
import { StickyBottomBanner } from '../components/StickyBottomBanner.importable';
import { SubMeta } from '../components/SubMeta';
import { SubNav } from '../components/SubNav.importable';
+import { grid } from '../grid';
import {
ArticleDesign,
type ArticleFormat,
@@ -65,274 +62,9 @@ import type { NavType } from '../model/extract-nav';
import { palette as themePalette } from '../palette';
import type { ArticleDeprecated } from '../types/article';
import type { RenderingTarget } from '../types/renderingTarget';
+import { type Area, gridCss, type LayoutType } from './lib/furnitureLayouts';
import { BannerWrapper, Stuck } from './lib/stickiness';
-const StandardGrid = ({
- children,
- isMatchReport,
- isMedia,
- isInFootballRedesignVariantGroup,
-}: {
- children: React.ReactNode;
- isMatchReport: boolean;
- isMedia: boolean;
- isInFootballRedesignVariantGroup: boolean;
-}) => (
-
- {children}
-
-);
-
-const maxWidth = css`
- ${from.desktop} {
- max-width: 620px;
- }
-`;
-
const stretchLines = css`
${until.phablet} {
margin-left: -20px;
@@ -344,6 +76,29 @@ const stretchLines = css`
}
`;
+interface GridItemProps {
+ area: Area;
+ layoutType: LayoutType;
+ element?: 'div' | 'aside';
+ customCss?: SerializedStyles;
+ children: React.ReactNode;
+}
+
+const GridItem = ({
+ area,
+ layoutType,
+ element: Element = 'div',
+ customCss,
+ children,
+}: GridItemProps) => (
+
+ {children}
+
+);
+
interface Props {
article: ArticleDeprecated;
format: ArticleFormat;
@@ -419,6 +174,12 @@ export const StandardLayout = (props: WebProps | AppProps) => {
const renderAds = canRenderAds(article);
+ const layoutType: LayoutType = isMatchReport
+ ? 'matchReport'
+ : isMedia
+ ? 'media'
+ : 'standard';
+
return (
<>
{isWeb && (
@@ -488,209 +249,155 @@ export const StandardLayout = (props: WebProps | AppProps) => {
pageId={article.pageId}
pageTags={article.tags}
/>
-
-
- {!isInFootballRedesignVariantGroup && (
- <>
-
-
- {isMatchReport && (
-
-
-
- )}
-
-
-
-
- {isMatchReport && (
-
-
-
- )}
-
-
- >
- )}
-
-
-
-
-
- {!isInFootballRedesignVariantGroup && (
-
-
-
- )}
-
-
- {format.theme === ArticleSpecial.Labs ? (
- <>>
- ) : (
-
- )}
-
-
-
+
+
+
+
-
-
+
+
+ {!isInFootballRedesignVariantGroup && (
+
+
-
-
-
- {isWeb &&
- format.theme === ArticleSpecial.Labs &&
- format.design !== ArticleDesign.Video ? (
-
- ) : (
-
- )}
-
-
- {isApps ? (
- <>
-
-
-
-
-
- {!!article.affiliateLinksDisclaimer && (
-
- )}
-
- >
+ )}
+
+
+
+
+
+
+
+
+ {isWeb &&
+ format.theme === ArticleSpecial.Labs &&
+ format.design !== ArticleDesign.Video ? (
+
) : (
-
+
+ )}
+
+ {isApps ? (
+ <>
+
+
+
+
{
{!!article.affiliateLinksDisclaimer && (
)}
-
- )}
-
-
- {/* Only show Listen to Article button on App landscape views */}
- {isApps && (
-
- {!isVideo && (
-
-
-
-
-
- )}
- )}
-
-
+ ) : (
+ <>
+
-
-
- {isApps && (
-
-
-
+ {!!article.affiliateLinksDisclaimer && (
+
)}
-
- {showBodyEndSlot && (
-
+ )}
+
+
+ {/* Only show Listen to Article button on App landscape views */}
+ {isApps && (
+
+ {!isVideo && (
+
-
-
+
+
+
+
)}
-
-
+ )}
+
+
+
+
+ {isApps && (
+
+
+
+ )}
+
+ {showBodyEndSlot && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
{isWeb && renderAds && !isLabs && (
>>
+>;
+
+type BreakpointRows = Area[][];
+
+type LayoutDefinition = {
+ mobile?: BreakpointRows;
+ tablet?: BreakpointRows;
+ desktop?: BreakpointRows;
+ leftCol?: BreakpointRows;
+};
+
+const tabletVanillaRows: BreakpointRows = [
+ ['title'],
+ ['headline'],
+ ['standfirst'],
+ ['main-media'],
+ ['meta'],
+ ['body'],
+];
+
+const furnitureRowLayouts: Record = {
+ standard: {
+ tablet: tabletVanillaRows,
+ desktop: [
+ ['title', 'right-column'],
+ ['headline', 'right-column'],
+ ['standfirst', 'right-column'],
+ ['main-media', 'right-column'],
+ ['meta', 'right-column'],
+ ['body', 'right-column'],
+ ],
+ leftCol: [
+ ['title', 'headline', 'right-column'],
+ ['standfirst', 'right-column'],
+ ['meta', 'main-media', 'right-column'],
+ ['body', 'right-column'],
+ ],
+ },
+
+ matchReport: {
+ tablet: [['match-summary'], ...tabletVanillaRows],
+ desktop: [
+ ['match-summary', 'right-column'],
+ ['title', 'right-column'],
+ ['headline', 'right-column'],
+ ['standfirst', 'right-column'],
+ ['main-media', 'right-column'],
+ ['meta', 'right-column'],
+ ['body', 'right-column'],
+ ],
+ leftCol: [
+ ['title', 'match-summary', 'right-column'],
+ ['headline', 'right-column'],
+ ['meta', 'main-media', 'right-column'],
+ ['body', 'right-column'],
+ ],
+ },
+
+ media: {
+ mobile: [
+ ['title'],
+ ['headline'],
+ ['main-media'],
+ ['standfirst'],
+ ['meta'],
+ ['body'],
+ ],
+ tablet: [
+ ['title'],
+ ['headline'],
+ ['main-media'],
+ ['standfirst'],
+ ['meta'],
+ ['body'],
+ ],
+ desktop: [
+ ['title'],
+ ['headline'],
+ ['main-media', 'right-column'],
+ ['standfirst', 'right-column'],
+ ['meta', 'right-column'],
+ ['body', 'right-column'],
+ ],
+ leftCol: [
+ ['title', 'headline'],
+ ['meta', 'main-media', 'right-column'],
+ ['meta', 'standfirst', 'right-column'],
+ ['body', 'right-column'],
+ ],
+ },
+};
+
+type BreakpointColumns = Partial<
+ Record
+>;
+
+type ColumnLayoutMap = Partial>;
+
+const furnitureColumnDefaults: ColumnLayoutMap = {
+ title: { leftCol: 'left' },
+ meta: { leftCol: 'left' },
+ ['right-column']: { desktop: 'right' },
+};
+
+const furnitureColumnLayouts: Record = {
+ standard: furnitureColumnDefaults,
+ media: {
+ ...furnitureColumnDefaults,
+ 'main-media': {
+ desktop: ['centre-column-start', 'right-column-start'],
+ },
+ standfirst: {
+ desktop: ['centre-column-start', 'right-column-start'],
+ },
+ body: {
+ desktop: ['centre-column-start', 'right-column-start'],
+ },
+ },
+ matchReport: furnitureColumnDefaults,
+};
+
+const buildRowMap = (layout: LayoutDefinition): LayoutRows => {
+ const map: LayoutRows = {};
+
+ const apply = (
+ rows: BreakpointRows | undefined,
+ breakpoint: Breakpoint,
+ ) => {
+ if (!rows) return;
+
+ const areaRows: Record = {};
+
+ for (const [index, areas] of rows.entries()) {
+ const row = index + 1;
+
+ for (const area of areas) {
+ areaRows[area] ??= [];
+ areaRows[area].push(row);
+ }
+ }
+
+ for (const [area, rowList] of Object.entries(areaRows) as [
+ Area,
+ number[],
+ ][]) {
+ const start = rowList[0];
+ const span = rowList.length > 1 ? rowList.length : undefined;
+
+ if (start == null) continue;
+
+ map[area] ??= {};
+ map[area][breakpoint] = { start, span };
+ }
+ };
+
+ apply(layout.mobile, 'mobile');
+ apply(layout.tablet, 'tablet');
+ apply(layout.desktop, 'desktop');
+ apply(layout.leftCol, 'leftCol');
+
+ return map;
+};
+
+const rowMaps = Object.fromEntries(
+ Object.entries(furnitureRowLayouts).map(([name, layout]) => [
+ name,
+ buildRowMap(layout),
+ ]),
+) as Record;
+
+const breakpointQueries = {
+ mobile: until.tablet,
+ tablet: from.tablet,
+ leftCol: from.leftCol,
+ desktop: from.desktop,
+} as const;
+
+type ColumnConfig = Partial>;
+
+export const gridCss = (
+ area: Area,
+ layoutType: LayoutType,
+ columnsOverride?: ColumnConfig,
+): SerializedStyles => {
+ const rows = rowMaps[layoutType][area] ?? {};
+ const columns = furnitureColumnLayouts[layoutType][area] ?? {};
+
+ return css([
+ grid.column.centre, // default
+ Object.entries(rows).map(([bp, placement]) => {
+ const rowValue =
+ placement.span != null
+ ? `${placement.start} / span ${placement.span}`
+ : placement.start;
+
+ return css`
+ ${breakpointQueries[bp as Breakpoint]} {
+ grid-row: ${rowValue};
+ }
+ `;
+ }),
+ Object.entries(columns).map(([bp, colOrSpan]) => {
+ const colStyle = Array.isArray(colOrSpan)
+ ? grid.between(colOrSpan[0], colOrSpan[1])
+ : grid.column[colOrSpan];
+
+ return css`
+ ${from[bp as keyof typeof from]} {
+ ${colStyle};
+ }
+ `;
+ }),
+ columnsOverride &&
+ Object.entries(columnsOverride).map(
+ ([bp, col]) => css`
+ ${from[bp as keyof typeof from]} {
+ ${grid.column[col]};
+ }
+ `,
+ ),
+ ]);
+};