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]}; + } + `, + ), + ]); +};