From c40755ddb1d62e619d5d0e249824adbcc787a780 Mon Sep 17 00:00:00 2001 From: Peran Osborn Date: Fri, 9 Oct 2026 19:55:02 +0100 Subject: [PATCH] Add horizontal table of contents for The Filter in the revamped immersive layout Immersive Filter articles in the revamped layout (StandardLayoutArticleGrid) now show the table of contents as a sticky, horizontally scrolling nav instead of the "Jump to" list. ArticleBody takes a tableOfContentsStyle prop ('default' | 'horizontal'), and section headings get a scroll margin so they don't land underneath the sticky nav. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/components/ArticleBody.tsx | 32 +- .../HorizontalTableOfContents.island.test.tsx | 54 +++ .../HorizontalTableOfContents.island.tsx | 398 ++++++++++++++++++ .../HorizontalTableOfContents.stories.tsx | 63 +++ .../src/layouts/StandardLayoutArticleGrid.tsx | 7 + dotcom-rendering/src/paletteDeclarations.ts | 60 +++ 6 files changed, 610 insertions(+), 4 deletions(-) create mode 100644 dotcom-rendering/src/components/HorizontalTableOfContents.island.test.tsx create mode 100644 dotcom-rendering/src/components/HorizontalTableOfContents.island.tsx create mode 100644 dotcom-rendering/src/components/HorizontalTableOfContents.stories.tsx diff --git a/dotcom-rendering/src/components/ArticleBody.tsx b/dotcom-rendering/src/components/ArticleBody.tsx index a24f1ef3655..1dc7738f4ee 100644 --- a/dotcom-rendering/src/components/ArticleBody.tsx +++ b/dotcom-rendering/src/components/ArticleBody.tsx @@ -20,6 +20,10 @@ import { palette as themePalette } from '../palette'; import type { Block } from '../types/blocks'; import type { Switches } from '../types/config'; import type { TagType } from '../types/tag'; +import { + HORIZONTAL_TABLE_OF_CONTENTS_SCROLL_MARGIN, + HorizontalTableOfContents, +} from './HorizontalTableOfContents.island'; import { Island } from './Island'; import { LiveBlogRenderer } from './LiveBlogRenderer'; import { TableOfContents } from './TableOfContents.island'; @@ -50,6 +54,7 @@ type Props = { onFirstPage?: boolean; keyEvents?: Block[]; tableOfContents?: TableOfContentsItem[]; + tableOfContentsStyle?: 'default' | 'horizontal'; lang?: string; isRightToLeftLang?: boolean; shouldHideAds: boolean; @@ -58,6 +63,16 @@ type Props = { isInDeprecatedInteractiveLayout?: boolean; }; +/** + * Stops section headings landing underneath the sticky horizontal table of + * contents when a reader follows one of its links. + */ +const horizontalTableOfContentsHeadingOffset = css` + h2[id] { + scroll-margin-top: ${HORIZONTAL_TABLE_OF_CONTENTS_SCROLL_MARGIN}px; + } +`; + const globalOlStyles = () => css` ol:not([data-ignore='global-ol-styling']) { counter-reset: li; @@ -148,6 +163,7 @@ export const ArticleBody = ({ keyEvents = [], keywordIds, tableOfContents, + tableOfContentsStyle = 'default', lang, isRightToLeftLang = false, editionId, @@ -235,10 +251,16 @@ export const ArticleBody = ({ <> {tableOfContents && tableOfContents.length > 0 && ( - + {tableOfContentsStyle === 'horizontal' ? ( + + ) : ( + + )} )}
{ + const line = 60; + + it('returns undefined above the first heading', () => { + expect( + getCurrentSectionId( + [ + { id: 'a', top: 200 }, + { id: 'b', top: 900 }, + ], + line, + ), + ).toBeUndefined(); + }); + + it('returns the last heading scrolled past the line', () => { + expect( + getCurrentSectionId( + [ + { id: 'a', top: -800 }, + { id: 'b', top: -100 }, + { id: 'c', top: 400 }, + ], + line, + ), + ).toBe('b'); + }); + + it('selects a heading scrolled exactly to its scroll margin', () => { + expect( + getCurrentSectionId( + [ + { id: 'a', top: -500 }, + { id: 'b', top: 60.5 }, + ], + line, + ), + ).toBe('b'); + }); + + it('goes back to the earlier section when scrolling up past a heading', () => { + expect( + getCurrentSectionId( + [ + { id: 'a', top: -500 }, + { id: 'b', top: 120 }, + ], + line, + ), + ).toBe('a'); + }); +}); diff --git a/dotcom-rendering/src/components/HorizontalTableOfContents.island.tsx b/dotcom-rendering/src/components/HorizontalTableOfContents.island.tsx new file mode 100644 index 00000000000..4f9d15096a1 --- /dev/null +++ b/dotcom-rendering/src/components/HorizontalTableOfContents.island.tsx @@ -0,0 +1,398 @@ +import { css } from '@emotion/react'; +import { isNonNullable } from '@guardian/libs'; +import { + from, + space, + textSansBold14, + until, +} from '@guardian/source/foundations'; +import { + SvgChevronLeftSingle, + SvgChevronRightSingle, +} from '@guardian/source/react-components'; +import { useEffect, useRef, useState } from 'react'; +import { getZIndex } from '../lib/getZIndex'; +import type { TableOfContentsItem } from '../model/enhanceTableOfContents'; +import { palette } from '../palette'; + +interface Props { + tableOfContents: TableOfContentsItem[]; +} + +/** Distance between the top of the viewport and the pinned bar. */ +const STICKY_TOP = space[2]; + +/** + * The bar's fixed height. The links can't wrap, so the bar can't grow, which + * is what lets this be shared with the section headings' `scroll-margin-top`. + */ +const BAR_HEIGHT = 44; + +/** + * How far below the top of the viewport a section heading sits after a reader + * follows one of the nav's links: clear of the pinned bar, with a small gap. + * It is also the line the reader has to scroll a heading past for its section + * to become the current one, so that following a link always selects it. + */ +export const HORIZONTAL_TABLE_OF_CONTENTS_SCROLL_MARGIN = + STICKY_TOP + BAR_HEIGHT + space[2]; + +/** + * Works out which section the reader is in: the last heading whose top has + * scrolled up to the line under the bar. Unlike observing headings as they + * enter the viewport, this gives the right answer when scrolling up as well as + * down. Returns `undefined` above the first heading. + * + * @param sections Section headings, in document order. + */ +export const getCurrentSectionId = ( + sections: Array<{ id: string; top: number }>, + line: number, +): string | undefined => { + let current: string | undefined; + + for (const section of sections) { + // Allow a pixel for rounding, so that a heading scrolled exactly to its + // `scroll-margin-top` counts as current. + if (section.top > line + 1) break; + current = section.id; + } + + return current; +}; + +const prefersReducedMotion = (): boolean => + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +const wrapperStyles = css` + position: sticky; + top: ${STICKY_TOP}px; + z-index: ${getZIndex('tableOfContents')}; + box-sizing: border-box; + display: flex; + align-items: center; + width: 100%; + max-width: 620px; + height: ${BAR_HEIGHT}px; + margin: ${space[4]}px 0 ${space[6]}px; + padding: 0 10px; + background-color: ${palette('--table-of-contents-horizontal-background')}; + border: 1px solid ${palette('--table-of-contents-horizontal-border')}; + border-radius: 4px; + + /* Edge fades, shown when there is more to scroll to on that side. */ + &::before, + &::after { + content: ''; + position: absolute; + top: 0; + z-index: 1; + width: 45px; + height: 100%; + background: linear-gradient( + 90deg, + transparent 0%, + ${palette('--table-of-contents-horizontal-background')} 65% + ); + pointer-events: none; + visibility: hidden; + } + + &::before { + left: 0; + transform: rotate(180deg); + border-radius: 0 4px 4px 0; + } + + &::after { + right: 0; + border-radius: 0 4px 4px 0; + } + + ${from.desktop} { + &::before, + &::after { + width: 90px; + } + } +`; + +const showPrevStyles = css` + &::before { + visibility: visible; + } +`; + +const showNextStyles = css` + &::after { + visibility: visible; + } +`; + +const scrollStyles = css` + flex: 1; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +`; + +const listStyles = css` + display: flex; + width: max-content; + margin: 0; + /* Room for the focus outline, which the scroll container would clip. */ + padding: ${space[1]}px 2px; + list-style: none; +`; + +const itemStyles = css` + display: flex; + align-items: center; + padding: 0 12px; + border-right: 1px solid ${palette('--table-of-contents-horizontal-border')}; + + &:first-of-type { + padding-left: 0; + } + + &:last-of-type { + padding-right: 0; + border-right: none; + } +`; + +const linkStyles = css` + ${textSansBold14}; + display: inline-flex; + align-items: center; + min-height: 24px; + white-space: nowrap; + color: ${palette('--table-of-contents-horizontal-text')}; + text-decoration: none; + text-underline-offset: 4px; + + &:hover { + color: ${palette('--table-of-contents-horizontal-text-hover')}; + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid + ${palette('--table-of-contents-horizontal-text-active')}; + outline-offset: 2px; + border-radius: 2px; + } + + &[aria-current='location'] { + color: ${palette('--table-of-contents-horizontal-text-active')}; + text-decoration: underline; + text-decoration-thickness: 2px; + } +`; + +const buttonStyles = css` + position: absolute; + top: 0; + z-index: 2; + display: flex; + align-items: center; + height: 100%; + margin: 0; + padding: 0 6px; + background-color: transparent; + border: none; + cursor: pointer; + + svg { + fill: ${palette('--table-of-contents-horizontal-text')}; + } + + &:hover svg { + fill: ${palette('--table-of-contents-horizontal-text-hover')}; + } + + /* Below tablet the nav is swiped, so only the fades are shown. */ + ${until.tablet} { + display: none; + } +`; + +const prevButtonStyles = css` + left: 0; +`; + +const nextButtonStyles = css` + right: 0; +`; + +/** + * A table of contents shown as a sticky, horizontally scrolling bar of the + * article's sections: the horizontal alternative to the "Jump to" list in + * `TableOfContents`. + * + * The section the reader is in is highlighted and kept centred in the nav. When + * the sections don't all fit, fades show on the side(s) with more to scroll to, + * plus arrow buttons from tablet up. + * + * Section headings need `scroll-margin-top` set to + * `HORIZONTAL_TABLE_OF_CONTENTS_SCROLL_MARGIN` so that they don't land under the bar. + */ +export const HorizontalTableOfContents = ({ tableOfContents }: Props) => { + const [currentSectionId, setCurrentSectionId] = useState(); + const [canScrollPrev, setCanScrollPrev] = useState(false); + const [canScrollNext, setCanScrollNext] = useState(false); + const navRef = useRef(null); + + // Track the section the reader is in. + useEffect(() => { + // Anchor ids are added to the `h2`s by `enhanceH2s`. + const sections = tableOfContents + .map(({ id }) => document.getElementById(id)) + .filter(isNonNullable); + + if (sections.length === 0) return; + + const observer = new IntersectionObserver( + () => { + setCurrentSectionId( + getCurrentSectionId( + sections.map((section) => ({ + id: section.id, + top: section.getBoundingClientRect().top, + })), + HORIZONTAL_TABLE_OF_CONTENTS_SCROLL_MARGIN, + ), + ); + }, + { + rootMargin: `-${HORIZONTAL_TABLE_OF_CONTENTS_SCROLL_MARGIN}px 0px 0px 0px`, + threshold: 1, + }, + ); + + for (const section of sections) observer.observe(section); + + return () => observer.disconnect(); + }, [tableOfContents]); + + // Show the fades and arrows while the first or last item is out of view, + // so that they stay right however the nav was scrolled: swipe, trackpad, + // arrow or focus. The 1px margin absorbs subpixel rounding. + useEffect(() => { + const nav = navRef.current; + const first = nav?.querySelector('li:first-of-type'); + const last = nav?.querySelector('li:last-of-type'); + if (!nav || !first || !last) return; + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const isHidden = entry.intersectionRatio < 1; + if (entry.target === first) setCanScrollPrev(isHidden); + if (entry.target === last) setCanScrollNext(isHidden); + } + }, + { root: nav, rootMargin: '0px 1px', threshold: 1 }, + ); + + observer.observe(first); + observer.observe(last); + + return () => observer.disconnect(); + }, [tableOfContents]); + + // Keep the current section centred in the nav. This scrolls the nav only, + // unlike `scrollIntoView`, which would also scroll the page. + useEffect(() => { + const nav = navRef.current; + const link = nav?.querySelector('[aria-current="location"]'); + if (!nav || !link) return; + + const navRect = nav.getBoundingClientRect(); + const linkRect = link.getBoundingClientRect(); + const target = + nav.scrollLeft + + (linkRect.left - navRect.left) - + (navRect.width - linkRect.width) / 2; + const maxScrollLeft = nav.scrollWidth - nav.clientWidth; + + nav.scrollTo({ + left: Math.min(Math.max(target, 0), maxScrollLeft), + behavior: prefersReducedMotion() ? 'auto' : 'smooth', + }); + }, [currentSectionId]); + + const scrollByPage = (direction: -1 | 1) => { + const nav = navRef.current; + if (!nav) return; + + nav.scrollBy({ + left: direction * nav.clientWidth * 0.75, + behavior: prefersReducedMotion() ? 'auto' : 'smooth', + }); + }; + + return ( +
+ {canScrollPrev && ( + // Keyboard and screen reader users reach every section + // through the links, so the arrows are mouse-only. + + )} + + + + {canScrollNext && ( + + )} +
+ ); +}; diff --git a/dotcom-rendering/src/components/HorizontalTableOfContents.stories.tsx b/dotcom-rendering/src/components/HorizontalTableOfContents.stories.tsx new file mode 100644 index 00000000000..da2251681b0 --- /dev/null +++ b/dotcom-rendering/src/components/HorizontalTableOfContents.stories.tsx @@ -0,0 +1,63 @@ +import { css } from '@emotion/react'; +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { splitTheme } from '../../.storybook/decorators/splitThemeDecorator'; +import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat'; +import { HorizontalTableOfContents } from './HorizontalTableOfContents.island'; + +const meta = { + component: HorizontalTableOfContents, + title: 'Components/HorizontalTableOfContents', + decorators: [ + (Story) => ( +
+ +
+ ), + splitTheme([ + { + design: ArticleDesign.Standard, + display: ArticleDisplay.Immersive, + theme: Pillar.Lifestyle, + }, + ]), + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const FewSections = { + args: { + tableOfContents: [ + { id: 'best-overall', title: 'Best overall' }, + { id: 'best-budget', title: 'Best budget' }, + { id: 'how-we-tested', title: 'How we tested' }, + ], + }, +} satisfies Story; + +export const ManySections = { + args: { + tableOfContents: [ + 'Lifestyle', + 'Fashion', + 'Food & drink', + 'Beauty', + 'Home & garden', + 'Tech', + 'Outdoors', + 'Christmas gift guide', + 'Stocking fillers', + 'How we tested', + ].map((title) => ({ + id: title.toLowerCase().replace(/\W+/g, '-'), + title, + })), + }, +} satisfies Story; diff --git a/dotcom-rendering/src/layouts/StandardLayoutArticleGrid.tsx b/dotcom-rendering/src/layouts/StandardLayoutArticleGrid.tsx index 6fed5f71084..e94e0e0da50 100644 --- a/dotcom-rendering/src/layouts/StandardLayoutArticleGrid.tsx +++ b/dotcom-rendering/src/layouts/StandardLayoutArticleGrid.tsx @@ -39,6 +39,7 @@ import { getZIndex } from '../lib/getZIndex'; import { LABS_HEADER_HEIGHT } from '../lib/labs-constants'; import { safeParseURL } from '../lib/parse'; import { parse } from '../lib/slot-machine-flags'; +import { hasFilterSeriesTag } from '../lib/theFilter'; import { palette as themePalette } from '../palette'; import type { ArticleDeprecated } from '../types/article'; import type { RenderingTarget } from '../types/renderingTarget'; @@ -206,6 +207,11 @@ export const StandardLayoutArticleGrid = ({ const isLabs = format.theme === ArticleSpecial.Labs; const isImmersive = format.display === ArticleDisplay.Immersive; + const isFilterSeries = hasFilterSeriesTag(article.tags); + + const tableOfContentsStyle = + isImmersive && isFilterSeries ? 'horizontal' : 'default'; + const headlineBackgroundImmersive = themePalette( '--headline-background-immersive', ); @@ -755,6 +761,7 @@ export const StandardLayoutArticleGrid = ({ isDev={!!article.config.isDev} keywordIds={article.config.keywordIds} tableOfContents={article.tableOfContents} + tableOfContentsStyle={tableOfContentsStyle} lang={article.lang} isRightToLeftLang={article.isRightToLeftLang} editionId={article.editionId} diff --git a/dotcom-rendering/src/paletteDeclarations.ts b/dotcom-rendering/src/paletteDeclarations.ts index ddcf1439f0b..78b5327087b 100644 --- a/dotcom-rendering/src/paletteDeclarations.ts +++ b/dotcom-rendering/src/paletteDeclarations.ts @@ -1950,6 +1950,46 @@ const tableOfContentsBorderLight: PaletteFunction = () => const tableOfContentsBorderDark: PaletteFunction = () => sourcePalette.neutral[20]; +/** + * The horizontal table of contents: a sticky bar of links to an article's + * sections, e.g. on The Filter's immersive articles. A pillar-tinted bar, with the inactive links one step + * darker than the border so that they pass AA at 14px on the light surface. + */ +const tableOfContentsHorizontalPillar = ( + theme: ArticleTheme, + lightness: 100 | 200 | 300 | 400 | 500 | 600 | 800, + fallback: string, +): string => { + switch (theme) { + case ArticleSpecial.Labs: + case ArticleSpecial.SpecialReport: + case ArticleSpecial.SpecialReportAlt: + return fallback; + default: + return pillarPalette(theme, lightness); + } +}; +const tableOfContentsHorizontalBackgroundLight: PaletteFunction = ({ theme }) => + tableOfContentsHorizontalPillar(theme, 800, sourcePalette.neutral[97]); +const tableOfContentsHorizontalBackgroundDark: PaletteFunction = ({ theme }) => + tableOfContentsHorizontalPillar(theme, 100, sourcePalette.neutral[10]); +const tableOfContentsHorizontalBorderLight: PaletteFunction = ({ theme }) => + tableOfContentsHorizontalPillar(theme, 400, sourcePalette.neutral[46]); +const tableOfContentsHorizontalBorderDark: PaletteFunction = ({ theme }) => + tableOfContentsHorizontalPillar(theme, 500, sourcePalette.neutral[60]); +const tableOfContentsHorizontalTextLight: PaletteFunction = ({ theme }) => + tableOfContentsHorizontalPillar(theme, 300, sourcePalette.neutral[38]); +const tableOfContentsHorizontalTextDark: PaletteFunction = ({ theme }) => + tableOfContentsHorizontalPillar(theme, 500, sourcePalette.neutral[73]); +const tableOfContentsHorizontalTextHoverLight: PaletteFunction = ({ theme }) => + tableOfContentsHorizontalPillar(theme, 200, sourcePalette.neutral[20]); +const tableOfContentsHorizontalTextHoverDark: PaletteFunction = ({ theme }) => + tableOfContentsHorizontalPillar(theme, 600, sourcePalette.neutral[86]); +const tableOfContentsHorizontalTextActiveLight: PaletteFunction = () => + sourcePalette.neutral[7]; +const tableOfContentsHorizontalTextActiveDark: PaletteFunction = () => + sourcePalette.neutral[97]; + const podcastMetaTitleLight: PaletteFunction = () => sourcePalette.neutral[97]; const podcastMetaTitleDark: PaletteFunction = () => sourcePalette.neutral[97]; const podcastMetaButtonTextLight: PaletteFunction = () => @@ -8666,6 +8706,26 @@ const paletteColours = { light: tableOfContentsBorderLight, dark: tableOfContentsBorderDark, }, + '--table-of-contents-horizontal-background': { + light: tableOfContentsHorizontalBackgroundLight, + dark: tableOfContentsHorizontalBackgroundDark, + }, + '--table-of-contents-horizontal-border': { + light: tableOfContentsHorizontalBorderLight, + dark: tableOfContentsHorizontalBorderDark, + }, + '--table-of-contents-horizontal-text': { + light: tableOfContentsHorizontalTextLight, + dark: tableOfContentsHorizontalTextDark, + }, + '--table-of-contents-horizontal-text-active': { + light: tableOfContentsHorizontalTextActiveLight, + dark: tableOfContentsHorizontalTextActiveDark, + }, + '--table-of-contents-horizontal-text-hover': { + light: tableOfContentsHorizontalTextHoverLight, + dark: tableOfContentsHorizontalTextHoverDark, + }, '--tabs-input': { light: () => sourcePalette.neutral[100], dark: () => sourcePalette.neutral[0],