diff --git a/website/src/__tests__/components/markdown-table-scroll.test.tsx b/website/src/__tests__/components/markdown-table-scroll.test.tsx new file mode 100644 index 000000000..25f1c9642 --- /dev/null +++ b/website/src/__tests__/components/markdown-table-scroll.test.tsx @@ -0,0 +1,148 @@ +import { render, screen } from "@testing-library/react"; +import fc from "fast-check"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { MarkdownFormatter } from "@/components/blog/MarkdownFormatter"; + +const WIDE_TABLE_MARKDOWN = [ + "Table 3: Cost of the energy bill measures (£ million)", + "", + "| Measure | PolicyEngine, 2026-27 | HM Treasury, 2026-27 | PolicyEngine, 2027-28 | HM Treasury, 2027-28 |", + "| --- | --- | --- | --- | --- |", + "| Household Energy Payment | 1,969 | 2,625 | 0 | 0 |", + "| Total | 2,808 | 3,380 | 353 | 350 |", +].join("\n"); + +const RAW_HTML_TABLE_MARKDOWN = + "" + + "
YearCost
20261
"; + +const SCROLL_REGION_NAME = "Scrollable table"; + +function markdownTable(columns: number, rows: number, tableIndex: number) { + const cells = (row: number) => + Array.from({ length: columns }, (_, col) => `t${tableIndex}r${row}c${col}`); + return [ + `| ${cells(0).join(" | ")} |`, + `| ${Array.from({ length: columns }, () => "---").join(" | ")} |`, + ...Array.from( + { length: rows }, + (_, row) => `| ${cells(row + 1).join(" | ")} |`, + ), + ].join("\n"); +} + +describe("MarkdownFormatter table scroll container", () => { + beforeEach(() => { + // jsdom has no matchMedia; the formatter's display-category hook reads it. + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + test("given a markdown table then it renders inside a horizontal scroll container", () => { + // Given / When + render(); + + // Then + const scroller = screen.getByRole("table").parentElement; + expect(scroller).toHaveAttribute("data-table-scroll"); + expect(scroller).toHaveStyle({ overflowX: "auto", maxWidth: "100%" }); + }); + + test("given a raw HTML table then it is wrapped in the scroll container too", () => { + // Given / When + render(); + + // Then + expect(screen.getByRole("table").parentElement).toHaveAttribute( + "data-table-scroll", + ); + }); + + test("given a table that fits its column then the container adds no tab stop", () => { + // Given / When (jsdom reports zero widths, so nothing overflows) + render(); + + // Then + const scroller = screen.getByRole("table").parentElement; + expect(scroller).toHaveAttribute("data-table-scroll"); + expect(scroller).not.toHaveAttribute("tabindex"); + expect( + screen.queryByRole("region", { name: SCROLL_REGION_NAME }), + ).toBeNull(); + }); + + test("given a table wider than its column then the container is a focusable labelled region", () => { + // Given + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + vi.spyOn(HTMLElement.prototype, "scrollWidth", "get").mockReturnValue(527); + vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(343); + + // When + render(); + + // Then + const region = screen.getByRole("region", { name: SCROLL_REGION_NAME }); + expect(region).toHaveAttribute("tabindex", "0"); + expect(region).toContainElement(screen.getByRole("table")); + }); + + test("given any number of tables then each sits alone in its own scroll container", () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 4 }), + fc.integer({ min: 1, max: 8 }), + fc.integer({ min: 1, max: 5 }), + (tableCount, columns, rows) => { + // Given + const markdown = Array.from({ length: tableCount }, (_, i) => + markdownTable(columns, rows, i), + ).join("\n\nA paragraph between tables.\n\n"); + + // When + const { container, unmount } = render( + , + ); + + // Then + const tables = Array.from(container.querySelectorAll("table")); + const scrollers = container.querySelectorAll("[data-table-scroll]"); + expect(tables).toHaveLength(tableCount); + expect(scrollers).toHaveLength(tableCount); + for (const table of tables) { + expect(table.parentElement).toHaveAttribute("data-table-scroll"); + expect(table.parentElement?.querySelectorAll("table")).toHaveLength( + 1, + ); + } + unmount(); + }, + ), + { numRuns: 25 }, + ); + // 25 renders take well under a second normally, but a cold, loaded + // runner can pass Vitest's 5s default. + }, 30_000); +}); diff --git a/website/src/app/[countryId]/research/[slug]/ArticleClient.tsx b/website/src/app/[countryId]/research/[slug]/ArticleClient.tsx index fd8f68221..365064b1f 100644 --- a/website/src/app/[countryId]/research/[slug]/ArticleClient.tsx +++ b/website/src/app/[countryId]/research/[slug]/ArticleClient.tsx @@ -356,7 +356,7 @@ function PostBody({ -
+
{bodyContent}
diff --git a/website/src/components/blog/MarkdownFormatter.tsx b/website/src/components/blog/MarkdownFormatter.tsx index dd3166afc..9265435ad 100644 --- a/website/src/components/blog/MarkdownFormatter.tsx +++ b/website/src/components/blog/MarkdownFormatter.tsx @@ -94,6 +94,49 @@ function Tr({ children }: { children?: React.ReactNode }) { ); } +/** + * Horizontal scroll container for a markdown table, so a table wider than + * the column scrolls on its own instead of widening the page. It carries the + * table's vertical margins (so they still collapse with neighbouring + * paragraphs) and its shadow (which a scroll container would otherwise clip). + */ +export function TableScroll({ children }: { children?: React.ReactNode }) { + const ref = useRef(null); + const [overflowing, setOverflowing] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el || typeof ResizeObserver === "undefined") return; + const update = () => setOverflowing(el.scrollWidth > el.clientWidth); + update(); + const observer = new ResizeObserver(update); + observer.observe(el); + if (el.firstElementChild) observer.observe(el.firstElementChild); + return () => observer.disconnect(); + }, []); + + return ( + + ); +} + function Th({ children }: { children?: React.ReactNode }) { const displayCategory = useDisplayCategory(); const mobile = displayCategory === "mobile"; @@ -868,20 +911,19 @@ export function MarkdownFormatter({ }, table: ({ children }) => ( - - {children} -
+ + + {children} +
+
), th: Th,