@charcuterie/ui 4.19.0 → 4.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +2 -0
  2. package/dist/htmlEditor/HtmlEditor.d.ts +25 -0
  3. package/dist/htmlEditor/HtmlEditor.d.ts.map +1 -0
  4. package/dist/htmlEditor/HtmlEditor.js +231 -0
  5. package/dist/htmlEditor/HtmlEditor.js.map +1 -0
  6. package/dist/htmlEditor/HtmlView.d.ts +10 -0
  7. package/dist/htmlEditor/HtmlView.d.ts.map +1 -0
  8. package/dist/htmlEditor/HtmlView.js +28 -0
  9. package/dist/htmlEditor/HtmlView.js.map +1 -0
  10. package/dist/htmlEditor/htmlContentStyles.d.ts +3 -0
  11. package/dist/htmlEditor/htmlContentStyles.d.ts.map +1 -0
  12. package/dist/htmlEditor/htmlContentStyles.js +3 -0
  13. package/dist/htmlEditor/htmlContentStyles.js.map +1 -0
  14. package/dist/htmlEditor/index.d.ts +8 -0
  15. package/dist/htmlEditor/index.d.ts.map +1 -0
  16. package/dist/htmlEditor/index.js +4 -0
  17. package/dist/htmlEditor/index.js.map +1 -0
  18. package/dist/htmlEditor/safeHtml.d.ts +12 -0
  19. package/dist/htmlEditor/safeHtml.d.ts.map +1 -0
  20. package/dist/htmlEditor/safeHtml.js +137 -0
  21. package/dist/htmlEditor/safeHtml.js.map +1 -0
  22. package/dist/htmlEditor/view.d.ts +6 -0
  23. package/dist/htmlEditor/view.d.ts.map +1 -0
  24. package/dist/htmlEditor/view.js +3 -0
  25. package/dist/htmlEditor/view.js.map +1 -0
  26. package/package.json +52 -2
  27. package/src/htmlEditor/HtmlEditor.mdx +61 -0
  28. package/src/htmlEditor/HtmlEditor.stories.tsx +192 -0
  29. package/src/htmlEditor/HtmlEditor.test.tsx +296 -0
  30. package/src/htmlEditor/HtmlEditor.tsx +420 -0
  31. package/src/htmlEditor/HtmlView.mdx +37 -0
  32. package/src/htmlEditor/HtmlView.stories.tsx +119 -0
  33. package/src/htmlEditor/HtmlView.test.tsx +88 -0
  34. package/src/htmlEditor/HtmlView.tsx +71 -0
  35. package/src/htmlEditor/htmlContentStyles.ts +3 -0
  36. package/src/htmlEditor/htmlDocument.storyHelpers.ts +7 -0
  37. package/src/htmlEditor/index.ts +7 -0
  38. package/src/htmlEditor/safeHtml.test.ts +123 -0
  39. package/src/htmlEditor/safeHtml.ts +187 -0
  40. package/src/htmlEditor/view.ts +5 -0
  41. package/src/sourceRules.test.ts +22 -0
@@ -0,0 +1,420 @@
1
+ import { useUniqueId } from "@charcuterie/logic"
2
+ import Image from "@tiptap/extension-image"
3
+ import TextAlign from "@tiptap/extension-text-align"
4
+ import {
5
+ Color,
6
+ TextStyle,
7
+ } from "@tiptap/extension-text-style"
8
+ import { EditorContent, useEditor } from "@tiptap/react"
9
+ import StarterKit from "@tiptap/starter-kit"
10
+ import type { ReactNode } from "react"
11
+ import { useEffect, useRef, useState } from "react"
12
+
13
+ import { Button } from "../Button/Button.tsx"
14
+ import { Dialog } from "../Dialog/Dialog.tsx"
15
+ import { Field } from "../Field/Field.tsx"
16
+ import { FOCUS_RING_CLASS } from "../intentStyles.ts"
17
+ import {
18
+ toSafeImageUrl,
19
+ toSafeLinkUrl,
20
+ } from "../safeUrls.ts"
21
+ import type { SlotProps } from "../slotProps.ts"
22
+ import type { ToolbarItem } from "../Toolbar/Toolbar.tsx"
23
+ import { Toolbar } from "../Toolbar/Toolbar.tsx"
24
+ import { toClassName } from "../toClassName.ts"
25
+ import { HTML_CONTENT_CLASS } from "./htmlContentStyles.ts"
26
+ import { toSafeHtml, toSafeHtmlColour } from "./safeHtml.ts"
27
+
28
+ export type HtmlEditorProps = SlotProps & {
29
+ className?: string
30
+ /** Initial only. Remount with a key to switch documents. */
31
+ defaultValue?: string
32
+ icons?: Readonly<Record<string, ReactNode>>
33
+ isDisabled?: boolean
34
+ isReadOnly?: boolean
35
+ /** Omit inside a Field, which supplies the real label. */
36
+ label?: string
37
+ /** Hidden form control; its original bytes survive until an edit. */
38
+ name?: string
39
+ onChange?: (value: string) => void
40
+ toolbarLabel?: string
41
+ }
42
+
43
+ const EDITOR_CLASS = toClassName(
44
+ HTML_CONTENT_CLASS,
45
+ "min-h-40 rounded-md border border-border-default bg-surface-sunken px-3 py-2",
46
+ FOCUS_RING_CLASS,
47
+ )
48
+
49
+ const INPUT_CLASS = toClassName(
50
+ "w-full min-w-0 rounded-md border border-border-default bg-surface-sunken p-2 text-content-primary",
51
+ FOCUS_RING_CLASS,
52
+ )
53
+
54
+ /**
55
+ * A rich editor for records already stored as HTML. Sanitization is
56
+ * a projection for display and editing, not a migration: mounting,
57
+ * selecting, focusing and submitting an untouched form never emit
58
+ * normalized HTML. ProseMirror owns document edits and their undo
59
+ * history; external defaults never replace that document.
60
+ */
61
+ export const HtmlEditor = ({
62
+ className,
63
+ defaultValue = "",
64
+ icons,
65
+ isDisabled = false,
66
+ isReadOnly = false,
67
+ label,
68
+ name,
69
+ onChange,
70
+ toolbarLabel = "HTML formatting",
71
+ ...slotProps
72
+ }: HtmlEditorProps): ReactNode => {
73
+ const generatedId = useUniqueId("html-editor")
74
+ const dialogInputRef = useRef<HTMLInputElement>(null)
75
+ const id = slotProps.id ?? generatedId
76
+ const [initialValue] = useState(defaultValue)
77
+ const [formValue, setFormValue] = useState(initialValue)
78
+ const [dialog, setDialog] = useState<
79
+ "link" | "image" | "colour" | null
80
+ >(null)
81
+ const [error, setError] = useState<string | undefined>()
82
+ const editor = useEditor(
83
+ {
84
+ content: toSafeHtml(initialValue),
85
+ editable: !isDisabled && !isReadOnly,
86
+ immediatelyRender: false,
87
+ shouldRerenderOnTransaction: true,
88
+ extensions: [
89
+ StarterKit.configure({
90
+ link: {
91
+ openOnClick: false,
92
+ autolink: false,
93
+ isAllowedUri: (url) =>
94
+ toSafeLinkUrl(url) !== undefined,
95
+ },
96
+ }),
97
+ Image.configure({ allowBase64: true }),
98
+ TextStyle,
99
+ Color,
100
+ TextAlign.configure({
101
+ types: ["heading", "paragraph"],
102
+ alignments: [
103
+ "start",
104
+ "end",
105
+ "left",
106
+ "right",
107
+ "center",
108
+ "justify",
109
+ ],
110
+ }),
111
+ ],
112
+ editorProps: {
113
+ attributes: {
114
+ id,
115
+ role: "textbox",
116
+ "aria-multiline": "true",
117
+ "aria-label": label ?? "",
118
+ "aria-labelledby":
119
+ label === undefined && slotProps.id
120
+ ? `${slotProps.id}-label`
121
+ : "",
122
+ "aria-describedby":
123
+ slotProps["aria-describedby"] ?? "",
124
+ "aria-invalid": String(
125
+ slotProps["aria-invalid"] ?? false,
126
+ ),
127
+ "aria-required": String(
128
+ slotProps["aria-required"] ??
129
+ slotProps.required ??
130
+ false,
131
+ ),
132
+ "aria-disabled": String(isDisabled),
133
+ "aria-readonly": String(isReadOnly),
134
+ tabindex: String(isDisabled ? -1 : 0),
135
+ class: EDITOR_CLASS,
136
+ },
137
+ transformPastedHTML: toSafeHtml,
138
+ },
139
+ onUpdate: ({
140
+ editor: changedEditor,
141
+ transaction,
142
+ }) => {
143
+ if (!transaction.docChanged) return
144
+ const next = toSafeHtml(changedEditor.getHTML())
145
+ setFormValue(next)
146
+ onChange?.(next)
147
+ },
148
+ },
149
+ [],
150
+ )
151
+
152
+ useEffect(() => {
153
+ editor?.setEditable(!isDisabled && !isReadOnly, false)
154
+ }, [editor, isDisabled, isReadOnly])
155
+
156
+ const hasDisabledActions =
157
+ isDisabled || isReadOnly || !editor
158
+ const openDialog = (
159
+ next: "link" | "image" | "colour",
160
+ ) => {
161
+ setError(undefined)
162
+ setDialog(next)
163
+ }
164
+ const toggle = (
165
+ key: string,
166
+ title: string,
167
+ onSelect: () => void,
168
+ isPressed: boolean,
169
+ ): ToolbarItem => ({
170
+ key,
171
+ type: "control",
172
+ element: (
173
+ <Button
174
+ appearance="ghost"
175
+ aria-pressed={isPressed}
176
+ iconStart={icons?.[key]}
177
+ isDisabled={hasDisabledActions}
178
+ onClick={onSelect}
179
+ >
180
+ {title}
181
+ </Button>
182
+ ),
183
+ })
184
+ const action = (
185
+ key: string,
186
+ title: string,
187
+ onSelect: () => void,
188
+ ): ToolbarItem => ({
189
+ key,
190
+ label: title,
191
+ icon: icons?.[key],
192
+ isDisabled: hasDisabledActions,
193
+ onSelect,
194
+ })
195
+ const closeDialog = () => setDialog(null)
196
+
197
+ return (
198
+ <div
199
+ className={toClassName(
200
+ "min-w-0 space-y-2",
201
+ className,
202
+ )}
203
+ >
204
+ <Toolbar
205
+ label={toolbarLabel}
206
+ overflow="panel"
207
+ overflowIcon={icons?.overflow}
208
+ items={[
209
+ toggle(
210
+ "bold",
211
+ "Bold",
212
+ () => {
213
+ editor?.chain().focus().toggleBold().run()
214
+ },
215
+ editor?.isActive("bold") ?? false,
216
+ ),
217
+ toggle(
218
+ "italic",
219
+ "Italic",
220
+ () => {
221
+ editor?.chain().focus().toggleItalic().run()
222
+ },
223
+ editor?.isActive("italic") ?? false,
224
+ ),
225
+ toggle(
226
+ "underline",
227
+ "Underline",
228
+ () => {
229
+ editor
230
+ ?.chain()
231
+ .focus()
232
+ .toggleUnderline()
233
+ .run()
234
+ },
235
+ editor?.isActive("underline") ?? false,
236
+ ),
237
+ toggle(
238
+ "bulletList",
239
+ "Bullet list",
240
+ () => {
241
+ editor
242
+ ?.chain()
243
+ .focus()
244
+ .toggleBulletList()
245
+ .run()
246
+ },
247
+ editor?.isActive("bulletList") ?? false,
248
+ ),
249
+ toggle(
250
+ "orderedList",
251
+ "Numbered list",
252
+ () => {
253
+ editor
254
+ ?.chain()
255
+ .focus()
256
+ .toggleOrderedList()
257
+ .run()
258
+ },
259
+ editor?.isActive("orderedList") ?? false,
260
+ ),
261
+ action("alignStart", "Align start", () => {
262
+ editor
263
+ ?.chain()
264
+ .focus()
265
+ .setTextAlign("start")
266
+ .run()
267
+ }),
268
+ action("alignCenter", "Align centre", () => {
269
+ editor
270
+ ?.chain()
271
+ .focus()
272
+ .setTextAlign("center")
273
+ .run()
274
+ }),
275
+ action("alignEnd", "Align end", () => {
276
+ editor
277
+ ?.chain()
278
+ .focus()
279
+ .setTextAlign("end")
280
+ .run()
281
+ }),
282
+ action("link", "Edit link", () =>
283
+ openDialog("link"),
284
+ ),
285
+ action("unlink", "Remove link", () => {
286
+ editor
287
+ ?.chain()
288
+ .focus()
289
+ .extendMarkRange("link")
290
+ .unsetLink()
291
+ .run()
292
+ }),
293
+ action("colour", "Text colour", () =>
294
+ openDialog("colour"),
295
+ ),
296
+ action("image", "Insert image", () =>
297
+ openDialog("image"),
298
+ ),
299
+ action("undo", "Undo", () => {
300
+ editor?.chain().focus().undo().run()
301
+ }),
302
+ action("redo", "Redo", () => {
303
+ editor?.chain().focus().redo().run()
304
+ }),
305
+ ]}
306
+ />
307
+ <EditorContent editor={editor} />
308
+ {name !== undefined && (
309
+ <input
310
+ disabled={isDisabled}
311
+ name={name}
312
+ type="hidden"
313
+ value={formValue}
314
+ />
315
+ )}
316
+ <Dialog
317
+ initialFocus={dialogInputRef}
318
+ heading={
319
+ dialog === "image"
320
+ ? "Insert image"
321
+ : dialog === "colour"
322
+ ? "Text colour"
323
+ : "Edit link"
324
+ }
325
+ isVisible={dialog !== null}
326
+ onClose={closeDialog}
327
+ >
328
+ <form
329
+ key={dialog}
330
+ className="space-y-4"
331
+ onSubmit={(event) => {
332
+ event.preventDefault()
333
+ if (!editor || hasDisabledActions) return
334
+ const data = new FormData(event.currentTarget)
335
+ const value = String(
336
+ data.get("value") ?? "",
337
+ ).trim()
338
+ if (dialog === "colour") {
339
+ const colour = toSafeHtmlColour(value)
340
+ if (!colour) {
341
+ setError(
342
+ "Enter a colour name, hex, RGB or HSL value.",
343
+ )
344
+ return
345
+ }
346
+ editor.chain().focus().setColor(colour).run()
347
+ } else if (dialog === "image") {
348
+ const src = toSafeImageUrl(value)
349
+ if (!src) {
350
+ setError("Enter a safe image URL.")
351
+ return
352
+ }
353
+ editor
354
+ .chain()
355
+ .focus()
356
+ .setImage({
357
+ src,
358
+ alt: String(data.get("alt") ?? ""),
359
+ })
360
+ .run()
361
+ } else {
362
+ const href = toSafeLinkUrl(value)
363
+ if (!href) {
364
+ setError("Enter a safe link URL.")
365
+ return
366
+ }
367
+ editor
368
+ .chain()
369
+ .focus()
370
+ .extendMarkRange("link")
371
+ .setLink({ href })
372
+ .run()
373
+ }
374
+ closeDialog()
375
+ }}
376
+ >
377
+ <Field
378
+ error={error}
379
+ label={dialog === "colour" ? "Colour" : "URL"}
380
+ isRequired
381
+ >
382
+ <input
383
+ className={INPUT_CLASS}
384
+ defaultValue={
385
+ dialog === "link"
386
+ ? String(
387
+ editor?.getAttributes("link").href ??
388
+ "",
389
+ )
390
+ : ""
391
+ }
392
+ name="value"
393
+ ref={dialogInputRef}
394
+ required
395
+ />
396
+ </Field>
397
+ {dialog === "image" && (
398
+ <Field label="Image description">
399
+ <input className={INPUT_CLASS} name="alt" />
400
+ </Field>
401
+ )}
402
+ <div className="flex flex-wrap gap-2">
403
+ <Button
404
+ isDisabled={hasDisabledActions}
405
+ type="submit"
406
+ >
407
+ Apply
408
+ </Button>
409
+ <Button
410
+ appearance="outline"
411
+ onClick={closeDialog}
412
+ >
413
+ Cancel
414
+ </Button>
415
+ </div>
416
+ </form>
417
+ </Dialog>
418
+ </div>
419
+ )
420
+ }
@@ -0,0 +1,37 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+ import * as HtmlViewStories from "./HtmlView.stories";
3
+
4
+ <Meta of={HtmlViewStories} />
5
+
6
+ # HtmlView
7
+
8
+ Read saved HTML notes with the same safe document policy and document styling as `HtmlEditor`.
9
+ It renders React nodes, with no `innerHTML` and no editor instance. `value` is controlled because
10
+ a reader has no caret or undo history to preserve. `label` names the document. Its `@charcuterie/ui/html-view` entry requires only the optional
11
+ `parse5` peer and loads no editor core.
12
+
13
+ <Canvas of={HtmlViewStories.Playground} />
14
+
15
+ ```tsx
16
+ import { HtmlView } from "@charcuterie/ui/html-view"
17
+
18
+ <HtmlView label="Project notes" value={record.notes} />
19
+ ```
20
+
21
+ The parser decodes HTML entities before checking URL schemes. Only known document tags,
22
+ safe links/images, accessible image text, list start numbers and safe colour/alignment styles
23
+ reach the page. Scripts, embeds, event handlers, arbitrary classes/IDs, foreign namespaces and
24
+ resource-loading CSS cannot reach the DOM. Unknown wrappers retain ordinary readable children;
25
+ executable/foreign content is discarded. Safe links use the shared `TextLink` shape.
26
+
27
+ Reading a note never writes its filtered projection back to storage. Plain HTML source remains
28
+ available to its owner independently of this component; no conversion to markdown occurs.
29
+
30
+ <Canvas of={HtmlViewStories.AllVariants} />
31
+ <Canvas of={HtmlViewStories.AllStates} />
32
+ <Canvas of={HtmlViewStories.Responsive} />
33
+ <Canvas of={HtmlViewStories.Interactive} />
34
+
35
+ ## Props
36
+
37
+ <Controls />
@@ -0,0 +1,119 @@
1
+ import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import { useState } from "react"
4
+ import { Button } from "../Button/Button.tsx"
5
+ import {
6
+ ContainerBoard,
7
+ StoryCell,
8
+ StoryGrid,
9
+ } from "../board.storyHelpers.tsx"
10
+ import { HtmlView } from "./HtmlView.tsx"
11
+ import {
12
+ DOCUMENT_IMAGE,
13
+ HOSTILE_HTML,
14
+ HTML_DOCUMENT,
15
+ } from "./htmlDocument.storyHelpers.ts"
16
+
17
+ const meta = {
18
+ title: "Components/Data/HtmlView",
19
+ component: HtmlView,
20
+ parameters: { layout: "padded" },
21
+ args: { label: "Saved notes", value: HTML_DOCUMENT },
22
+ } satisfies Meta<typeof HtmlView>
23
+ export default meta
24
+ type Story = StoryObj<typeof meta>
25
+
26
+ export const Playground: Story = {
27
+ parameters: playgroundParameters,
28
+ }
29
+ export const AllVariants: Story = {
30
+ render: () => (
31
+ <StoryGrid columns={2}>
32
+ <StoryCell
33
+ align="stretch"
34
+ label="Rich paragraphs and lists"
35
+ >
36
+ <HtmlView
37
+ label="Rich notes"
38
+ value={HTML_DOCUMENT}
39
+ />
40
+ </StoryCell>
41
+ <StoryCell align="stretch" label="Safe image">
42
+ <HtmlView
43
+ label="Illustrated notes"
44
+ value={`<p>Mounting fixture</p><img src="${DOCUMENT_IMAGE}" alt="Fixture illustration">`}
45
+ />
46
+ </StoryCell>
47
+ <StoryCell align="stretch" label="Relative link">
48
+ <HtmlView
49
+ label="Linked notes"
50
+ value='<p>Read <a href="./instructions">the instructions</a>.</p>'
51
+ />
52
+ </StoryCell>
53
+ <StoryCell
54
+ align="stretch"
55
+ label="Code and line breaks"
56
+ >
57
+ <HtmlView
58
+ label="Technical notes"
59
+ value="<p>Inspect<br>then assemble.</p><pre><code>width = 24 &lt; 32</code></pre>"
60
+ />
61
+ </StoryCell>
62
+ </StoryGrid>
63
+ ),
64
+ }
65
+ export const AllStates: Story = {
66
+ render: () => (
67
+ <StoryGrid columns={2}>
68
+ <StoryCell align="stretch" label="Saved">
69
+ <HtmlView
70
+ label="Saved document"
71
+ value={HTML_DOCUMENT}
72
+ />
73
+ </StoryCell>
74
+ <StoryCell align="stretch" label="Empty">
75
+ <HtmlView label="Empty document" value="" />
76
+ </StoryCell>
77
+ <StoryCell
78
+ align="stretch"
79
+ label="Untrusted HTML is filtered"
80
+ >
81
+ <HtmlView
82
+ label="Filtered document"
83
+ value={HOSTILE_HTML}
84
+ />
85
+ </StoryCell>
86
+ </StoryGrid>
87
+ ),
88
+ }
89
+ export const Responsive: Story = {
90
+ render: () => (
91
+ <ContainerBoard>
92
+ {(width) => (
93
+ <HtmlView
94
+ label={`Notes at ${width}`}
95
+ value={`${HTML_DOCUMENT}<p>https://example.invalid/an-extremely-long-source-path-with-no-spaces-in-the-identifier-for-a-recipe-revision</p>`}
96
+ />
97
+ )}
98
+ </ContainerBoard>
99
+ ),
100
+ }
101
+ export const Interactive: Story = {
102
+ render: () => {
103
+ const [value, setValue] = useState(HTML_DOCUMENT)
104
+ return (
105
+ <div className="space-y-4">
106
+ <Button
107
+ onClick={() =>
108
+ setValue(
109
+ "<p>Updated <strong>saved notes</strong>.</p>",
110
+ )
111
+ }
112
+ >
113
+ Show next revision
114
+ </Button>
115
+ <HtmlView label="Current revision" value={value} />
116
+ </div>
117
+ )
118
+ },
119
+ }
@@ -0,0 +1,88 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { mountStory } from "../mountStory.testHelpers.ts"
6
+ import { expectAgentDrivable } from "../testing/index.ts"
7
+ import * as stories from "./HtmlView.stories.tsx"
8
+
9
+ const {
10
+ Playground,
11
+ AllVariants,
12
+ AllStates,
13
+ Interactive,
14
+ Responsive,
15
+ } = composeStories(stories)
16
+
17
+ test("the named document renders paragraphs, lists and safe links without an editing surface", async () => {
18
+ const { canvas, canvasElement } =
19
+ await mountStory(Playground)
20
+ expectAgentDrivable(canvas, {
21
+ role: "document",
22
+ name: "Saved notes",
23
+ })
24
+ await expect(
25
+ canvas.getByRole("link", { name: "Read the recipe" }),
26
+ ).toHaveAttribute(
27
+ "href",
28
+ "https://example.invalid/recipe",
29
+ )
30
+ await expect(
31
+ canvas.queryByRole("textbox"),
32
+ ).not.toBeInTheDocument()
33
+ await expect(
34
+ canvasElement.querySelectorAll("li"),
35
+ ).toHaveLength(4)
36
+ })
37
+
38
+ test("hostile HTML has visible ordinary content and no executable DOM, source props or unsafe URL", async () => {
39
+ const { canvas } = await mountStory(AllStates)
40
+ const document = canvas.getByRole("document", {
41
+ name: "Filtered document",
42
+ })
43
+ await expect(document).toHaveTextContent("Visible notes")
44
+ await expect(
45
+ document.querySelector(
46
+ "script,style,iframe,svg,math,[onclick],[onerror],[srcset]",
47
+ ),
48
+ ).toBeNull()
49
+ await expect(
50
+ document.querySelector("a[href],img"),
51
+ ).toBeNull()
52
+ await expect(document).not.toHaveTextContent(
53
+ "Foreign content",
54
+ )
55
+ })
56
+
57
+ test("images retain their description and relative links retain ordinary navigation", async () => {
58
+ const { canvas } = await mountStory(AllVariants)
59
+ expectAgentDrivable(canvas, {
60
+ role: "img",
61
+ name: "Fixture illustration",
62
+ })
63
+ await expect(
64
+ canvas.getByRole("link", { name: "the instructions" }),
65
+ ).toHaveAttribute("href", "./instructions")
66
+ })
67
+
68
+ test("a controlled reader reflects the next saved revision", async () => {
69
+ const { canvas } = await mountStory(Interactive)
70
+ await userEvent.click(
71
+ canvas.getByRole("button", {
72
+ name: "Show next revision",
73
+ }),
74
+ )
75
+ await expect(
76
+ canvas.getByRole("document", {
77
+ name: "Current revision",
78
+ }),
79
+ ).toHaveTextContent("Updated saved notes.")
80
+ })
81
+
82
+ test("long document paths stay inside each container", async () => {
83
+ const { canvas } = await mountStory(Responsive)
84
+ for (const document of canvas.getAllByRole("document"))
85
+ await expect(document.scrollWidth).toBeLessThanOrEqual(
86
+ document.clientWidth + 1,
87
+ )
88
+ })