@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.
- package/README.md +2 -0
- package/dist/htmlEditor/HtmlEditor.d.ts +25 -0
- package/dist/htmlEditor/HtmlEditor.d.ts.map +1 -0
- package/dist/htmlEditor/HtmlEditor.js +231 -0
- package/dist/htmlEditor/HtmlEditor.js.map +1 -0
- package/dist/htmlEditor/HtmlView.d.ts +10 -0
- package/dist/htmlEditor/HtmlView.d.ts.map +1 -0
- package/dist/htmlEditor/HtmlView.js +28 -0
- package/dist/htmlEditor/HtmlView.js.map +1 -0
- package/dist/htmlEditor/htmlContentStyles.d.ts +3 -0
- package/dist/htmlEditor/htmlContentStyles.d.ts.map +1 -0
- package/dist/htmlEditor/htmlContentStyles.js +3 -0
- package/dist/htmlEditor/htmlContentStyles.js.map +1 -0
- package/dist/htmlEditor/index.d.ts +8 -0
- package/dist/htmlEditor/index.d.ts.map +1 -0
- package/dist/htmlEditor/index.js +4 -0
- package/dist/htmlEditor/index.js.map +1 -0
- package/dist/htmlEditor/safeHtml.d.ts +12 -0
- package/dist/htmlEditor/safeHtml.d.ts.map +1 -0
- package/dist/htmlEditor/safeHtml.js +137 -0
- package/dist/htmlEditor/safeHtml.js.map +1 -0
- package/dist/htmlEditor/view.d.ts +6 -0
- package/dist/htmlEditor/view.d.ts.map +1 -0
- package/dist/htmlEditor/view.js +3 -0
- package/dist/htmlEditor/view.js.map +1 -0
- package/package.json +52 -2
- package/src/htmlEditor/HtmlEditor.mdx +61 -0
- package/src/htmlEditor/HtmlEditor.stories.tsx +192 -0
- package/src/htmlEditor/HtmlEditor.test.tsx +296 -0
- package/src/htmlEditor/HtmlEditor.tsx +420 -0
- package/src/htmlEditor/HtmlView.mdx +37 -0
- package/src/htmlEditor/HtmlView.stories.tsx +119 -0
- package/src/htmlEditor/HtmlView.test.tsx +88 -0
- package/src/htmlEditor/HtmlView.tsx +71 -0
- package/src/htmlEditor/htmlContentStyles.ts +3 -0
- package/src/htmlEditor/htmlDocument.storyHelpers.ts +7 -0
- package/src/htmlEditor/index.ts +7 -0
- package/src/htmlEditor/safeHtml.test.ts +123 -0
- package/src/htmlEditor/safeHtml.ts +187 -0
- package/src/htmlEditor/view.ts +5 -0
- 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 < 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
|
+
})
|