Behive Tech Component Registry

Print Document

v0.1.0Draft

A 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.

pnpm add @behivetech/templates.print-document

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";
10
11/** 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";
13
14/** 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 }[] = [];
16
17/** 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}
26
27export 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 left
42 */
43 runningTitle?: string;
44}
45
46/**
47 * Turns text into a CSS string token; quotes and backslashes are escaped and a
48 * line break is flattened so one title cannot end the declaration.
49 */
50const toCssString = (text: string): string =>
51 JSON.stringify(text.replace(/\s+/g, " "));
52
53/** 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);
57
58 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};
67
68/**
69 * A printable book as a template: an optional cover page, an optional contents
70 * page that links to each section, then one section per entry, each starting on
71 * a new US Letter page. The print rules use their own named page, so they
72 * never change how any other page of the site prints. On screen each page shows
73 * 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 property
76 * 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 ...rest
85}: PrintDocumentProps) => {
86 const [rootClass, getChildClass] = getClassName({
87 className,
88 rootClass: "BHT__PrintDocument",
89 styles,
90 });
91 const headingIdBase = useId();
92 const tocHeadingId = `${headingIdBase}-toc`;
93
94 const entryRef = useRef({ title: "" });
95
96 // The entry joins the list once per mount and stays in its place, so
97 // retitling a document never moves it ahead of one mounted after it.
98 useEffect(() => {
99 const entry = entryRef.current;
100
101 mountedTitles.push(entry);
102
103 // Remove only this document's entry, so whichever document unmounts first
104 // never brings back a title that no mounted document owns.
105 return () => {
106 const index = mountedTitles.indexOf(entry);
107
108 if (index >= 0) {
109 mountedTitles.splice(index, 1);
110 applyRunningTitle();
111 }
112 };
113 }, []);
114
115 useEffect(() => {
116 entryRef.current.title = runningTitle?.trim() ?? "";
117 applyRunningTitle();
118 }, [runningTitle]);
119
120 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 <section
141 key={id}
142 id={id}
143 className={getChildClass("section")}
144 aria-labelledby={`${headingIdBase}-${index}`}
145 >
146 <h2
147 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 other
2// page prints. The top margin (0.917in, 88px at 96dpi) leaves room for the
3// running title in the margin box and keeps the section title where the guide
4// page's own print layout puts it. The cover has its own named page: it has no
5// running title and a plain half-inch margin, and a change of named page
6// forces the page break.
7@page print-document {
8 size: letter;
9 margin: 0.917in 0.5in 0.5in;
10
11 @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}
19
20@page print-document-cover {
21 size: letter;
22 margin: 0.5in;
23}
24
25// One printed or on-screen page. On screen it is a sheet; on paper the page
26// 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);
34
35 // 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`. The
37 // 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 is
39 // 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;
46
47 color: CanvasText;
48 background: Canvas;
49 border-radius: var(--md-sys-shape-corner-small);
50 box-shadow: var(--md-sys-elevation-2);
51
52 @media print {
53 max-width: none;
54 padding: 0;
55 margin: 0;
56 border-radius: 0;
57 box-shadow: none;
58 }
59}
60
61// A block, not a flex column: a forced page break and a named page need block
62// flow, and a flex item does not fragment across pages reliably.
63.BHT__PrintDocument {
64 display: block;
65 page: print-document;
66
67 &__cover {
68 @include sheet;
69
70 page: print-document-cover;
71 break-after: page;
72 }
73
74 &__toc {
75 @include sheet;
76
77 break-before: page;
78 }
79
80 &__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 }
90
91 &__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 }
100
101 &__toc-item {
102 font-size: var(--md-sys-typescale-title-medium-size);
103 line-height: var(--md-sys-typescale-title-medium-line-height);
104 }
105
106 &__toc-link {
107 color: inherit;
108 text-decoration: none;
109
110 &:hover {
111 text-decoration: underline;
112 }
113 }
114
115 &__section {
116 @include sheet;
117
118 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";
2
3/** A cover, a contents page and two sections; each section starts a new page when printed. */
4export const BasicPrintDocument = () => (
5 <PrintDocument
6 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);
23
24/** Without a cover or a contents heading it is just the sections, one per page. */
25export const SectionsOnlyPrintDocument = () => (
26 <PrintDocument
27 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";
3
4const 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];
9
10const readTitle = () =>
11 document.documentElement.style.getPropertyValue(
12 "--print-document-running-title",
13 );
14
15describe("PrintDocument", () => {
16 it("renders every section with its title as an h2 and its content", () => {
17 render(<PrintDocument sections={SECTIONS} />);
18
19 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 });
25
26 it("lists every section in order in the contents, linked to its section", () => {
27 render(<PrintDocument toc="Contents" sections={SECTIONS} />);
28
29 const contents = screen.getByRole("navigation", { name: "Contents" });
30 const links = within(contents).getAllByRole("link");
31
32 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 });
43
44 it("has no contents page without a heading for it or without sections", () => {
45 const { rerender } = render(<PrintDocument sections={SECTIONS} />);
46
47 expect(screen.queryByRole("navigation")).toBeNull();
48
49 rerender(<PrintDocument toc="Contents" sections={[]} />);
50
51 expect(screen.queryByRole("navigation")).toBeNull();
52 });
53
54 it("renders the cover before the contents and the sections", () => {
55 render(
56 <PrintDocument
57 cover={<h1>House guide</h1>}
58 toc="Contents"
59 sections={SECTIONS}
60 />,
61 );
62
63 const cover = screen.getByRole("heading", { level: 1 });
64 const contents = screen.getByRole("navigation");
65
66 expect(
67 cover.compareDocumentPosition(contents) &
68 Node.DOCUMENT_POSITION_FOLLOWING,
69 ).toBeTruthy();
70 });
71
72 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\\""');
77
78 unmount();
79
80 expect(readTitle()).toBe("");
81 });
82
83 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 );
90
91 expect(readTitle()).toBe('"Second"');
92
93 first.unmount();
94
95 expect(readTitle()).toBe('"Second"');
96
97 second.unmount();
98
99 expect(readTitle()).toBe("");
100 });
101
102 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 );
109
110 second.unmount();
111
112 expect(readTitle()).toBe('"First"');
113
114 first.unmount();
115
116 expect(readTitle()).toBe("");
117 });
118
119 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 );
123
124 docs[1].unmount();
125
126 expect(readTitle()).toBe('"C"');
127
128 docs[2].unmount();
129
130 expect(readTitle()).toBe('"A"');
131
132 docs[0].unmount();
133
134 expect(readTitle()).toBe("");
135 });
136
137 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} />);
142
143 untitled.unmount();
144
145 expect(readTitle()).toBe('"First"');
146
147 first.unmount();
148 });
149
150 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 );
157
158 first.rerender(<PrintDocument runningTitle="A2" sections={SECTIONS} />);
159
160 expect(readTitle()).toBe('"B"');
161
162 second.unmount();
163
164 expect(readTitle()).toBe('"A2"');
165
166 first.unmount();
167 });
168
169 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 );
174
175 first.rerender(<PrintDocument runningTitle="A" sections={SECTIONS} />);
176
177 expect(readTitle()).toBe('"B"');
178
179 first.unmount();
180 second.unmount();
181 });
182
183 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 );
190
191 expect(readTitle()).toBe('"First"');
192
193 blank.unmount();
194 first.unmount();
195 });
196
197 it("gives section headings ids that never collide with a section id", () => {
198 render(
199 <PrintDocument
200 sections={[
201 { id: "wifi", title: "A", content: null },
202 { id: "wifi-title", title: "B", content: null },
203 ]}
204 />,
205 );
206
207 const ids = [...document.querySelectorAll("[id]")].map((el) => el.id);
208
209 expect(new Set(ids).size).toBe(ids.length);
210 });
211
212 it("applies additional className", () => {
213 const { container } = render(
214 <PrintDocument className="custom" sections={SECTIONS} />,
215 );
216
217 expect((container.firstChild as HTMLElement).className).toContain("custom");
218 });
219});