diff --git a/dotcom-rendering/src/components/ArticleBody.tsx b/dotcom-rendering/src/components/ArticleBody.tsx index 380c74b692a..7b4af5ed594 100644 --- a/dotcom-rendering/src/components/ArticleBody.tsx +++ b/dotcom-rendering/src/components/ArticleBody.tsx @@ -58,6 +58,7 @@ type Props = { serverTime?: number; idApiUrl?: string; accentColor?: string; + isOldInteractive?: boolean; }; const globalOlStyles = () => css` @@ -165,6 +166,7 @@ export const ArticleBody = ({ serverTime, idApiUrl, accentColor, + isOldInteractive = false, }: Props) => { const isInteractiveContent = format.design === ArticleDesign.Interactive || @@ -292,6 +294,7 @@ export const ArticleBody = ({ contributionsServiceUrl={contributionsServiceUrl} shouldHideAds={shouldHideAds} idApiUrl={idApiUrl} + isOldInteractive={isOldInteractive} /> {hasObserverPublicationTag && } diff --git a/dotcom-rendering/src/components/Figure.tsx b/dotcom-rendering/src/components/Figure.tsx index ccb113fb4b1..d69c3414b1b 100644 --- a/dotcom-rendering/src/components/Figure.tsx +++ b/dotcom-rendering/src/components/Figure.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/react'; -import { breakpoints, from, space, until } from '@guardian/source/foundations'; +import { from, space, until } from '@guardian/source/foundations'; import { ArticleDesign, type ArticleFormat } from '../lib/articleFormat'; import type { FEElement, RoleType } from '../types/content'; @@ -74,47 +74,7 @@ const roleCss = { margin-top: ${space[3]}px; margin-bottom: ${space[3]}px; - ${until.tablet} { - margin-left: -20px; - margin-right: -20px; - } - ${until.mobileLandscape} { - margin-left: -10px; - margin-right: -10px; - } - ${from.tablet} { - --scrollbar-width-fallback: 15px; - --half-scrollbar-width-fallback: 7.5px; - - width: calc( - 100vw - var(--scrollbar-width, var(--scrollbar-width-fallback)) - ); - max-width: calc( - 100vw - var(--scrollbar-width, var(--scrollbar-width-fallback)) - ); - - --grid-container-max-width: 740px; - --grid-container-left-margin: calc( - ((-100vw + (var(--grid-container-max-width) - 42px)) / 2) + - var( - --half-scrollbar-width, - var(--half-scrollbar-width-fallback) - ) - ); - - margin-left: var(--grid-container-left-margin); - } - ${from.desktop} { - --grid-container-max-width: ${breakpoints.desktop}px; - } - ${from.leftCol} { - --grid-container-max-width: ${breakpoints.leftCol}px; - --grid-left-col-width: 140px; - } - ${from.wide} { - --grid-container-max-width: ${breakpoints.wide}px; - --grid-left-col-width: 219px; - } + grid-column: 1 / -1; `, showcase: css` diff --git a/dotcom-rendering/src/components/SubMeta.tsx b/dotcom-rendering/src/components/SubMeta.tsx index 24d1b24196a..8f594eab135 100644 --- a/dotcom-rendering/src/components/SubMeta.tsx +++ b/dotcom-rendering/src/components/SubMeta.tsx @@ -129,6 +129,7 @@ type Props = { webUrl: string; webTitle: string; showBottomSocialButtons: boolean; + isDeprecatedInteractiveLayout?: boolean; }; const syndicationButtonOverrides = css` @@ -204,6 +205,7 @@ export const SubMeta = ({ webUrl, webTitle, showBottomSocialButtons, + isDeprecatedInteractiveLayout = false, }: Props) => { const createLinks = () => { const links: BaseLinkType[] = []; @@ -228,9 +230,7 @@ export const SubMeta = ({
{ const notSupported =
Not supported
; const format = { @@ -43,6 +46,7 @@ const DecideLayoutApps = ({ article, renderingTarget }: AppProps) => { }; const serverTime = article.serverTime; + const publicationDate = new Date(article.frontendData.webPublicationDate); switch (article.display) { case ArticleDisplay.Immersive: { @@ -116,15 +120,24 @@ const DecideLayoutApps = ({ article, renderingTarget }: AppProps) => { default: { switch (article.design) { case ArticleDesign.Interactive: - return ( - - ); - + if (publicationDate < interactiveLayoutSwitchoverDate) { + return ( + + ); + } else { + return ( + + ); + } case ArticleDesign.FullPageInteractive: { return ( { }; const serverTime = article.serverTime; + const publicationDate = new Date(article.frontendData.webPublicationDate); switch (article.display) { case ArticleDisplay.Immersive: { @@ -293,15 +307,26 @@ const DecideLayoutWeb = ({ article, NAV, renderingTarget }: WebProps) => { default: { switch (article.design) { case ArticleDesign.Interactive: - return ( - - ); + if (publicationDate < interactiveLayoutSwitchoverDate) { + return ( + + ); + } else { + return ( + + ); + } case ArticleDesign.FullPageInteractive: { return ( ( -
- {children} -
-); - -const maxWidth = css` - ${from.desktop} { - max-width: 620px; - } -`; - const stretchLines = css` ${until.phablet} { margin-left: -20px; @@ -181,38 +73,63 @@ const stretchLines = css` } `; -export const temporaryBodyCopyColourOverride = css` - .content__main-column--interactive p { - /* stylelint-disable-next-line declaration-no-important */ - color: ${themePalette('--article-text')} !important; - } -`; +interface GridItemProps { + area: Area; + layoutType: LayoutType; + element?: 'div' | 'aside'; + customCss?: SerializedStyles; + children: React.ReactNode; +} -interface CommonProps { +const GridItem = ({ + area, + layoutType, + element: Element = 'div', + customCss, + children, +}: GridItemProps) => ( + + {children} + +); + +interface Props { article: ArticleDeprecated; format: ArticleFormat; renderingTarget: RenderingTarget; serverTime?: number; } -interface WebProps extends CommonProps { +interface WebProps extends Props { NAV: NavType; renderingTarget: 'Web'; } -interface AppsProps extends CommonProps { +interface AppProps extends Props { renderingTarget: 'Apps'; } -export const InteractiveLayout = (props: WebProps | AppsProps) => { +export const InteractiveLayout = (props: WebProps | AppProps) => { const { article, format, renderingTarget, serverTime } = props; const { config: { isPaidContent, host, hasSurveyAd }, editionId, } = article; - const isApps = renderingTarget === 'Apps'; 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; @@ -222,23 +139,8 @@ export const InteractiveLayout = (props: WebProps | AppsProps) => { const renderAds = canRenderAds(article); - const includesFullWidthElement = article.blocks.some((block) => - block.elements.some((element) => { - const role = - 'role' in element - ? (element.role as RoleType | 'fullWidth' | undefined) - : undefined; - return role === 'fullWidth'; - }), - ); - return ( <> - {includesFullWidthElement && ( - - - - )} {isApps && ( <> @@ -247,367 +149,401 @@ export const InteractiveLayout = (props: WebProps | AppsProps) => { - )} - {article.isLegacyInteractive && ( - - )} {isWeb && ( - <> -
- {renderAds && ( - -
-
- -
-
-
- )} - - tag.id)} - sectionId={article.config.section} - contentType={article.contentType} - /> -
- - {format.theme === ArticleSpecial.Labs && ( - +
+ {renderAds && ( +
- +
)} + tag.id)} + sectionId={article.config.section} + contentType={article.contentType} + /> +
+ )} - {renderAds && hasSurveyAd && ( - - )} - + {format.theme === ArticleSpecial.Labs && ( + +
+ +
+
+ )} + + {isWeb && renderAds && hasSurveyAd && ( + )} +
+ {isApps && renderAds && ( + + + + )} -
-
+ + + - - -
- -
-
- -
- + + + + + + + + +
+ {isWeb && + format.theme === ArticleSpecial.Labs && + format.design !== ArticleDesign.Video ? ( + + ) : ( + + )} +
+ {isApps ? ( + <> + + -
-
- - {format.theme === ArticleSpecial.Labs ? ( - <> - ) : ( - - )} - - -
- -
-
- - - - -
-
- -
-
-
- -
- {isApps ? ( - <> - - - - - - - - ) : ( - - )} -
-
- - - + + + - - -
-
-
- -
-
- - + )} + + + ) : ( + + )} + + + {/* Only show Listen to Article button on App landscape views */} + {isApps && ( + +
+ + + +
+
+ )} + + -
-
-
-
- + + + )} + + {showBodyEndSlot && ( + + + + )} +
+
+ + +
+
+ + + -
- -
- -
+ > + + + + + + + {isWeb && renderAds && (
{ {article.storyPackage && (
{ webURL={article.webURL} /> - {showComments && (
{ '--article-section-background', )} borderColour={themePalette('--article-border')} - fontColour={themePalette('--article-section-title')} > {
)}
- - {isWeb && props.NAV.subNavSections && ( -
- - - -
- )} - {isWeb && ( <> + {props.NAV.subNavSections && ( +
+ + + +
+ )}
{ editionId={article.editionId} />
- { !!article.config.switches.remoteBanner } tags={article.tags} + host={host} /> @@ -817,19 +755,22 @@ export const InteractiveLayout = (props: WebProps | AppsProps) => { /> )} + {isApps && ( -
- - - -
+ <> +
+ + + +
+ )} ); diff --git a/dotcom-rendering/src/layouts/InteractiveLayoutDeprecated.tsx b/dotcom-rendering/src/layouts/InteractiveLayoutDeprecated.tsx new file mode 100644 index 00000000000..6141e64a78a --- /dev/null +++ b/dotcom-rendering/src/layouts/InteractiveLayoutDeprecated.tsx @@ -0,0 +1,838 @@ +import { css, Global } from '@emotion/react'; +import { + from, + palette as sourcePalette, + until, +} from '@guardian/source/foundations'; +import { Hide } from '@guardian/source/react-components'; +import { StraightLines } from '@guardian/source-development-kitchen/react-components'; +import type React from 'react'; +import { AdSlot, MobileStickyContainer } from '../components/AdSlot.web'; +import { AppsFooter } from '../components/AppsFooter.island'; +import { ArticleBody } from '../components/ArticleBody'; +import { ArticleContainer } from '../components/ArticleContainer'; +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'; +import { DiscussionLayout } from '../components/DiscussionLayout'; +import { Footer } from '../components/Footer'; +import { GridItem } from '../components/GridItem'; +import { HeaderAdSlot } from '../components/HeaderAdSlot'; +import { InteractivesDisableArticleSwipe } from '../components/InteractivesDisableArticleSwipe.island'; +import { InteractivesNativePlatformWrapper } from '../components/InteractivesNativePlatformWrapper.island'; +import { InteractivesScrollbarWidth } from '../components/InteractivesScrollbarWidth.island'; +import { Island } from '../components/Island'; +import { LabsHeader } from '../components/LabsHeader'; +import { MainMedia } from '../components/MainMedia'; +import { Masthead } from '../components/Masthead/Masthead'; +import { MostViewedFooterData } from '../components/MostViewedFooterData.island'; +import { MostViewedFooterLayout } from '../components/MostViewedFooterLayout'; +import { OnwardsUpper } from '../components/OnwardsUpper.island'; +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 { type ArticleFormat, ArticleSpecial } from '../lib/articleFormat'; +import { canRenderAds } from '../lib/canRenderAds'; +import { getContributionsServiceUrl } from '../lib/contributions'; +import { decideStoryPackageTrails } from '../lib/decideTrail'; +import type { NavType } from '../model/extract-nav'; +import { palette as themePalette } from '../palette'; +import type { ArticleDeprecated } from '../types/article'; +import type { RoleType } from '../types/content'; +import type { RenderingTarget } from '../types/renderingTarget'; +import { + interactiveGlobalStyles, + interactiveLegacyClasses, +} from './lib/interactiveLegacyStyling'; +import { BannerWrapper, Stuck } from './lib/stickiness'; + +const InteractiveGrid = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +const maxWidth = css` + ${from.desktop} { + max-width: 620px; + } +`; + +const stretchLines = css` + ${until.phablet} { + margin-left: -20px; + margin-right: -20px; + } + ${until.mobileLandscape} { + margin-left: -10px; + margin-right: -10px; + } +`; + +export const temporaryBodyCopyColourOverride = css` + .content__main-column--interactive p { + /* stylelint-disable-next-line declaration-no-important */ + color: ${themePalette('--article-text')} !important; + } +`; + +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 InteractiveLayoutDeprecated = (props: WebProps | AppsProps) => { + const { article, format, renderingTarget, serverTime } = props; + const { + config: { isPaidContent, host, hasSurveyAd }, + editionId, + } = article; + + const isApps = renderingTarget === 'Apps'; + const isWeb = renderingTarget === 'Web'; + + const showComments = article.isCommentable && !isPaidContent; + + const { branding } = article.commercialProperties[article.editionId]; + + const contributionsServiceUrl = getContributionsServiceUrl(article); + + const renderAds = canRenderAds(article); + + const includesFullWidthElement = article.blocks.some((block) => + block.elements.some((element) => { + const role = + 'role' in element + ? (element.role as RoleType | 'fullWidth' | undefined) + : undefined; + return role === 'fullWidth'; + }), + ); + + return ( + <> + {includesFullWidthElement && ( + + + + )} + {isApps && ( + <> + + + + + + + + + )} + {article.isLegacyInteractive && ( + + )} + {isWeb && ( + <> +
+ {renderAds && ( + +
+
+ +
+
+
+ )} + + tag.id)} + sectionId={article.config.section} + contentType={article.contentType} + /> +
+ + {format.theme === ArticleSpecial.Labs && ( + +
+ +
+
+ )} + + {renderAds && hasSurveyAd && ( + + )} + + )} +
+ +
+
+ + +
+ +
+
+ +
+ +
+
+ + {format.theme === ArticleSpecial.Labs ? ( + <> + ) : ( + + )} + + +
+ +
+
+ + + + +
+
+ +
+
+
+ +
+ {isApps ? ( + <> + + + + + + + + ) : ( + + )} +
+
+ + + + + +
+
+
+ +
+
+ + + +
+
+ +
+ +
+ +
+ +
+ + {isWeb && renderAds && ( +
+ +
+ )} + + {article.storyPackage && ( +
+ + + +
+ )} + + + + + + {showComments && ( +
+ +
+ )} + + {!isPaidContent && ( +
+ + + + + +
+ )} + + {isWeb && renderAds && ( +
+ +
+ )} +
+ + {isWeb && props.NAV.subNavSections && ( +
+ + + +
+ )} + + {isWeb && ( + <> +
+
+
+ + + + + + + + + )} + {isApps && ( +
+ + + +
+ )} + + ); +}; diff --git a/dotcom-rendering/src/layouts/lib/furnitureArrangements.ts b/dotcom-rendering/src/layouts/lib/furnitureArrangements.ts new file mode 100644 index 00000000000..229d620e6f7 --- /dev/null +++ b/dotcom-rendering/src/layouts/lib/furnitureArrangements.ts @@ -0,0 +1,108 @@ +import { css, type SerializedStyles } from '@emotion/react'; +import { from, until } from '@guardian/source/foundations'; +import { grid } from '../../grid'; + +export type LayoutType = 'standard'; + +export type Area = + | 'title' + | 'headline' + | 'standfirst' + | 'media' + | 'meta' + | 'body' + | 'right-column'; + +type Breakpoint = 'mobile' | 'tablet' | 'desktop' | 'leftCol'; + +const breakpointQueries: Record = { + mobile: until.tablet, + tablet: from.tablet, + desktop: from.desktop, + leftCol: from.leftCol, +}; + +// 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). + +type AreaCss = Partial>; +type LayoutCssMap = Partial>; + +const standardCss: LayoutCssMap = { + title: { + tablet: 'grid-row: 1;', + leftCol: + 'grid-row: 1; grid-column: left-column-start / left-column-end;', + }, + headline: { + tablet: 'grid-row: 2;', + leftCol: 'grid-row: 1;', + }, + standfirst: { + tablet: 'grid-row: 3;', + leftCol: 'grid-row: 2;', + }, + media: { + tablet: 'grid-row: 4;', + leftCol: 'grid-row: 3;', + }, + meta: { + tablet: 'grid-row: 5;', + leftCol: + 'grid-row: 3 / span 2; grid-column: left-column-start / left-column-end;', + }, + body: { + mobile: grid.column.all, + tablet: `grid-row: 6; ${grid.column.all}`, + leftCol: 'grid-row: 4;', + }, + 'right-column': { + desktop: + 'grid-row: 1 / span 6; grid-column: right-column-start / right-column-end;', + leftCol: + 'grid-row: 1 / span 4; grid-column: right-column-start / right-column-end;', + }, +}; + +const layoutCssMaps: Record = { + standard: standardCss, +}; + +/** + * 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. + * + * @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 areaOverrides = layoutCssMaps[layoutType][area] ?? {}; + + const breakpointCss = Object.entries(areaOverrides).map( + ([bp, styles]) => css` + ${breakpointQueries[bp as Breakpoint]} { + ${styles} + } + `, + ); + + // 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} + `; +}; diff --git a/dotcom-rendering/src/lib/ArticleRenderer.tsx b/dotcom-rendering/src/lib/ArticleRenderer.tsx index 20cc9d78058..b6f36284f39 100644 --- a/dotcom-rendering/src/lib/ArticleRenderer.tsx +++ b/dotcom-rendering/src/lib/ArticleRenderer.tsx @@ -1,5 +1,6 @@ import { css } from '@emotion/react'; import { useConfig } from '../components/ConfigContext'; +import { grid } from '../grid'; import { interactiveLegacyClasses } from '../layouts/lib/interactiveLegacyStyling'; import type { ServerSideTests, Switches } from '../types/config'; import type { FEElement } from '../types/content'; @@ -37,6 +38,7 @@ type Props = { contributionsServiceUrl: string; shouldHideAds: boolean; idApiUrl?: string; + isOldInteractive?: boolean; }; export const ArticleRenderer = ({ @@ -61,6 +63,7 @@ export const ArticleRenderer = ({ contributionsServiceUrl, shouldHideAds, idApiUrl, + isOldInteractive = false, }: Props) => { const isSectionedMiniProfilesArticle = elements.filter( @@ -106,6 +109,13 @@ export const ArticleRenderer = ({ // ^^ Until we decide where to do the "isomorphism split" in this this code is not safe here. // But should be soon. + const interactiveLayoutCSS = css` + ${grid.container} + > * { + ${grid.column.centre} + } + `; + return (
{renderingTarget === 'Apps' ? renderedElements