diff --git a/dotcom-rendering/src/grid.ts b/dotcom-rendering/src/grid.ts index a02e5c3f861..e97434d1ade 100644 --- a/dotcom-rendering/src/grid.ts +++ b/dotcom-rendering/src/grid.ts @@ -288,4 +288,10 @@ const grid = { centreRule, } as const; +// ----- Types ----- // +type ColumnPreset = keyof typeof grid.column; + +// ----- Exports ----- // + +export type { Line, ColumnPreset }; export { grid }; diff --git a/dotcom-rendering/src/layouts/lib/articleArrangements.ts b/dotcom-rendering/src/layouts/lib/articleArrangements.ts index 2cf5fb6843f..ef3b4eb3fbd 100644 --- a/dotcom-rendering/src/layouts/lib/articleArrangements.ts +++ b/dotcom-rendering/src/layouts/lib/articleArrangements.ts @@ -1,6 +1,6 @@ import { css, type SerializedStyles } from '@emotion/react'; import { from, until } from '@guardian/source/foundations'; -import { grid } from '../../grid'; +import { type ColumnPreset, grid, type Line } from '../../grid'; export type LayoutType = 'standard' | 'showcase' | 'media'; @@ -13,156 +13,173 @@ export type Area = | 'body' | 'right-column'; +// Breakpoint must stay in sync with the keys of `from` / `until` from +// @guardian/source/foundations. If those change, this type needs updating too. type Breakpoint = 'mobile' | 'tablet' | 'desktop' | 'leftCol'; -const breakpointQueries: Record = { - mobile: until.tablet, - tablet: from.tablet, - desktop: from.desktop, - leftCol: from.leftCol, -}; +// Rows config +const tabletStandardRows: Rows = [ + ['title'], + ['headline'], + ['standfirst'], + ['media'], + ['meta'], + ['body'], +]; -// Raw CSS overrides per area per breakpoint. Entries are only needed when an area -// deviates from the default: centre column, single-column mobile layout with areas -// in DOM order (media → title → headline → standfirst → meta → body → right-column). +const desktopStandardRows: Rows = [ + ['title', 'right-column'], + ['headline', 'right-column'], + ['standfirst', 'right-column'], + ['media', 'right-column'], + ['meta', 'right-column'], + ['body', 'right-column'], +]; -type AreaCss = Partial>; -type LayoutCssMap = Partial>; +const mediaRowsUntilDesktop: Rows = [ + ['title'], + ['headline'], + ['media'], + ['standfirst'], + ['meta'], + ['body'], +]; -const standardCss: LayoutCssMap = { - title: { - tablet: 'grid-row: 1;', - leftCol: `grid-row: 1; ${grid.column.left}`, - }, - headline: { - tablet: 'grid-row: 2;', - leftCol: 'grid-row: 1;', - }, - standfirst: { - tablet: 'grid-row: 3;', - leftCol: 'grid-row: 2;', +// Breakpoints not listed here inherit browser default flow (no grid-row applied) +const furnitureRowArrangements: Record = { + standard: { + tablet: tabletStandardRows, + desktop: desktopStandardRows, + leftCol: [ + ['title', 'headline', 'right-column'], + ['standfirst', 'right-column'], + ['meta', 'media', 'right-column'], + ['meta', 'body', 'right-column'], + ], + }, + showcase: { + desktop: desktopStandardRows, + leftCol: [ + ['title', 'headline'], + ['standfirst'], + ['meta', 'media'], + ['meta', 'body', 'right-column'], + ], }, media: { - tablet: 'grid-row: 4;', - leftCol: 'grid-row: 3;', - }, - meta: { - tablet: 'grid-row: 5;', - leftCol: `grid-row: 3 / span 2; ${grid.column.left};`, - }, - body: { - tablet: 'grid-row: 6;', - leftCol: 'grid-row: 4;', - }, - 'right-column': { - desktop: `grid-row: 1 / span 6; ${grid.column.right};`, - leftCol: `grid-row: 1 / span 4; ${grid.column.right};`, + mobile: mediaRowsUntilDesktop, + tablet: mediaRowsUntilDesktop, + desktop: [ + ['title'], + ['headline'], + ['media', 'right-column'], + ['standfirst', 'right-column'], + ['meta', 'right-column'], + ['body', 'right-column'], + ], + leftCol: [ + ['title', 'headline'], + ['meta', 'media', 'right-column'], + ['meta', 'standfirst', 'right-column'], + ['meta', 'body', 'right-column'], + ], }, }; -const showcaseCss: LayoutCssMap = { - title: { - tablet: 'grid-row: 1;', - leftCol: `grid-row: 1; ${grid.column.left}`, - }, - headline: { - tablet: 'grid-row: 2;', - leftCol: 'grid-row: 1;', - }, - standfirst: { - tablet: 'grid-row: 3;', - }, - media: { - tablet: 'grid-row: 4;', - leftCol: `grid-row: 2; ${grid.between( - 'centre-column-start', - 'right-column-end', - )}`, - }, - meta: { - tablet: 'grid-row: 5;', - leftCol: `grid-row: 2 / span 2; ${grid.column.left};`, - }, - body: { - tablet: 'grid-row: 6;', - leftCol: 'grid-row: 4;', - }, - 'right-column': { - desktop: `grid-row: 1 / span 6; ${grid.column.right};`, - leftCol: `grid-row: 3 / span 2; ${grid.column.right};`, - }, +// Columns config +const furnitureColumnDefaults: ColumnArrangementMap = { + title: { leftCol: 'left' }, + meta: { leftCol: 'left' }, + ['right-column']: { desktop: 'right' }, }; -const mediaCss: LayoutCssMap = { - title: { - mobile: 'grid-row: 1;', - tablet: 'grid-row: 1;', - leftCol: `grid-row: 1; ${grid.column.left};`, - }, - headline: { - mobile: 'grid-row: 2;', - tablet: 'grid-row: 2;', - leftCol: 'grid-row: 1;', +// Array form means [gridLineStart, gridLineEnd] — used for custom-width spans +const furnitureColumnArrangements: Record = { + standard: furnitureColumnDefaults, + showcase: { + ...furnitureColumnDefaults, + media: { + leftCol: ['centre-column-start', 'right-column-end'], + }, }, media: { - mobile: 'grid-row: 3;', - tablet: 'grid-row: 3;', - desktop: `grid-row: 3; ${grid.between( - 'centre-column-start', - 'right-column-start', - )};`, - leftCol: `grid-row: 2; ${grid.between( - 'centre-column-start', - 'right-column-start', - )};`, - }, - standfirst: { - mobile: 'grid-row: 4;', - tablet: 'grid-row: 4;', - desktop: `grid-row: 4; ${grid.between( - 'centre-column-start', - 'right-column-start', - )};`, - leftCol: `grid-row: 3; ${grid.between( - 'centre-column-start', - 'right-column-start', - )};`, - }, - meta: { - mobile: 'grid-row: 5;', - tablet: 'grid-row: 5;', - leftCol: `grid-row: 2 / span 3; ${grid.column.left};`, - }, - body: { - desktop: `grid-row: 6; ${grid.between( - 'centre-column-start', - 'right-column-start', - )};`, - leftCol: `grid-row: 4; ${grid.between( - 'centre-column-start', - 'right-column-start', - )};`, - }, - 'right-column': { - desktop: `grid-row: 3 / span 4; ${grid.column.right};`, - leftCol: `grid-row: 2 / span 3; ${grid.column.right};`, + ...furnitureColumnDefaults, + media: { + desktop: ['centre-column-start', 'right-column-start'], + }, + standfirst: { + desktop: ['centre-column-start', 'right-column-start'], + }, + body: { + desktop: ['centre-column-start', 'right-column-start'], + }, }, }; -const layoutCssMaps: Record = { - standard: standardCss, - showcase: showcaseCss, - media: mediaCss, +// Types +type Rows = Area[][]; + +type ArrangementDefinition = { + mobile?: Rows; + tablet?: Rows; + desktop?: Rows; + leftCol?: Rows; +}; + +type BreakpointColumns = Partial< + Record +>; + +type ColumnArrangementMap = Partial>; + +const breakpointQueries: Record = { + mobile: until.tablet, + tablet: from.tablet, + desktop: from.desktop, + leftCol: from.leftCol, }; +/** + * Returns the CSS `grid-row` value for an area at a given breakpoint, or null + * if the area doesn't appear in that breakpoint's row config. + * + * Consecutive row appearances are collapsed into a single span, e.g. rows + * [2, 3, 4] becomes "2 / span 3". + */ +const getRowValue = (area: Area, rows: Rows): string | number | null => { + const indices = rows + .map((areas, i) => (areas.includes(area) ? i + 1 : null)) + .filter((i): i is number => i !== null); + + if (indices.length === 0) return null; + + return indices.length > 1 + ? `${indices[0]} / span ${indices.length}` + : (indices[0] ?? null); +}; + +/** + * Returns the `grid-column` CSS string for a column config entry — either a + * named preset (e.g. 'left', 'right') or a custom [start, end] line pair. + */ +const getColumnStyle = ( + colOrSpan: ColumnPreset | [Line | number, Line | number], +): string => + Array.isArray(colOrSpan) + ? grid.between(colOrSpan[0], colOrSpan[1]) + : grid.column[colOrSpan]; + /** * Returns the Emotion CSS needed to position a single grid item — its * default column, its row at each breakpoint, and any column overrides. * The grid item _must_ be inside a {@link grid} module container. * - * All items default to the centre column. Per-breakpoint overrides for - * `grid-row` and `grid-column` are applied on top via media queries, - * looked up from the plain CSS maps defined in this file. + * The output is built from three layers, applied in order (later wins): + * 1. **Default column** — all items start in the centre column. + * 2. **Row placement** — `grid-row` values per breakpoint, read directly + * from {@link furnitureRowArrangements}. + * 3. **Column placement** — column overrides per breakpoint from + * {@link furnitureColumnArrangements} (e.g. `meta` shifts left on wide screens). * * @param area - The named piece of article furniture to position (e.g. `'headline'`, `'body'`). * @param layoutType - See {@link LayoutType}. Determines which CSS map to use for lookups. @@ -175,20 +192,30 @@ export const gridItemCss = ( area: Area, layoutType: LayoutType, ): SerializedStyles => { - const areaOverrides = layoutCssMaps[layoutType][area] ?? {}; + const layoutRowConfig = furnitureRowArrangements[layoutType]; + const areaColumnsConfig = + furnitureColumnArrangements[layoutType][area] ?? {}; + + const rowPlacementCss = ( + Object.entries(layoutRowConfig) as [Breakpoint, Rows][] + ).flatMap(([breakpoint, rows]) => { + const rowValue = getRowValue(area, rows); + if (rowValue === null) return []; + + return css` + ${breakpointQueries[breakpoint]} { + grid-row: ${rowValue}; + } + `; + }); - const breakpointCss = Object.entries(areaOverrides).map( - ([bp, styles]) => css` - ${breakpointQueries[bp as Breakpoint]} { - ${styles} + const columnPlacementCss = Object.entries(areaColumnsConfig).map( + ([breakpoint, colOrSpan]) => css` + ${from[breakpoint as keyof typeof from]} { + ${getColumnStyle(colOrSpan)}; } `, ); - // All items default to the centre column; breakpoint entries above - // override grid-row and grid-column as needed. - return css` - grid-column: centre-column-start / centre-column-end; - ${breakpointCss} - `; + return css([grid.column.centre, rowPlacementCss, columnPlacementCss]); };