diff --git a/dotcom-rendering/src/grid.ts b/dotcom-rendering/src/grid.ts index 8121c5c5397..a4995a81f42 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,82 @@ 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 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 + ? ` + /* CENTRE RULE */ + & > *:first-child::before { + grid-column: centre-column-start; + justify-self: start; + transform: var(--centre-transform); + }` + : '' + } + } +`; + +// ----- API ----- // + const between = (from: Line | number, to: Line | number): string => ` grid-column: ${from} / ${to}; `; @@ -182,6 +241,8 @@ const grid = { * breakpoint. */ mobileColumnGap, + + verticalRules, } as const; // ----- Exports ----- // diff --git a/dotcom-rendering/src/layouts/DecideLayout.tsx b/dotcom-rendering/src/layouts/DecideLayout.tsx index 57edc35d105..6c958ab25e9 100644 --- a/dotcom-rendering/src/layouts/DecideLayout.tsx +++ b/dotcom-rendering/src/layouts/DecideLayout.tsx @@ -14,7 +14,6 @@ import { InteractiveLayout } from './InteractiveLayout'; import { LiveLayout } from './LiveLayout'; import { NewsletterSignupLayout } from './NewsletterSignupLayout'; import { PictureLayout } from './PictureLayout'; -import { ShowcaseLayout } from './ShowcaseLayout'; import { StandardLayout } from './StandardLayout'; interface BaseProps { @@ -102,7 +101,7 @@ const DecideLayoutApps = ({ article, renderingTarget }: AppProps) => { ); default: return ( - { ); default: return ( - ( -
- {children} -
-); - -const maxWidth = css` - ${from.desktop} { - max-width: 620px; - } -`; - -const fullHeight = css` - height: 100%; -`; - -const stretchLines = css` - ${until.phablet} { - margin-left: -20px; - margin-right: -20px; - } - ${until.mobileLandscape} { - margin-left: -10px; - margin-right: -10px; - } -`; -const mainMediaWrapper = css` - position: relative; -`; - -const PositionHeadline = ({ - design, - children, -}: { - design: ArticleDesign; - children: React.ReactNode; -}) => { - switch (design) { - case ArticleDesign.Interview: - return ( -
-
{children}
-
- ); - default: - return
{children}
; - } -}; - -interface CommonProps { - article: ArticleDeprecated; - format: ArticleFormat; - renderingTarget: RenderingTarget; - serverTime?: number; -} - -interface WebProps extends CommonProps { - NAV: NavType; - renderingTarget: 'Web'; -} - -interface AppsProps extends CommonProps { - renderingTarget: 'Apps'; -} - -export const ShowcaseLayout = (props: WebProps | AppsProps) => { - const { article, format, renderingTarget, serverTime } = props; - const { - config: { isPaidContent, host, hasSurveyAd }, - editionId, - } = article; - const isWeb = renderingTarget === 'Web'; - const isApps = renderingTarget === 'Apps'; - - const showBodyEndSlot = - isWeb && - (parse(article.slotMachineFlags ?? '').showBodyEnd || - article.config.switches.slotBodyEnd); - - // TODO: - // 1) Read 'forceEpic' value from URL parameter and use it to force the slot to render - // 2) Otherwise, ensure slot only renders if `article.config.shouldHideReaderRevenue` equals false. - - const showComments = article.isCommentable && !isPaidContent; - - const { branding } = article.commercialProperties[article.editionId]; - - const contributionsServiceUrl = getContributionsServiceUrl(article); - - const renderAds = canRenderAds(article); - - const isLabs = format.theme === ArticleSpecial.Labs; - - return ( - <> - {isWeb && ( - <> - {!isLabs ? ( -
- {renderAds && ( - -
- -
-
- )} - - -
- ) : ( - // Else, this is a labs article so just show Nav and the Labs header - <> -
- {renderAds && ( - -
- -
-
- )} - - - -
- -
- -
-
- - )} - - )} - - {isWeb && renderAds && hasSurveyAd && ( - - )} - -
- {isApps && renderAds && ( - - - - )} - -
- - -
- -
-
- - - - - - - - - - - - - - {/* Only show Listen to Article button on App landscape views */} - {isApps && ( - -
- - - -
-
- )} -
- -
-
- -
-
-
- {isApps ? ( - <> - - - - - - {!!article.affiliateLinksDisclaimer && ( - - )} - - - ) : ( - <> - - {!!article.affiliateLinksDisclaimer && ( - - )} - - )} -
-
- - - - {showBodyEndSlot && ( - - - - )} - - - - - -
- - - - - -
-
-
-
- - {isWeb && renderAds && !isLabs && ( -
- -
- )} - - {article.storyPackage && ( -
- - - -
- )} - - - - - - {showComments && ( -
- -
- )} - - {!isPaidContent && ( -
- - - - - -
- )} - - {isWeb && renderAds && !isLabs && ( -
- -
- )} -
- {isWeb && props.NAV.subNavSections && ( -
- - - -
- )} - {isWeb && ( - <> -
-
-
- - - - - - - - - )} - {isApps && ( -
- - - -
- )} - - ); -}; diff --git a/dotcom-rendering/src/layouts/StandardLayout.tsx b/dotcom-rendering/src/layouts/StandardLayout.tsx index ebe454b335d..07b1a9a741f 100644 --- a/dotcom-rendering/src/layouts/StandardLayout.tsx +++ b/dotcom-rendering/src/layouts/StandardLayout.tsx @@ -1,4 +1,4 @@ -import { css } from '@emotion/react'; +import { css, type SerializedStyles } from '@emotion/react'; import { log } from '@guardian/libs'; import { from, @@ -30,7 +30,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,15 +41,16 @@ 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, + ArticleDisplay, type ArticleFormat, ArticleSpecial, } from '../lib/articleFormat'; @@ -65,268 +65,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, + type FurnitureLayoutType, + rowCss, +} 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; @@ -344,6 +89,72 @@ const stretchLines = css` } `; +const spanCentreToRightColumnFromDesktop = css` + ${from.desktop} { + ${grid.between('centre-column-start', 'right-column-start')}; + } +`; + +const rightColumnCss = (isMedia: boolean, isShowcase: boolean) => css` + display: none; + + ${from.desktop} { + display: block; + padding-top: 6px; + grid-row: ${isMedia ? 3 : 1} / span 999; + } + + ${from.leftCol} { + grid-row: ${isShowcase ? 3 : isMedia ? 2 : 1} / span 999; + } +`; + +interface GridItemProps { + area: Area; + layoutType: FurnitureLayoutType; + columns?: { + tablet?: 'left' | 'centre' | 'right'; + desktop?: 'left' | 'centre' | 'right'; + leftCol?: 'left' | 'centre' | 'right'; + }; + element?: 'div' | 'aside'; + customCss?: SerializedStyles; + children: React.ReactNode; +} + +const columnCss = (columnsConfig?: GridItemProps['columns']) => [ + grid.column.centre, + Object.entries({ + tablet: columnsConfig?.tablet, + desktop: columnsConfig?.desktop, + leftCol: columnsConfig?.leftCol, + }) + .filter(([, value]) => value != null) + .map( + ([bp, col]) => css` + ${from[bp as keyof typeof from]} { + ${grid.column[col!]}; + } + `, + ), +]; + +const GridItem = ({ + area, + layoutType, + columns, + element: Element = 'div', + customCss, + children, +}: GridItemProps) => ( + + {children} + +); + interface Props { article: ArticleDeprecated; format: ArticleFormat; @@ -417,8 +228,18 @@ export const StandardLayout = (props: WebProps | AppProps) => { const isLabs = format.theme === ArticleSpecial.Labs; + const isShowcase = format.display === ArticleDisplay.Showcase; + const renderAds = canRenderAds(article); + const layoutType: FurnitureLayoutType = isMatchReport + ? 'matchReport' + : isMedia + ? 'media' + : isShowcase + ? 'showcase' + : 'standard'; + return ( <> {isWeb && ( @@ -488,66 +309,76 @@ export const StandardLayout = (props: WebProps | AppProps) => { pageId={article.pageId} pageTags={article.tags} /> -
- - {!isInFootballRedesignVariantGroup && ( - <> - -
- {isMatchReport && ( - - - - )} -
-
- -
- {isMatchReport && ( - - - - )} -
-
- + {isMatchReport && !isInFootballRedesignVariantGroup && ( + +
+ + + +
+
+ + + +
+
)} - -
+ +
{
{!isInFootballRedesignVariantGroup && ( - + { )} - +
{format.theme === ArticleSpecial.Labs ? ( <> ) : ( )} - - +
+
{ />
- + - +
{isWeb && @@ -720,7 +574,16 @@ export const StandardLayout = (props: WebProps | AppProps) => {
)} - + {/* Only show Listen to Article button on App landscape views */} {isApps && ( @@ -859,52 +722,37 @@ export const StandardLayout = (props: WebProps | AppProps) => { /> - -
+ + - - - - - -
+ /> +
- -
+ + {isWeb && renderAds && !isLabs && (
+>; + +type BreakpointRows = Area[][]; + +type LayoutDefinition = { + mobile?: BreakpointRows; + tablet?: BreakpointRows; + leftCol?: BreakpointRows; +}; + +const furnitureRowLayouts: Record = { + standard: { + tablet: [ + ['title'], + ['headline'], + ['standfirst'], + ['main-media'], + ['meta'], + ], + + leftCol: [ + ['title', 'headline'], + ['standfirst'], + ['meta', 'main-media'], + ], + }, + showcase: { + tablet: [ + ['title'], + ['headline'], + ['standfirst'], + ['main-media'], + ['meta'], + ], + + leftCol: [ + ['title', 'headline'], + ['meta', 'main-media'], + ['standfirst'], + ['right-column'], + ], + }, + matchReport: { + tablet: [ + ['match-summary'], + ['title'], + ['headline'], + ['standfirst'], + ['main-media'], + ['meta'], + ], + leftCol: [ + ['title', 'match-summary'], + ['headline'], + ['meta', 'main-media'], + ], + }, + media: { + mobile: [ + ['title'], + ['headline'], + ['main-media'], + ['standfirst'], + ['meta'], + ], + tablet: [ + ['title'], + ['headline'], + ['main-media'], + ['standfirst'], + ['meta'], + ], + leftCol: [ + ['title', 'headline'], + ['meta', 'main-media'], + ['standfirst'], + ], + }, +}; + +const buildRowMap = (layout: LayoutDefinition): LayoutRows => { + const map: LayoutRows = {} as LayoutRows; + + const apply = ( + rows: Area[][] | undefined, + breakpoint: 'mobile' | 'tablet' | 'leftCol', + ) => { + if (!rows) return; + + for (const [index, areas] of rows.entries()) { + const row = index + 1; + + for (const area of areas) { + map[area] ??= {}; + map[area][breakpoint] = row; + } + } + }; + + apply(layout.mobile, 'mobile'); + apply(layout.tablet, 'tablet'); + 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; + +export const rowCss = ( + area: Area, + layoutType: FurnitureLayoutType, +): SerializedStyles => { + const rows = rowMaps[layoutType][area] ?? {}; + + return css( + Object.entries(rows).map( + ([bp, row]) => css` + ${breakpointQueries[bp as keyof typeof breakpointQueries]} { + grid-row: ${row}; + } + `, + ), + ); +};