@charcuterie/ui 3.13.0 → 3.14.1
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/dist/markdownEditorCodeMirror/MarkdownView.d.ts +140 -0
- package/dist/markdownEditorCodeMirror/MarkdownView.d.ts.map +1 -0
- package/dist/markdownEditorCodeMirror/MarkdownView.js +268 -0
- package/dist/markdownEditorCodeMirror/MarkdownView.js.map +1 -0
- package/dist/markdownEditorCodeMirror/index.d.ts +14 -3
- package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/index.js +12 -3
- package/dist/markdownEditorCodeMirror/index.js.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreview.d.ts +42 -2
- package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreview.js +298 -28
- package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js +141 -10
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -1
- package/dist/markdownEditorCodeMirror/safeUrls.d.ts +46 -0
- package/dist/markdownEditorCodeMirror/safeUrls.d.ts.map +1 -0
- package/dist/markdownEditorCodeMirror/safeUrls.js +123 -0
- package/dist/markdownEditorCodeMirror/safeUrls.js.map +1 -0
- package/package.json +1 -1
- package/src/MarkdownEditor/MarkdownEditor.mdx +5 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +11 -0
- package/src/markdownEditorCodeMirror/MarkdownView.mdx +173 -0
- package/src/markdownEditorCodeMirror/MarkdownView.stories.tsx +362 -0
- package/src/markdownEditorCodeMirror/MarkdownView.test.tsx +349 -0
- package/src/markdownEditorCodeMirror/MarkdownView.tsx +373 -0
- package/src/markdownEditorCodeMirror/index.ts +17 -2
- package/src/markdownEditorCodeMirror/livePreview.ts +391 -35
- package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +233 -0
- package/src/markdownEditorCodeMirror/livePreviewRanges.ts +192 -10
- package/src/markdownEditorCodeMirror/safeUrls.test.ts +0 -0
- package/src/markdownEditorCodeMirror/safeUrls.ts +142 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect, userEvent, waitFor } from "storybook/test"
|
|
3
|
+
import { test } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
|
|
6
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
7
|
+
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
|
+
import * as stories from "./MarkdownView.stories.tsx"
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
AllStates,
|
|
12
|
+
Default,
|
|
13
|
+
HostileMarkdown,
|
|
14
|
+
Interactive,
|
|
15
|
+
NestedLinkText,
|
|
16
|
+
SameAsTheEditor,
|
|
17
|
+
} = composeStories(stories)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The gap this component was built to close, as a red/green fact.
|
|
21
|
+
*
|
|
22
|
+
* `isReadOnly` on either editor leaves nine disabled buttons in a
|
|
23
|
+
* `role="toolbar"` above the document — findable by a screen reader,
|
|
24
|
+
* findable by an agent, and useless to both. A CSS `display: none`
|
|
25
|
+
* in the consuming app was the workaround; an element that is not
|
|
26
|
+
* there is the fix.
|
|
27
|
+
*/
|
|
28
|
+
test("there is no toolbar, not a hidden one", async () => {
|
|
29
|
+
const { canvas } = await mountStory(Default)
|
|
30
|
+
|
|
31
|
+
await expect(canvas.queryAllByRole("toolbar")).toEqual([])
|
|
32
|
+
|
|
33
|
+
await expect(canvas.queryAllByRole("button")).toEqual([])
|
|
34
|
+
|
|
35
|
+
// The side the CSS workaround could never reach: the editor's
|
|
36
|
+
// toolbar collapses into an overflow **menu** at narrow widths, so
|
|
37
|
+
// "hide the toolbar" left a menu button behind.
|
|
38
|
+
await expect(canvas.queryAllByRole("menu")).toEqual([])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A reader, not a text field.
|
|
43
|
+
*
|
|
44
|
+
* The role is the difference between heading navigation and being
|
|
45
|
+
* dropped into forms mode with the whole document read as one flat
|
|
46
|
+
* string — and the headings are the reason it matters, so both are
|
|
47
|
+
* asserted together.
|
|
48
|
+
*/
|
|
49
|
+
test("it is an article whose headings are headings", async () => {
|
|
50
|
+
const { canvas } = await mountStory(Default)
|
|
51
|
+
|
|
52
|
+
const article = expectAgentDrivable(canvas, {
|
|
53
|
+
name: "Description",
|
|
54
|
+
role: "article",
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
await expect(article).toHaveAttribute(
|
|
58
|
+
"contenteditable",
|
|
59
|
+
"false",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
await expect(
|
|
63
|
+
canvas.getByRole("heading", {
|
|
64
|
+
name: "Rack move, phase two",
|
|
65
|
+
}),
|
|
66
|
+
).toHaveAttribute("aria-level", "1")
|
|
67
|
+
|
|
68
|
+
await expect(
|
|
69
|
+
canvas.getByRole("heading", {
|
|
70
|
+
name: "Before the window",
|
|
71
|
+
}),
|
|
72
|
+
).toHaveAttribute("aria-level", "2")
|
|
73
|
+
|
|
74
|
+
await expectNoAxeViolations(article)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The requirement that made this a component in the CodeMirror
|
|
79
|
+
* subpath rather than a `react-markdown` reader in the barrel.
|
|
80
|
+
*
|
|
81
|
+
* Both surfaces get one string. The assertion is not "they look
|
|
82
|
+
* similar" — it is that the constructs a second renderer would get
|
|
83
|
+
* subtly wrong come out identical: the same table, the same cells,
|
|
84
|
+
* the same task boxes, the same autolink.
|
|
85
|
+
*/
|
|
86
|
+
test("it renders what the editor renders", async () => {
|
|
87
|
+
const { canvas } = await mountStory(SameAsTheEditor)
|
|
88
|
+
|
|
89
|
+
const [viewTable, editorTable] =
|
|
90
|
+
canvas.getAllByRole("table")
|
|
91
|
+
|
|
92
|
+
await expect(viewTable).toBeDefined()
|
|
93
|
+
|
|
94
|
+
await expect(editorTable).toBeDefined()
|
|
95
|
+
|
|
96
|
+
const toCellText = (table: HTMLElement) =>
|
|
97
|
+
Array.from(table.querySelectorAll("th, td")).map(
|
|
98
|
+
(cell) => cell.textContent,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
await expect(
|
|
102
|
+
toCellText(viewTable as HTMLElement),
|
|
103
|
+
).toEqual(toCellText(editorTable as HTMLElement))
|
|
104
|
+
|
|
105
|
+
// Three task boxes in each, and the first one ticked in each.
|
|
106
|
+
const checkboxes: HTMLInputElement[] =
|
|
107
|
+
canvas.getAllByRole("checkbox")
|
|
108
|
+
|
|
109
|
+
await expect(checkboxes).toHaveLength(6)
|
|
110
|
+
|
|
111
|
+
await expect(
|
|
112
|
+
checkboxes.filter((checkbox) => checkbox.checked),
|
|
113
|
+
).toHaveLength(2)
|
|
114
|
+
|
|
115
|
+
// The bare URL, autolinked on both sides with nothing typed
|
|
116
|
+
// around it.
|
|
117
|
+
await expect(
|
|
118
|
+
canvas.getAllByText(
|
|
119
|
+
"https://example.invalid/product?id=1234",
|
|
120
|
+
).length,
|
|
121
|
+
).toBe(2)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A link a reader can reach, which the editor's cannot be.
|
|
126
|
+
*
|
|
127
|
+
* The editor paints a `<span>` and translates a `mousedown` into a
|
|
128
|
+
* navigation, because an anchor inside a `contenteditable` fights
|
|
129
|
+
* the caret for the line. A document has no caret, so the link is an
|
|
130
|
+
* anchor: in the tab order, with an address the browser will show,
|
|
131
|
+
* copy and open.
|
|
132
|
+
*/
|
|
133
|
+
test("a link is a real anchor in the tab order", async () => {
|
|
134
|
+
const { canvas } = await mountStory(Interactive)
|
|
135
|
+
|
|
136
|
+
const link = expectAgentDrivable(canvas, {
|
|
137
|
+
name: "the runbook",
|
|
138
|
+
role: "link",
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
await expect(link.tagName).toBe("A")
|
|
142
|
+
|
|
143
|
+
await expect(link).toHaveAttribute(
|
|
144
|
+
"href",
|
|
145
|
+
"https://example.invalid/runbook",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
// `target="_blank"` without `noopener` hands the opened page a
|
|
149
|
+
// live handle back into this one.
|
|
150
|
+
await expect(link).toHaveAttribute(
|
|
151
|
+
"rel",
|
|
152
|
+
"noopener noreferrer",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
// The document itself is not a tab stop — there is nothing in it
|
|
156
|
+
// to operate — but every link in it is. Tab from the button above
|
|
157
|
+
// lands on the first link rather than on the article.
|
|
158
|
+
canvas.getByRole("button", { name: "Before" }).focus()
|
|
159
|
+
|
|
160
|
+
await userEvent.tab()
|
|
161
|
+
|
|
162
|
+
await expect(document.activeElement?.tagName).toBe("A")
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The answer to "is a checkbox interactive in a read-only view?",
|
|
167
|
+
* asserted from both sides.
|
|
168
|
+
*
|
|
169
|
+
* Not offered by default, because two of the three surfaces this was
|
|
170
|
+
* built for have nowhere to put a tick. Offered when the consumer
|
|
171
|
+
* says where it goes, which is the same shape `onUploadImage` uses
|
|
172
|
+
* on the editors.
|
|
173
|
+
*/
|
|
174
|
+
test("a checkbox is inert until a consumer says where the tick goes", async () => {
|
|
175
|
+
const { canvas } = await mountStory(AllStates)
|
|
176
|
+
|
|
177
|
+
const inert = canvas.getByRole("checkbox", {
|
|
178
|
+
name: "Photograph the current cabling",
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
await expect(inert).toBeDisabled()
|
|
182
|
+
|
|
183
|
+
const tickable = canvas.getByRole("checkbox", {
|
|
184
|
+
name: "Close the change record",
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
await expect(tickable).not.toBeDisabled()
|
|
188
|
+
|
|
189
|
+
await userEvent.click(tickable)
|
|
190
|
+
|
|
191
|
+
// The tick reaches the *markdown* and comes back as state, which
|
|
192
|
+
// is the round trip that matters: the box is a view over the
|
|
193
|
+
// document, and the document is what the consumer stores.
|
|
194
|
+
await waitFor(async () => {
|
|
195
|
+
await expect(
|
|
196
|
+
canvas.getByRole("checkbox", {
|
|
197
|
+
name: "Close the change record",
|
|
198
|
+
}),
|
|
199
|
+
).toBeChecked()
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The document this thing will actually be pointed at.
|
|
205
|
+
*
|
|
206
|
+
* Three separate claims, and each one used to be false or was one
|
|
207
|
+
* refactor away from it: a `javascript:` URL is not a link, a
|
|
208
|
+
* `<script>` is text, and a `data:text/html` is not an image.
|
|
209
|
+
*/
|
|
210
|
+
test("hostile markdown renders as characters, not as behaviour", async () => {
|
|
211
|
+
const { canvas, canvasElement } =
|
|
212
|
+
await mountStory(HostileMarkdown)
|
|
213
|
+
|
|
214
|
+
const hrefs = canvas
|
|
215
|
+
.getAllByRole("link")
|
|
216
|
+
.map((link: HTMLElement) => link.getAttribute("href"))
|
|
217
|
+
|
|
218
|
+
await expect(hrefs).toEqual([
|
|
219
|
+
"https://example.invalid/runbook",
|
|
220
|
+
])
|
|
221
|
+
|
|
222
|
+
await expect(
|
|
223
|
+
canvasElement.querySelector('[href^="javascript:"]'),
|
|
224
|
+
).toBeNull()
|
|
225
|
+
|
|
226
|
+
// The whole `[text](javascript:…)` construct is left as source, so
|
|
227
|
+
// the reader can see what the file tried to do.
|
|
228
|
+
await expect(canvasElement.textContent).toContain(
|
|
229
|
+
"[Looks like a link](javascript:",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
// A `<script>` in the source is characters. There is no
|
|
233
|
+
// `innerHTML` in this subpath for it to be anything else.
|
|
234
|
+
await expect(
|
|
235
|
+
canvasElement.querySelector("script"),
|
|
236
|
+
).toBeNull()
|
|
237
|
+
|
|
238
|
+
await expect(canvasElement.textContent).toContain(
|
|
239
|
+
"<script>",
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
// `<img src=x onerror=…>` — the tag is text, so there is no
|
|
243
|
+
// element to fail to load.
|
|
244
|
+
await expect(
|
|
245
|
+
canvasElement.querySelector("img[onerror]"),
|
|
246
|
+
).toBeNull()
|
|
247
|
+
|
|
248
|
+
// The only image markdown in the document names a
|
|
249
|
+
// `data:text/html` URL, which is not an image URL. `[src]` and not
|
|
250
|
+
// `img`, because CodeMirror parks its own source-less
|
|
251
|
+
// `.cm-widgetBuffer` spacer beside every widget.
|
|
252
|
+
await expect(
|
|
253
|
+
canvasElement.querySelector("img[src]"),
|
|
254
|
+
).toBeNull()
|
|
255
|
+
|
|
256
|
+
await expectNoAxeViolations(canvasElement)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Selectable, which is most of what a reader does with a document.
|
|
261
|
+
*
|
|
262
|
+
* A `contenteditable="false"` surface can very easily be an
|
|
263
|
+
* unselectable one — CodeMirror's own base theme is what makes it
|
|
264
|
+
* not, and a theme rule here could take it away without anything
|
|
265
|
+
* else noticing.
|
|
266
|
+
*/
|
|
267
|
+
test("the text can be selected", async () => {
|
|
268
|
+
const { canvas } = await mountStory(Default)
|
|
269
|
+
|
|
270
|
+
const article = canvas.getByRole("article")
|
|
271
|
+
|
|
272
|
+
await expect(
|
|
273
|
+
getComputedStyle(article).userSelect,
|
|
274
|
+
).not.toBe("none")
|
|
275
|
+
|
|
276
|
+
const range = document.createRange()
|
|
277
|
+
|
|
278
|
+
range.selectNodeContents(article)
|
|
279
|
+
|
|
280
|
+
const selection = window.getSelection()
|
|
281
|
+
|
|
282
|
+
selection?.removeAllRanges()
|
|
283
|
+
|
|
284
|
+
selection?.addRange(range)
|
|
285
|
+
|
|
286
|
+
await expect(selection?.toString()).toContain(
|
|
287
|
+
"Rack move, phase two",
|
|
288
|
+
)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The blast radius, as a red/green fact.
|
|
293
|
+
*
|
|
294
|
+
* ``[`file.md`](path)`` produced a zero-length `linkText` range.
|
|
295
|
+
* `Decoration.mark` throws on an empty range, CodeMirror answers a
|
|
296
|
+
* throwing view plugin by destroying it, and destroying it takes
|
|
297
|
+
* **every** decoration in the document — so one link in one bullet
|
|
298
|
+
* dropped the whole file to raw markdown.
|
|
299
|
+
*
|
|
300
|
+
* The unit tests in `livePreviewRanges.test.ts` pin the range. This
|
|
301
|
+
* pins the consequence, which is the part a range assertion cannot
|
|
302
|
+
* see: that the heading three lines above the bad link still
|
|
303
|
+
* renders.
|
|
304
|
+
*/
|
|
305
|
+
test("inline markup in link text does not drop the document to raw source", async () => {
|
|
306
|
+
const { canvas } = await mountStory(NestedLinkText)
|
|
307
|
+
|
|
308
|
+
// The canary. This heading has nothing to do with links, and it
|
|
309
|
+
// is what stopped rendering when the plugin died.
|
|
310
|
+
await expect(
|
|
311
|
+
canvas.getByRole("heading", {
|
|
312
|
+
name: "Where the fix landed",
|
|
313
|
+
}),
|
|
314
|
+
).toHaveAttribute("aria-level", "2")
|
|
315
|
+
|
|
316
|
+
// No raw source anywhere in the document.
|
|
317
|
+
const article = canvas.getByRole("article")
|
|
318
|
+
|
|
319
|
+
await expect(article.textContent).not.toContain("](")
|
|
320
|
+
|
|
321
|
+
await expect(article.textContent).not.toContain("**")
|
|
322
|
+
|
|
323
|
+
await expect(article.textContent).not.toContain("~~")
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Each nesting, reachable as a link by the text a reader sees —
|
|
328
|
+
* with the markers gone, since those are concealed in a document.
|
|
329
|
+
*/
|
|
330
|
+
test.each([
|
|
331
|
+
["a code span", "livePreviewRanges.ts"],
|
|
332
|
+
["strong", "the runbook"],
|
|
333
|
+
["emphasis", "the older note"],
|
|
334
|
+
["strikethrough", "the retired page"],
|
|
335
|
+
["a mix", "read this first"],
|
|
336
|
+
["no nesting at all", "the index"],
|
|
337
|
+
])(
|
|
338
|
+
"link text through %s is a reachable anchor",
|
|
339
|
+
async (_case, name) => {
|
|
340
|
+
const { canvas } = await mountStory(NestedLinkText)
|
|
341
|
+
|
|
342
|
+
const link = expectAgentDrivable(canvas, {
|
|
343
|
+
name,
|
|
344
|
+
role: "link",
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
await expect(link.tagName).toBe("A")
|
|
348
|
+
},
|
|
349
|
+
)
|