diff --git a/dotcom-rendering/src/components/CustomSubNav.stories.tsx b/dotcom-rendering/src/components/CustomSubNav.stories.tsx new file mode 100644 index 00000000000..96a52acc07c --- /dev/null +++ b/dotcom-rendering/src/components/CustomSubNav.stories.tsx @@ -0,0 +1,49 @@ +import preview from '../../.storybook/preview'; +import type { CustomSubnav } from '../types/customSubnav'; +import { CustomSubNav as CustomSubNavComponent } from './CustomSubNav'; + +const links: CustomSubnav['links'] = [ + { linkText: 'Wildlife', dotcomPath: '/environment/wildlife' }, + { linkText: 'Energy', dotcomPath: '/environment/energy' }, + { linkText: 'Pollution', dotcomPath: '/environment/pollution' }, + { linkText: 'Climate crisis', dotcomPath: '/environment/climate-crisis' }, + { linkText: 'Trees and forests', dotcomPath: '/environment/forests' }, +]; + +const baseSubnav: CustomSubnav = { + id: 'environment-custom-subnav', + header: { + headerText: 'The age of extinction', + dotcomPath: '/environment/age-of-extinction', + copy: 'Our reporting on the sixth mass extinction and the fight to protect the natural world.', + }, + format: 'large', + links, + pages: [{ type: 'front', path: 'environment' }], + lastUpdated: 1_700_000_000_000, + updatedBy: 'Ada Lovelace', + updatedEmail: 'ada.lovelace@theguardian.com', +}; + +const meta = preview.meta({ + component: CustomSubNavComponent, + parameters: {}, + args: { + guardianBaseURL: 'https://www.theguardian.com', + }, +}); + +export const Large = meta.story({ + args: { + subnav: baseSubnav, + }, +}); + +export const Compact = meta.story({ + args: { + subnav: { + ...baseSubnav, + format: 'compact', + }, + }, +}); diff --git a/dotcom-rendering/src/components/CustomSubNav.tsx b/dotcom-rendering/src/components/CustomSubNav.tsx new file mode 100644 index 00000000000..7a5296cbd7c --- /dev/null +++ b/dotcom-rendering/src/components/CustomSubNav.tsx @@ -0,0 +1,168 @@ +import { css } from '@emotion/react'; +import { + from, + headlineBold20, + space, + textSans15, + textSans17, +} from '@guardian/source/foundations'; +import { nestedOphanComponents } from '../lib/ophan-helpers'; +import { palette } from '../palette'; +import type { CustomSubnav } from '../types/customSubnav'; + +type Props = { + subnav: CustomSubnav; + /** Base URL used to resolve site-relative `dotcomPath`s. */ + guardianBaseURL: string; +}; + +/** + * Resolves a site-relative `dotcomPath` against the site base URL. Absolute + * URLs are returned unchanged. + */ +const buildUrl = (dotcomPath: string, guardianBaseURL: string): string => { + if (/^https?:\/\//.test(dotcomPath)) { + return dotcomPath; + } + return `${guardianBaseURL}${ + dotcomPath.startsWith('/') ? '' : '/' + }${dotcomPath}`; +}; + +const wrapperStyles = css` + display: flex; + flex-direction: column; + gap: ${space[1]}px; + padding: ${space[2]}px 10px; + color: var(--sub-nav-link-header); + border-bottom: 1px solid ${palette('--sub-nav-border')}; + + ${from.mobileLandscape} { + padding: ${space[2]}px 20px; + } +`; + +const compactWrapperStyles = css` + flex-direction: row; + align-items: baseline; + flex-wrap: wrap; + gap: ${space[2]}px ${space[4]}px; +`; + +const headerTextStyles = css` + ${headlineBold20}; + color: inherit; + margin: 0; +`; + +const compactHeaderTextStyles = css` + ${textSans17}; + font-weight: 700; +`; + +const headerLinkStyles = css` + color: inherit; + text-decoration: none; + + :hover { + color: ${palette('--sub-nav-link-hover')}; + } +`; + +const copyStyles = css` + ${textSans15}; + color: inherit; + margin: 0; +`; + +const linksListStyles = css` + ${textSans15}; + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + column-gap: ${space[4]}px; + row-gap: ${space[1]}px; + + ${from.tablet} { + ${textSans17}; + } +`; + +const linkStyles = css` + color: inherit; + text-decoration: none; + font-weight: 500; + + :hover { + color: ${palette('--sub-nav-link-hover')}; + } +`; + +/** + * Renders a custom subnav targeted at a front. Frontend forwards the already + * selected subnav; DCR just renders it. + * + * For now only the header text and links are rendered. Images and palettes are + * intentionally ignored. Unknown `format` values fail safe to `compact`. + */ +export const CustomSubNav = ({ subnav, guardianBaseURL }: Props) => { + const { header, links, format } = subnav; + const isLarge = format === 'large'; + + const headerText = header.dotcomPath ? ( + + {header.headerText} + + ) : ( + header.headerText + ); + + return ( + + ); +}; diff --git a/dotcom-rendering/src/frontend/feFront.ts b/dotcom-rendering/src/frontend/feFront.ts index 1b3144b1704..17f00846e28 100644 --- a/dotcom-rendering/src/frontend/feFront.ts +++ b/dotcom-rendering/src/frontend/feFront.ts @@ -8,6 +8,7 @@ import type { Newsletter, StarRating, } from '../types/content'; +import type { CustomSubnav } from '../types/customSubnav'; import type { FooterType } from '../types/footer'; import type { EditorialTest } from '../types/front'; import type { FENavType } from '../types/frontend'; @@ -34,6 +35,11 @@ export interface FEFront { deeplyRead?: FETrailType[]; contributionsServiceUrl: string; canonicalUrl?: string; + /** + * An optional custom subnav targeted at this front. Omitted entirely when + * no custom subnav targets the front. + */ + subnav?: CustomSubnav; } interface FEPressedPage { diff --git a/dotcom-rendering/src/frontend/schemas/feFront.json b/dotcom-rendering/src/frontend/schemas/feFront.json index 635b4daaed9..60bb07659ec 100644 --- a/dotcom-rendering/src/frontend/schemas/feFront.json +++ b/dotcom-rendering/src/frontend/schemas/feFront.json @@ -57,6 +57,10 @@ }, "canonicalUrl": { "type": "string" + }, + "subnav": { + "$ref": "#/definitions/CustomSubnav", + "description": "An optional custom subnav targeted at this front. Omitted entirely when\nno custom subnav targets the front." } }, "required": [ @@ -4456,6 +4460,176 @@ ] } ] + }, + "CustomSubnav": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "header": { + "$ref": "#/definitions/CustomSubnavHeader" + }, + "format": { + "$ref": "#/definitions/CustomSubnavFormat" + }, + "links": { + "type": "array", + "items": { + "$ref": "#/definitions/SubnavLink" + } + }, + "pages": { + "type": "array", + "items": { + "$ref": "#/definitions/TargetedPage" + } + }, + "images": { + "type": "array", + "items": { + "$ref": "#/definitions/SubnavImage" + } + }, + "palette": { + "$ref": "#/definitions/Palettes" + }, + "lastUpdated": { + "type": "number" + }, + "updatedBy": { + "type": "string" + }, + "updatedEmail": { + "type": "string" + } + }, + "required": [ + "format", + "header", + "id", + "lastUpdated", + "links", + "pages", + "updatedBy", + "updatedEmail" + ] + }, + "CustomSubnavHeader": { + "type": "object", + "properties": { + "headerText": { + "type": "string" + }, + "dotcomPath": { + "type": "string" + }, + "copy": { + "type": "string" + } + }, + "required": [ + "copy", + "headerText" + ] + }, + "CustomSubnavFormat": { + "enum": [ + "compact", + "large" + ], + "type": "string" + }, + "SubnavLink": { + "type": "object", + "properties": { + "linkText": { + "type": "string" + }, + "dotcomPath": { + "type": "string" + } + }, + "required": [ + "dotcomPath", + "linkText" + ] + }, + "TargetedPage": { + "type": "object", + "properties": { + "type": { + "$ref": "#/definitions/TargetedPageType" + }, + "path": { + "type": "string" + } + }, + "required": [ + "path", + "type" + ] + }, + "TargetedPageType": { + "description": "Types for the custom subnav that frontend can target at individual fronts.\n\nFrontend evaluates the targeting rules (`pages`) and forwards the already\nselected subnav in the `/Front` payload. It is omitted entirely when no\ncustom subnav targets the front, so DCR must treat it as optional.\n\nSerialized from the fapi `CustomSubnav` model. Enum values serialize as\nlowercase strings.", + "enum": [ + "article", + "front", + "hasTag" + ], + "type": "string" + }, + "SubnavImage": { + "type": "object", + "properties": { + "imageSrc": { + "type": "string" + }, + "breakpoint": { + "$ref": "#/definitions/ImageBreakpoint" + } + }, + "required": [ + "breakpoint", + "imageSrc" + ] + }, + "ImageBreakpoint": { + "enum": [ + "mobile", + "tablet", + "web" + ], + "type": "string" + }, + "Palettes": { + "type": "object", + "properties": { + "light": { + "$ref": "#/definitions/Palette" + }, + "dark": { + "$ref": "#/definitions/Palette" + } + }, + "required": [ + "dark", + "light" + ] + }, + "Palette": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "header": { + "type": "string" + }, + "link": { + "type": "string" + } + } } }, "$schema": "http://json-schema.org/draft-07/schema#" diff --git a/dotcom-rendering/src/layouts/FrontLayout.tsx b/dotcom-rendering/src/layouts/FrontLayout.tsx index fc875502edc..f308a88f748 100644 --- a/dotcom-rendering/src/layouts/FrontLayout.tsx +++ b/dotcom-rendering/src/layouts/FrontLayout.tsx @@ -7,6 +7,7 @@ import { import { Fragment } from 'react'; import { AdSlot } from '../components/AdSlot.web'; import { CPScottHeader } from '../components/CPScottHeader'; +import { CustomSubNav } from '../components/CustomSubNav'; import { DecideContainer } from '../components/DecideContainer'; import { DirectoryPageNavIsland } from '../components/DirectoryPageNavIsland'; import { EditionSwitcherBanner } from '../components/EditionSwitcherBanner.island'; @@ -247,6 +248,23 @@ export const FrontLayout = ({ front, NAV }: Props) => { contentType={front.config.contentType} /> + {front.subnav && ( +
+ +
+ )} + {isPaidContent && (