Print Document
v0.1.0DraftA printable book on US Letter pages: an optional cover, an optional contents page that links to each section, then one section per page. It is dumb on purpose: it lays out what it is given and knows nothing about where the text comes from.
registry/behivetech/templates/print-document
print-document.tsx
1import {2 type HTMLAttributes,3 type ReactNode,4 useEffect,5 useId,6 useRef,7} from "react";8import { getClassName } from "@bht-component-registry/class-names";9import styles from "./print-document.module.scss";1011/** The custom property the `@page` margin box reads; it must sit on the root element because a page margin box inherits from there, not from this component. */12const RUNNING_TITLE_PROPERTY = "--print-document-running-title";1314/** One entry per mounted document, oldest mount first and kept in place for the document's whole mount; `title` is empty while it has no running title. The last entry with a title is the one `<html>` shows. Module-level because every instance writes to the same root element, so it is per module instance: an app that loads both the ESM and the CJS build gets two lists. */15const mountedTitles: { title: string }[] = [];1617/** One section of the document: its own page, listed in the contents. */18export interface PrintDocumentSection {19 /** Unique within the page, so prefix it if it comes from user data (a slug such as `main` would collide with another element); the section's `id` and the target of its contents link */20 id: string;21 /** The section heading (an `h2`) and its entry in the contents */22 title: string;23 /** The section body, already rendered (for stored HTML, render it through `RichText`) */24 content: ReactNode;25}2627export interface PrintDocumentProps extends Omit<28 HTMLAttributes<HTMLDivElement>,29 "children" | "title"30> {31 /** Additional class names to merge with the component root element */32 className?: string;33 /** The cover page content. It prints on its own page with no running title. Omit it for a document without a cover */34 cover?: ReactNode;35 /** The heading of the contents page, which lists every section in order with no page numbers. Omit it for no contents page */36 toc?: string;37 /** The sections in print order; each starts on a new page */38 sections: PrintDocumentSection[];39 /**40 * Text repeated in the top margin of every page after the cover. It prints only where the browser supports page margin boxes.41 * A page margin box inherits from the root element, so this sets the custom property `--print-document-running-title` as an inline style on `<html>` once mounted. That is a documented exception to the no-inline-styles rule: it is the only way to pass a prop to a margin box. No title prints before hydration or with JavaScript off. With several documents mounted, `<html>` holds the title of the one mounted last that is still mounted and has a title (retitling a document does not change its place), whatever order they unmount in, and nothing once none is left42 */43 runningTitle?: string;44}4546/**47 * Turns text into a CSS string token; quotes and backslashes are escaped and a48 * line break is flattened so one title cannot end the declaration.49 */50const toCssString = (text: string): string =>51 JSON.stringify(text.replace(/\s+/g, " "));5253/** Writes the last remaining title to `<html>`, or clears it when none is left. */54const applyRunningTitle = () => {55 const rootElement = document.documentElement;56 const last = [...mountedTitles].reverse().find(({ title }) => title);5758 if (last) {59 rootElement.style.setProperty(60 RUNNING_TITLE_PROPERTY,61 toCssString(last.title),62 );63 } else {64 rootElement.style.removeProperty(RUNNING_TITLE_PROPERTY);65 }66};6768/**69 * A printable book as a template: an optional cover page, an optional contents70 * page that links to each section, then one section per entry, each starting on71 * a new US Letter page. The print rules use their own named page, so they72 * never change how any other page of the site prints. On screen each page shows73 * as a white sheet with dark text, the same as on paper, whatever the site theme.74 *75 * The running title is handed to the page margin box through a custom property76 * on the root element, so it appears once the component has mounted.77 */78export const PrintDocument = ({79 className,80 cover,81 toc,82 sections,83 runningTitle,84 ...rest85}: PrintDocumentProps) => {86 const [rootClass, getChildClass] = getClassName({87 className,88 rootClass: "BHT__PrintDocument",89 styles,90 });91 const headingIdBase = useId();92 const tocHeadingId = `${headingIdBase}-toc`;9394 const entryRef = useRef({ title: "" });9596 // The entry joins the list once per mount and stays in its place, so97 // retitling a document never moves it ahead of one mounted after it.98 useEffect(() => {99 const entry = entryRef.current;100101 mountedTitles.push(entry);102103 // Remove only this document's entry, so whichever document unmounts first104 // never brings back a title that no mounted document owns.105 return () => {106 const index = mountedTitles.indexOf(entry);107108 if (index >= 0) {109 mountedTitles.splice(index, 1);110 applyRunningTitle();111 }112 };113 }, []);114115 useEffect(() => {116 entryRef.current.title = runningTitle?.trim() ?? "";117 applyRunningTitle();118 }, [runningTitle]);119120 return (121 <div {...rest} className={rootClass}>122 {cover && <section className={getChildClass("cover")}>{cover}</section>}123 {toc && sections.length > 0 && (124 <nav className={getChildClass("toc")} aria-labelledby={tocHeadingId}>125 <h2 id={tocHeadingId} className={getChildClass("toc-heading")}>126 {toc}127 </h2>128 <ol className={getChildClass("toc-list")}>129 {sections.map(({ id, title }) => (130 <li key={id} className={getChildClass("toc-item")}>131 <a href={`#${id}`} className={getChildClass("toc-link")}>132 {title}133 </a>134 </li>135 ))}136 </ol>137 </nav>138 )}139 {sections.map(({ id, title, content }, index) => (140 <section141 key={id}142 id={id}143 className={getChildClass("section")}144 aria-labelledby={`${headingIdBase}-${index}`}145 >146 <h2147 id={`${headingIdBase}-${index}`}148 className={getChildClass("section-title")}149 >150 {title}151 </h2>152 {content}153 </section>154 ))}155 </div>156 );157};
print-document.module.scss
1// US Letter pages on a named page, so none of these rules touch how any other2// page prints. The top margin (0.917in, 88px at 96dpi) leaves room for the3// running title in the margin box and keeps the section title where the guide4// page's own print layout puts it. The cover has its own named page: it has no5// running title and a plain half-inch margin, and a change of named page6// forces the page break.7@page print-document {8 size: letter;9 margin: 0.917in 0.5in 0.5in;1011 @top-left {12 content: var(--print-document-running-title, "");13 font-family: var(--md-sys-typescale-body-font);14 font-size: var(--md-sys-typescale-body-small-size);15 // A system colour, not a token: on paper the page is always light, and the print rules of RichText and the guide page use Canvas and CanvasText the same way.16 color: GrayText;17 }18}1920@page print-document-cover {21 size: letter;22 margin: 0.5in;23}2425// One printed or on-screen page. On screen it is a sheet; on paper the page26// margins do the padding, so the sheet drops its box.27@mixin sheet {28 box-sizing: border-box;29 width: 100%;30 max-width: 51rem;31 padding: var(--spacing-12) var(--spacing-8);32 margin: 0 auto var(--spacing-8);33 font-family: var(--md-sys-typescale-body-font);3435 // Paper: white with dark text whatever the site theme, on screen as in print.36 // The system colours Canvas and CanvasText follow `color-scheme: light`. The37 // roles the sheet's contents read (the prose of RichText reads these four)38 // are redefined here for the sheet's subtree, so what sits inside it is39 // readable on white without reaching into its classes.40 color-scheme: light;41 --md-sys-color-on-surface: CanvasText;42 // Muted text such as a blockquote: CanvasText blended toward Canvas is about 10:1 on white, well over AA (GrayText was 3.9:1). Still system colours, the same documented exception as above.43 --md-sys-color-on-surface-variant: color-mix(in srgb, CanvasText 75%, Canvas);44 --md-sys-color-secondary: LinkText;45 --md-sys-color-tertiary: GrayText;4647 color: CanvasText;48 background: Canvas;49 border-radius: var(--md-sys-shape-corner-small);50 box-shadow: var(--md-sys-elevation-2);5152 @media print {53 max-width: none;54 padding: 0;55 margin: 0;56 border-radius: 0;57 box-shadow: none;58 }59}6061// A block, not a flex column: a forced page break and a named page need block62// flow, and a flex item does not fragment across pages reliably.63.BHT__PrintDocument {64 display: block;65 page: print-document;6667 &__cover {68 @include sheet;6970 page: print-document-cover;71 break-after: page;72 }7374 &__toc {75 @include sheet;7677 break-before: page;78 }7980 &__toc-heading,81 &__section-title {82 margin: 0 0 var(--spacing-6);83 font-family: var(--md-sys-typescale-headline-large-font);84 font-size: var(--md-sys-typescale-headline-large-size);85 font-weight: var(--md-sys-typescale-headline-large-weight);86 line-height: var(--md-sys-typescale-headline-large-line-height);87 color: inherit;88 break-after: avoid;89 }9091 &__toc-list {92 display: flex;93 flex-direction: column;94 gap: var(--spacing-3);95 padding: 0;96 margin: 0;97 list-style-position: inside;98 list-style-type: decimal;99 }100101 &__toc-item {102 font-size: var(--md-sys-typescale-title-medium-size);103 line-height: var(--md-sys-typescale-title-medium-line-height);104 }105106 &__toc-link {107 color: inherit;108 text-decoration: none;109110 &:hover {111 text-decoration: underline;112 }113 }114115 &__section {116 @include sheet;117118 break-before: page;119 }120}
index.ts
1export { PrintDocument } from "./print-document.js";2export type {3 PrintDocumentProps,4 PrintDocumentSection,5} from "./print-document.js";
print-document.example.tsx
1import { PrintDocument } from "./print-document.js";23/** A cover, a contents page and two sections; each section starts a new page when printed. */4export const BasicPrintDocument = () => (5 <PrintDocument6 cover={<h1>House guide</h1>}7 toc="Contents"8 runningTitle="The BEhive · House guide"9 sections={[10 {11 id: "wifi",12 title: "Wi-Fi",13 content: <p>Ask the owner for the network details.</p>,14 },15 {16 id: "trash",17 title: "Trash",18 content: <p>The bins go out on Tuesday evening.</p>,19 },20 ]}21 />22);2324/** Without a cover or a contents heading it is just the sections, one per page. */25export const SectionsOnlyPrintDocument = () => (26 <PrintDocument27 sections={[28 { id: "arrival", title: "Arrival", content: <p>Check-in is at 4pm.</p> },29 ]}30 />31);
print-document.spec.tsx
1import { render, screen, within } from "@testing-library/react";2import { PrintDocument } from "./print-document.js";34const SECTIONS = [5 { id: "wifi", title: "Wi-Fi", content: <p>Network details</p> },6 { id: "trash", title: "Trash", content: <p>Tuesday</p> },7 { id: "hot-tub", title: "Hot tub", content: <p>Shower first</p> },8];910const readTitle = () =>11 document.documentElement.style.getPropertyValue(12 "--print-document-running-title",13 );1415describe("PrintDocument", () => {16 it("renders every section with its title as an h2 and its content", () => {17 render(<PrintDocument sections={SECTIONS} />);1819 expect(20 screen.getAllByRole("heading", { level: 2 }).map((h) => h.textContent),21 ).toEqual(["Wi-Fi", "Trash", "Hot tub"]);22 expect(screen.getByText("Shower first")).toBeTruthy();23 expect(document.getElementById("trash")?.tagName).toBe("SECTION");24 });2526 it("lists every section in order in the contents, linked to its section", () => {27 render(<PrintDocument toc="Contents" sections={SECTIONS} />);2829 const contents = screen.getByRole("navigation", { name: "Contents" });30 const links = within(contents).getAllByRole("link");3132 expect(links.map((link) => link.textContent)).toEqual([33 "Wi-Fi",34 "Trash",35 "Hot tub",36 ]);37 expect(links.map((link) => link.getAttribute("href"))).toEqual([38 "#wifi",39 "#trash",40 "#hot-tub",41 ]);42 });4344 it("has no contents page without a heading for it or without sections", () => {45 const { rerender } = render(<PrintDocument sections={SECTIONS} />);4647 expect(screen.queryByRole("navigation")).toBeNull();4849 rerender(<PrintDocument toc="Contents" sections={[]} />);5051 expect(screen.queryByRole("navigation")).toBeNull();52 });5354 it("renders the cover before the contents and the sections", () => {55 render(56 <PrintDocument57 cover={<h1>House guide</h1>}58 toc="Contents"59 sections={SECTIONS}60 />,61 );6263 const cover = screen.getByRole("heading", { level: 1 });64 const contents = screen.getByRole("navigation");6566 expect(67 cover.compareDocumentPosition(contents) &68 Node.DOCUMENT_POSITION_FOLLOWING,69 ).toBeTruthy();70 });7172 it("hands the running title to the page margin box and removes it on unmount", () => {73 const { unmount } = render(74 <PrintDocument runningTitle={'The "BEhive"'} sections={SECTIONS} />,75 );76 expect(readTitle()).toBe('"The \\"BEhive\\""');7778 unmount();7980 expect(readTitle()).toBe("");81 });8283 it("keeps the remaining title when documents unmount out of order", () => {84 const first = render(85 <PrintDocument runningTitle="First" sections={SECTIONS} />,86 );87 const second = render(88 <PrintDocument runningTitle="Second" sections={SECTIONS} />,89 );9091 expect(readTitle()).toBe('"Second"');9293 first.unmount();9495 expect(readTitle()).toBe('"Second"');9697 second.unmount();9899 expect(readTitle()).toBe("");100 });101102 it("falls back to the earlier title when the later document unmounts first", () => {103 const first = render(104 <PrintDocument runningTitle="First" sections={SECTIONS} />,105 );106 const second = render(107 <PrintDocument runningTitle="Second" sections={SECTIONS} />,108 );109110 second.unmount();111112 expect(readTitle()).toBe('"First"');113114 first.unmount();115116 expect(readTitle()).toBe("");117 });118119 it("holds the last remaining title with three documents unmounted in the middle first", () => {120 const docs = ["A", "B", "C"].map((title) =>121 render(<PrintDocument runningTitle={title} sections={SECTIONS} />),122 );123124 docs[1].unmount();125126 expect(readTitle()).toBe('"C"');127128 docs[2].unmount();129130 expect(readTitle()).toBe('"A"');131132 docs[0].unmount();133134 expect(readTitle()).toBe("");135 });136137 it("does not take the title when a document has no running title", () => {138 const first = render(139 <PrintDocument runningTitle="First" sections={SECTIONS} />,140 );141 const untitled = render(<PrintDocument sections={SECTIONS} />);142143 untitled.unmount();144145 expect(readTitle()).toBe('"First"');146147 first.unmount();148 });149150 it("keeps a document's place in the order when it is retitled", () => {151 const first = render(152 <PrintDocument runningTitle="A" sections={SECTIONS} />,153 );154 const second = render(155 <PrintDocument runningTitle="B" sections={SECTIONS} />,156 );157158 first.rerender(<PrintDocument runningTitle="A2" sections={SECTIONS} />);159160 expect(readTitle()).toBe('"B"');161162 second.unmount();163164 expect(readTitle()).toBe('"A2"');165166 first.unmount();167 });168169 it("gives a title to a document that had none without moving it ahead of a later one", () => {170 const first = render(<PrintDocument sections={SECTIONS} />);171 const second = render(172 <PrintDocument runningTitle="B" sections={SECTIONS} />,173 );174175 first.rerender(<PrintDocument runningTitle="A" sections={SECTIONS} />);176177 expect(readTitle()).toBe('"B"');178179 first.unmount();180 second.unmount();181 });182183 it("ignores a whitespace-only running title", () => {184 const first = render(185 <PrintDocument runningTitle="First" sections={SECTIONS} />,186 );187 const blank = render(188 <PrintDocument runningTitle=" " sections={SECTIONS} />,189 );190191 expect(readTitle()).toBe('"First"');192193 blank.unmount();194 first.unmount();195 });196197 it("gives section headings ids that never collide with a section id", () => {198 render(199 <PrintDocument200 sections={[201 { id: "wifi", title: "A", content: null },202 { id: "wifi-title", title: "B", content: null },203 ]}204 />,205 );206207 const ids = [...document.querySelectorAll("[id]")].map((el) => el.id);208209 expect(new Set(ids).size).toBe(ids.length);210 });211212 it("applies additional className", () => {213 const { container } = render(214 <PrintDocument className="custom" sections={SECTIONS} />,215 );216217 expect((container.firstChild as HTMLElement).className).toContain("custom");218 });219});