@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.
Files changed (32) hide show
  1. package/dist/markdownEditorCodeMirror/MarkdownView.d.ts +140 -0
  2. package/dist/markdownEditorCodeMirror/MarkdownView.d.ts.map +1 -0
  3. package/dist/markdownEditorCodeMirror/MarkdownView.js +268 -0
  4. package/dist/markdownEditorCodeMirror/MarkdownView.js.map +1 -0
  5. package/dist/markdownEditorCodeMirror/index.d.ts +14 -3
  6. package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -1
  7. package/dist/markdownEditorCodeMirror/index.js +12 -3
  8. package/dist/markdownEditorCodeMirror/index.js.map +1 -1
  9. package/dist/markdownEditorCodeMirror/livePreview.d.ts +42 -2
  10. package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -1
  11. package/dist/markdownEditorCodeMirror/livePreview.js +298 -28
  12. package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
  13. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -1
  14. package/dist/markdownEditorCodeMirror/livePreviewRanges.js +141 -10
  15. package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -1
  16. package/dist/markdownEditorCodeMirror/safeUrls.d.ts +46 -0
  17. package/dist/markdownEditorCodeMirror/safeUrls.d.ts.map +1 -0
  18. package/dist/markdownEditorCodeMirror/safeUrls.js +123 -0
  19. package/dist/markdownEditorCodeMirror/safeUrls.js.map +1 -0
  20. package/package.json +1 -1
  21. package/src/MarkdownEditor/MarkdownEditor.mdx +5 -0
  22. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +11 -0
  23. package/src/markdownEditorCodeMirror/MarkdownView.mdx +173 -0
  24. package/src/markdownEditorCodeMirror/MarkdownView.stories.tsx +362 -0
  25. package/src/markdownEditorCodeMirror/MarkdownView.test.tsx +349 -0
  26. package/src/markdownEditorCodeMirror/MarkdownView.tsx +373 -0
  27. package/src/markdownEditorCodeMirror/index.ts +17 -2
  28. package/src/markdownEditorCodeMirror/livePreview.ts +391 -35
  29. package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +233 -0
  30. package/src/markdownEditorCodeMirror/livePreviewRanges.ts +192 -10
  31. package/src/markdownEditorCodeMirror/safeUrls.test.ts +0 -0
  32. 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
+ )