diff --git a/dotcom-rendering/src/grid.ts b/dotcom-rendering/src/grid.ts index ad089db43e1..9161153a16d 100644 --- a/dotcom-rendering/src/grid.ts +++ b/dotcom-rendering/src/grid.ts @@ -251,6 +251,10 @@ const grid = { 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 65ebfbc5994..6449b0924a6 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.island'; import { DecideLines } from '../components/DecideLines'; import { DirectoryPageNav } from '../components/DirectoryPageNav'; @@ -27,7 +25,6 @@ import { DiscussionLayout } from '../components/DiscussionLayout'; import { FootballMatchHeaderWrapper } from '../components/FootballMatchHeaderWrapper.island'; import { FootballMatchInfoWrapper } from '../components/FootballMatchInfoWrapper.island'; import { Footer } from '../components/Footer'; -import { GridItem } from '../components/GridItem'; import { GuardianLabsLines } from '../components/GuardianLabsLines'; import { HeaderAdSlot } from '../components/HeaderAdSlot'; import { Island } from '../components/Island'; @@ -39,13 +36,13 @@ import { MostViewedFooterData } from '../components/MostViewedFooterData.island' import { MostViewedFooterLayout } from '../components/MostViewedFooterLayout'; import { MostViewedRightWithAd } from '../components/MostViewedRightWithAd.island'; import { OnwardsUpper } from '../components/OnwardsUpper.island'; -import { RightColumn } from '../components/RightColumn'; import { Section } from '../components/Section'; import { SlotBodyEnd } from '../components/SlotBodyEnd.island'; import { Standfirst } from '../components/Standfirst'; import { StickyBottomBanner } from '../components/StickyBottomBanner.island'; import { SubMeta } from '../components/SubMeta'; import { SubNav } from '../components/SubNav.island'; +import { grid } from '../grid'; import { ArticleDesign, type ArticleFormat, @@ -61,247 +58,13 @@ 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, + gridItemCss, + type LayoutType, +} from './lib/furnitureArrangements'; import { BannerWrapper, Stuck } from './lib/stickiness'; -const StandardGrid = ({ - children, - isMatchReport, - isMedia, -}: { - children: React.ReactNode; - isMatchReport: boolean; - isMedia: boolean; -}) => ( -
- {children} -
-); - -const maxWidth = css` - ${from.desktop} { - max-width: 620px; - } -`; - const stretchLines = css` ${until.phablet} { margin-left: -20px; @@ -313,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; @@ -385,6 +171,8 @@ export const StandardLayout = (props: WebProps | AppProps) => { const renderAds = canRenderAds(article); + const layoutType: LayoutType = isMedia ? 'media' : 'standard'; + return ( <> {isWeb && ( @@ -459,163 +247,112 @@ export const StandardLayout = (props: WebProps | AppProps) => { pageId={article.pageId} pageTags={article.tags} /> -
- + + + - -
- -
-
- - - - - - {format.theme === ArticleSpecial.Labs ? ( - <> + + + + + + + + + +
+ {isWeb && + format.theme === ArticleSpecial.Labs && + format.design !== ArticleDesign.Video ? ( + ) : ( - - )} - - -
- -
-
- - - - -
-
- {isWeb && - format.theme === ArticleSpecial.Labs && - format.design !== ArticleDesign.Video ? ( - - ) : ( - - )} -
-
- {isApps ? ( - <> - -
- -
-
- -
- -
- {!!article.affiliateLinksDisclaimer && ( - - )} -
- - ) : ( -
+ )} +
+ {isApps ? ( + <> + + + + { {!!article.affiliateLinksDisclaimer && ( )} -
- )} -
- - {/* Only show Listen to Article button on App landscape views */} - {isApps && ( - - {!isVideo && ( -
- - - -
- )}
- )} - - - - - {isApps && ( - + ) : ( + + )} +
+ + {/* Only show Listen to Article button on App landscape views */} + {isApps && ( + + {!isVideo && ( +
- - + + + +
)} +
+ )} + + + - {showBodyEndSlot && ( - - - - )} - - + + + )} + + {showBodyEndSlot && ( + + + + )} + + + +
+ + + + - - - -
- - - - - -
-
-
-
+ + + + {isWeb && renderAds && !isLabs && (
= { + standard: { + tablet: tabletStandardRows, + desktop: desktopStandardRows, + leftCol: [ + ['title', 'headline', 'right-column'], + ['standfirst', 'right-column'], + ['meta', 'main-media', 'right-column'], + ['meta', 'body', 'right-column'], + ], + }, + media: { + mobile: mediaRowsUntilDesktop, + tablet: mediaRowsUntilDesktop, + 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'], + ['meta', 'body', 'right-column'], + ], + }, +}; + +// Columns config +const furnitureColumnDefaults: ColumnArrangementMap = { + title: { leftCol: 'left' }, + meta: { leftCol: 'left' }, + ['right-column']: { desktop: 'right' }, +}; + +// Array form means [gridLineStart, gridLineEnd] — used for custom-width spans +const furnitureColumnArrangements: 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'], + }, + }, +}; + +// 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 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. + * + * 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. + * + * @example + * // In a React component: + *
+ */ +export const gridItemCss = ( + area: Area, + layoutType: LayoutType, +): SerializedStyles => { + const layoutRowConfig = furnitureRowArrangements[layoutType]; + const areaColumnsConfig = + furnitureColumnArrangements[layoutType][area] ?? {}; + + const rowPlacementCss = ( + Object.entries(layoutRowConfig) as [Breakpoint, Rows][] + ).flatMap(([breakpoint, rows]) => { + // Find which row indices the area appears in (1-indexed for CSS grid) + const rowIndicesOfArea = rows + .map((areas, i) => (areas.includes(area) ? i + 1 : null)) + .filter((i): i is number => i !== null); + + if (rowIndicesOfArea.length === 0) return []; + + const startingRow = rowIndicesOfArea[0]; + const rowValue = + rowIndicesOfArea.length > 1 + ? `${startingRow} / span ${rowIndicesOfArea.length}` + : startingRow; + + return css` + ${breakpointQueries[breakpoint]} { + grid-row: ${rowValue}; + } + `; + }); + + const columnPlacementCss = Object.entries(areaColumnsConfig).map( + ([breakpoint, colOrSpan]) => { + const colStyle = Array.isArray(colOrSpan) + ? grid.between(colOrSpan[0], colOrSpan[1]) + : grid.column[colOrSpan]; + + return css` + ${from[breakpoint as keyof typeof from]} { + ${colStyle}; + } + `; + }, + ); + + return css([grid.column.centre, rowPlacementCss, columnPlacementCss]); +};