@charcuterie/ui 3.13.0 → 3.14.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 (31) 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 +243 -26
  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 +49 -7
  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 +157 -0
  24. package/src/markdownEditorCodeMirror/MarkdownView.stories.tsx +314 -0
  25. package/src/markdownEditorCodeMirror/MarkdownView.test.tsx +288 -0
  26. package/src/markdownEditorCodeMirror/MarkdownView.tsx +373 -0
  27. package/src/markdownEditorCodeMirror/index.ts +17 -2
  28. package/src/markdownEditorCodeMirror/livePreview.ts +329 -33
  29. package/src/markdownEditorCodeMirror/livePreviewRanges.ts +64 -7
  30. package/src/markdownEditorCodeMirror/safeUrls.test.ts +0 -0
  31. package/src/markdownEditorCodeMirror/safeUrls.ts +142 -0
@@ -0,0 +1,314 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import { useState } from "react"
3
+
4
+ import {
5
+ ContainerBoard,
6
+ StoryCell,
7
+ StoryGrid,
8
+ } from "../board.storyHelpers.tsx"
9
+ import { Card } from "../Card/Card.tsx"
10
+ import { MarkdownEditorCodeMirror } from "./MarkdownEditorCodeMirror.tsx"
11
+ import { MarkdownView } from "./MarkdownView.tsx"
12
+
13
+ /**
14
+ * Invented, like every fixture in this repo — a task in a made-up
15
+ * homelab project, and deliberately the **same string** the editor's
16
+ * stories use.
17
+ *
18
+ * That is what makes the `SameAsTheEditor` story an argument rather
19
+ * than an illustration: the two surfaces are handed one document and
20
+ * a reader compares the frames.
21
+ */
22
+ const SAMPLE = `# Rack move, phase two
23
+
24
+ The **short** version: pull the _old_ switch, land the new one, and
25
+ keep the ~~three~~ two uplinks live throughout.
26
+
27
+ ## Before the window
28
+
29
+ - [x] Label every patch lead
30
+ - [ ] Photograph the current cabling
31
+ - [ ] Cut over at 02:00
32
+
33
+ 1. Power down the shelf
34
+ 2. Swap the rails
35
+ 3. Bring it back up
36
+
37
+ > Nothing here touches the storage pool.
38
+
39
+ Run \`ping -c 3 gateway.invalid\` before and after, and read
40
+ [the runbook](https://example.invalid/runbook) first.
41
+
42
+ | Port | Goes to |
43
+ | --- | --- |
44
+ | 1 | uplink |
45
+ | 2 | shelf |
46
+
47
+ \`\`\`sh
48
+ ip -br addr show
49
+ \`\`\`
50
+
51
+ Replacement rails ordered from https://example.invalid/product?id=1234
52
+ `
53
+
54
+ /**
55
+ * A drawing rather than a fetched file, so the story needs no
56
+ * network and the frame is the same on every machine. The same
57
+ * technique `Avatar`'s portrait uses, and one of the few things
58
+ * `data:` is genuinely good for.
59
+ */
60
+ const DIAGRAM_URL = `data:image/svg+xml;utf8,${encodeURIComponent(
61
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 60"><rect width="240" height="60" rx="6" fill="#2F6F4E"/><rect x="12" y="14" width="96" height="32" rx="4" fill="#F2E8CF"/><rect x="132" y="14" width="96" height="32" rx="4" fill="#F2E8CF"/><path d="M108 30h24" stroke="#F2E8CF" stroke-width="4"/></svg>',
62
+ )}`
63
+
64
+ const WITH_IMAGE = `## Rack inventory
65
+
66
+ ![Two shelves and the uplink between them](${DIAGRAM_URL})
67
+
68
+ | Port | Goes to | **Speed** |
69
+ | :--- | :---: | ---: |
70
+ | 1 | uplink | 10G |
71
+ | 2 | shelf | 1G |
72
+ `
73
+
74
+ /**
75
+ * The document nobody in this house wrote: what a fetched file looks
76
+ * like when its author would rather it did something else.
77
+ *
78
+ * Every line here is a thing that used to work. The `javascript:`
79
+ * link opened and ran; the `<script>` was never a script but is
80
+ * worth showing anyway; the `data:text/html` image was an `src` this
81
+ * component wrote verbatim.
82
+ */
83
+ const HOSTILE = `# A file fetched from somewhere else
84
+
85
+ [Looks like a link](javascript:alert(document.domain)) — and is not one.
86
+
87
+ <script>alert("this is four coloured characters, not a script")</script>
88
+
89
+ <img src=x onerror="alert(1)">
90
+
91
+ ![Not an image](data:text/html,<script>alert(1)</script>)
92
+
93
+ [This one is fine](https://example.invalid/runbook), and so is ./RELEASING.md.
94
+ `
95
+
96
+ const CHECKLIST = `## Before the window
97
+
98
+ - [x] Label every patch lead
99
+ - [ ] Photograph the current cabling
100
+ - [ ] Cut over at 02:00
101
+ `
102
+
103
+ /**
104
+ * A second list rather than the same one twice, and the difference
105
+ * is load bearing: two checkboxes with the same accessible name on
106
+ * one page are two things `getByRole("checkbox", { name })` cannot
107
+ * tell apart — which is the drivability property this library gates
108
+ * on, failing in a *story* rather than in a component.
109
+ */
110
+ const TICKABLE_CHECKLIST = `## After the window
111
+
112
+ - [x] Bring the shelf back up
113
+ - [ ] Re-check both uplinks
114
+ - [ ] Close the change record
115
+ `
116
+
117
+ /**
118
+ * The state the checkbox story cannot show without one: a tick that
119
+ * goes somewhere.
120
+ *
121
+ * `onToggleTask` hands back the whole next markdown document, which
122
+ * is the same shape the editors' `onChange` uses — so a consumer
123
+ * wires one autosave and points both at it.
124
+ */
125
+ const TickableChecklist = () => {
126
+ const [value, setValue] = useState(TICKABLE_CHECKLIST)
127
+
128
+ return (
129
+ <MarkdownView
130
+ label="Tickable checklist"
131
+ onToggleTask={setValue}
132
+ value={value}
133
+ />
134
+ )
135
+ }
136
+
137
+ const meta = {
138
+ title: "Components/Data/MarkdownView",
139
+ component: MarkdownView,
140
+ parameters: { layout: "padded" },
141
+ args: {
142
+ label: "Description",
143
+ onToggleTask: undefined,
144
+ value: SAMPLE,
145
+ },
146
+ } satisfies Meta<typeof MarkdownView>
147
+
148
+ export default meta
149
+
150
+ type Story = StoryObj<typeof meta>
151
+
152
+ /**
153
+ * The whole point in one frame: the document is drawn, and there is
154
+ * **no toolbar above it**. Not a disabled one, not a hidden one —
155
+ * `getAllByRole("toolbar")` returns nothing, because nothing in this
156
+ * component can render one.
157
+ */
158
+ export const Default: Story = {}
159
+
160
+ /**
161
+ * The same string, in both surfaces, side by side.
162
+ *
163
+ * This is the requirement the component exists to meet, and it is
164
+ * the one a screenshot can actually check: the heading is the same
165
+ * size, the table has the same borders and the same alignment, the
166
+ * checkboxes are in the same places, and the bare URL is a link in
167
+ * both. They agree because they are the same code — one parser, one
168
+ * set of range rules, one theme — rather than because somebody kept
169
+ * two renderers in step.
170
+ */
171
+ export const SameAsTheEditor: Story = {
172
+ render: (viewProps) => (
173
+ <StoryGrid columns={2}>
174
+ <StoryCell align="stretch" label="MarkdownView">
175
+ <MarkdownView {...viewProps} />
176
+ </StoryCell>
177
+
178
+ <StoryCell
179
+ align="stretch"
180
+ label="MarkdownEditorCodeMirror"
181
+ >
182
+ <MarkdownEditorCodeMirror
183
+ defaultValue={SAMPLE}
184
+ label="Description"
185
+ />
186
+ </StoryCell>
187
+ </StoryGrid>
188
+ ),
189
+ }
190
+
191
+ /**
192
+ * Everything the reader can be shown, including the two states that
193
+ * are easy to get wrong: an empty document, and a checkbox nobody
194
+ * may tick.
195
+ */
196
+ export const AllStates: Story = {
197
+ render: (viewProps) => (
198
+ <StoryGrid columns={2}>
199
+ <StoryCell
200
+ align="stretch"
201
+ label="checkboxes are inert without onToggleTask"
202
+ >
203
+ <MarkdownView
204
+ {...viewProps}
205
+ label="Read-only checklist"
206
+ value={CHECKLIST}
207
+ />
208
+ </StoryCell>
209
+
210
+ <StoryCell
211
+ align="stretch"
212
+ label="…and operable with one"
213
+ >
214
+ <TickableChecklist />
215
+ </StoryCell>
216
+
217
+ <StoryCell
218
+ align="stretch"
219
+ label="an image and a table"
220
+ >
221
+ <MarkdownView
222
+ {...viewProps}
223
+ label="Rack inventory"
224
+ value={WITH_IMAGE}
225
+ />
226
+ </StoryCell>
227
+
228
+ <StoryCell align="stretch" label="an empty document">
229
+ <MarkdownView
230
+ {...viewProps}
231
+ label="Nothing written yet"
232
+ value=""
233
+ />
234
+ </StoryCell>
235
+ </StoryGrid>
236
+ ),
237
+ }
238
+
239
+ /**
240
+ * A file from a git host, rendering as the characters it contains.
241
+ *
242
+ * The `javascript:` link keeps its brackets and its parentheses on
243
+ * purpose. A link-coloured word that silently refuses to work tells
244
+ * the reader nothing; the source tells them exactly what the file
245
+ * tried to do.
246
+ */
247
+ export const HostileMarkdown: Story = {
248
+ args: {
249
+ label: "README.md at 9f3c1ab",
250
+ value: HOSTILE,
251
+ },
252
+ }
253
+
254
+ /**
255
+ * The three container widths. A table that will not fit scrolls
256
+ * inside its own block rather than pushing the column sideways, and
257
+ * an image is capped at the width it is given — which is what keeps
258
+ * the Narrow View readable instead of merely small.
259
+ */
260
+ export const Responsive: Story = {
261
+ render: (viewProps) => (
262
+ <ContainerBoard>
263
+ {(width) => (
264
+ <Card heading={`Rack inventory (${width})`}>
265
+ <MarkdownView
266
+ {...viewProps}
267
+ label={`Rack inventory at ${width}`}
268
+ value={WITH_IMAGE}
269
+ />
270
+ </Card>
271
+ )}
272
+ </ContainerBoard>
273
+ ),
274
+ }
275
+
276
+ /**
277
+ * The complete keyboard path, which for a reader is short and is the
278
+ * point.
279
+ *
280
+ * The document itself is **not** a tab stop — there is nothing in it
281
+ * to operate, and a reading surface that swallowed a Tab would be
282
+ * one more thing between the reader and the next control. What *is*
283
+ * reachable is every link in it, in document order, because they are
284
+ * real anchors rather than painted spans: Tab to one, Enter to open
285
+ * it, and the context menu will copy its address.
286
+ *
287
+ * The buttons on either side are there so the path is visible — Tab
288
+ * from the first lands on each link in turn and ends on the second.
289
+ */
290
+ export const Interactive: Story = {
291
+ args: {
292
+ label: "Rack move, phase two",
293
+ value: SAMPLE,
294
+ },
295
+ render: (viewProps) => (
296
+ <div className="flex flex-col items-start gap-3">
297
+ <button
298
+ className="rounded-md border border-border-default px-3 py-1 text-content-primary text-sm"
299
+ type="button"
300
+ >
301
+ {"Before"}
302
+ </button>
303
+
304
+ <MarkdownView {...viewProps} />
305
+
306
+ <button
307
+ className="rounded-md border border-border-default px-3 py-1 text-content-primary text-sm"
308
+ type="button"
309
+ >
310
+ {"After"}
311
+ </button>
312
+ </div>
313
+ ),
314
+ }
@@ -0,0 +1,288 @@
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
+ SameAsTheEditor,
16
+ } = composeStories(stories)
17
+
18
+ /**
19
+ * The gap this component was built to close, as a red/green fact.
20
+ *
21
+ * `isReadOnly` on either editor leaves nine disabled buttons in a
22
+ * `role="toolbar"` above the document — findable by a screen reader,
23
+ * findable by an agent, and useless to both. A CSS `display: none`
24
+ * in the consuming app was the workaround; an element that is not
25
+ * there is the fix.
26
+ */
27
+ test("there is no toolbar, not a hidden one", async () => {
28
+ const { canvas } = await mountStory(Default)
29
+
30
+ await expect(canvas.queryAllByRole("toolbar")).toEqual([])
31
+
32
+ await expect(canvas.queryAllByRole("button")).toEqual([])
33
+
34
+ // The side the CSS workaround could never reach: the editor's
35
+ // toolbar collapses into an overflow **menu** at narrow widths, so
36
+ // "hide the toolbar" left a menu button behind.
37
+ await expect(canvas.queryAllByRole("menu")).toEqual([])
38
+ })
39
+
40
+ /**
41
+ * A reader, not a text field.
42
+ *
43
+ * The role is the difference between heading navigation and being
44
+ * dropped into forms mode with the whole document read as one flat
45
+ * string — and the headings are the reason it matters, so both are
46
+ * asserted together.
47
+ */
48
+ test("it is an article whose headings are headings", async () => {
49
+ const { canvas } = await mountStory(Default)
50
+
51
+ const article = expectAgentDrivable(canvas, {
52
+ name: "Description",
53
+ role: "article",
54
+ })
55
+
56
+ await expect(article).toHaveAttribute(
57
+ "contenteditable",
58
+ "false",
59
+ )
60
+
61
+ await expect(
62
+ canvas.getByRole("heading", {
63
+ name: "Rack move, phase two",
64
+ }),
65
+ ).toHaveAttribute("aria-level", "1")
66
+
67
+ await expect(
68
+ canvas.getByRole("heading", {
69
+ name: "Before the window",
70
+ }),
71
+ ).toHaveAttribute("aria-level", "2")
72
+
73
+ await expectNoAxeViolations(article)
74
+ })
75
+
76
+ /**
77
+ * The requirement that made this a component in the CodeMirror
78
+ * subpath rather than a `react-markdown` reader in the barrel.
79
+ *
80
+ * Both surfaces get one string. The assertion is not "they look
81
+ * similar" — it is that the constructs a second renderer would get
82
+ * subtly wrong come out identical: the same table, the same cells,
83
+ * the same task boxes, the same autolink.
84
+ */
85
+ test("it renders what the editor renders", async () => {
86
+ const { canvas } = await mountStory(SameAsTheEditor)
87
+
88
+ const [viewTable, editorTable] =
89
+ canvas.getAllByRole("table")
90
+
91
+ await expect(viewTable).toBeDefined()
92
+
93
+ await expect(editorTable).toBeDefined()
94
+
95
+ const toCellText = (table: HTMLElement) =>
96
+ Array.from(table.querySelectorAll("th, td")).map(
97
+ (cell) => cell.textContent,
98
+ )
99
+
100
+ await expect(
101
+ toCellText(viewTable as HTMLElement),
102
+ ).toEqual(toCellText(editorTable as HTMLElement))
103
+
104
+ // Three task boxes in each, and the first one ticked in each.
105
+ const checkboxes: HTMLInputElement[] =
106
+ canvas.getAllByRole("checkbox")
107
+
108
+ await expect(checkboxes).toHaveLength(6)
109
+
110
+ await expect(
111
+ checkboxes.filter((checkbox) => checkbox.checked),
112
+ ).toHaveLength(2)
113
+
114
+ // The bare URL, autolinked on both sides with nothing typed
115
+ // around it.
116
+ await expect(
117
+ canvas.getAllByText(
118
+ "https://example.invalid/product?id=1234",
119
+ ).length,
120
+ ).toBe(2)
121
+ })
122
+
123
+ /**
124
+ * A link a reader can reach, which the editor's cannot be.
125
+ *
126
+ * The editor paints a `<span>` and translates a `mousedown` into a
127
+ * navigation, because an anchor inside a `contenteditable` fights
128
+ * the caret for the line. A document has no caret, so the link is an
129
+ * anchor: in the tab order, with an address the browser will show,
130
+ * copy and open.
131
+ */
132
+ test("a link is a real anchor in the tab order", async () => {
133
+ const { canvas } = await mountStory(Interactive)
134
+
135
+ const link = expectAgentDrivable(canvas, {
136
+ name: "the runbook",
137
+ role: "link",
138
+ })
139
+
140
+ await expect(link.tagName).toBe("A")
141
+
142
+ await expect(link).toHaveAttribute(
143
+ "href",
144
+ "https://example.invalid/runbook",
145
+ )
146
+
147
+ // `target="_blank"` without `noopener` hands the opened page a
148
+ // live handle back into this one.
149
+ await expect(link).toHaveAttribute(
150
+ "rel",
151
+ "noopener noreferrer",
152
+ )
153
+
154
+ // The document itself is not a tab stop — there is nothing in it
155
+ // to operate — but every link in it is. Tab from the button above
156
+ // lands on the first link rather than on the article.
157
+ canvas.getByRole("button", { name: "Before" }).focus()
158
+
159
+ await userEvent.tab()
160
+
161
+ await expect(document.activeElement?.tagName).toBe("A")
162
+ })
163
+
164
+ /**
165
+ * The answer to "is a checkbox interactive in a read-only view?",
166
+ * asserted from both sides.
167
+ *
168
+ * Not offered by default, because two of the three surfaces this was
169
+ * built for have nowhere to put a tick. Offered when the consumer
170
+ * says where it goes, which is the same shape `onUploadImage` uses
171
+ * on the editors.
172
+ */
173
+ test("a checkbox is inert until a consumer says where the tick goes", async () => {
174
+ const { canvas } = await mountStory(AllStates)
175
+
176
+ const inert = canvas.getByRole("checkbox", {
177
+ name: "Photograph the current cabling",
178
+ })
179
+
180
+ await expect(inert).toBeDisabled()
181
+
182
+ const tickable = canvas.getByRole("checkbox", {
183
+ name: "Close the change record",
184
+ })
185
+
186
+ await expect(tickable).not.toBeDisabled()
187
+
188
+ await userEvent.click(tickable)
189
+
190
+ // The tick reaches the *markdown* and comes back as state, which
191
+ // is the round trip that matters: the box is a view over the
192
+ // document, and the document is what the consumer stores.
193
+ await waitFor(async () => {
194
+ await expect(
195
+ canvas.getByRole("checkbox", {
196
+ name: "Close the change record",
197
+ }),
198
+ ).toBeChecked()
199
+ })
200
+ })
201
+
202
+ /**
203
+ * The document this thing will actually be pointed at.
204
+ *
205
+ * Three separate claims, and each one used to be false or was one
206
+ * refactor away from it: a `javascript:` URL is not a link, a
207
+ * `<script>` is text, and a `data:text/html` is not an image.
208
+ */
209
+ test("hostile markdown renders as characters, not as behaviour", async () => {
210
+ const { canvas, canvasElement } =
211
+ await mountStory(HostileMarkdown)
212
+
213
+ const hrefs = canvas
214
+ .getAllByRole("link")
215
+ .map((link: HTMLElement) => link.getAttribute("href"))
216
+
217
+ await expect(hrefs).toEqual([
218
+ "https://example.invalid/runbook",
219
+ ])
220
+
221
+ await expect(
222
+ canvasElement.querySelector('[href^="javascript:"]'),
223
+ ).toBeNull()
224
+
225
+ // The whole `[text](javascript:…)` construct is left as source, so
226
+ // the reader can see what the file tried to do.
227
+ await expect(canvasElement.textContent).toContain(
228
+ "[Looks like a link](javascript:",
229
+ )
230
+
231
+ // A `<script>` in the source is characters. There is no
232
+ // `innerHTML` in this subpath for it to be anything else.
233
+ await expect(
234
+ canvasElement.querySelector("script"),
235
+ ).toBeNull()
236
+
237
+ await expect(canvasElement.textContent).toContain(
238
+ "<script>",
239
+ )
240
+
241
+ // `<img src=x onerror=…>` — the tag is text, so there is no
242
+ // element to fail to load.
243
+ await expect(
244
+ canvasElement.querySelector("img[onerror]"),
245
+ ).toBeNull()
246
+
247
+ // The only image markdown in the document names a
248
+ // `data:text/html` URL, which is not an image URL. `[src]` and not
249
+ // `img`, because CodeMirror parks its own source-less
250
+ // `.cm-widgetBuffer` spacer beside every widget.
251
+ await expect(
252
+ canvasElement.querySelector("img[src]"),
253
+ ).toBeNull()
254
+
255
+ await expectNoAxeViolations(canvasElement)
256
+ })
257
+
258
+ /**
259
+ * Selectable, which is most of what a reader does with a document.
260
+ *
261
+ * A `contenteditable="false"` surface can very easily be an
262
+ * unselectable one — CodeMirror's own base theme is what makes it
263
+ * not, and a theme rule here could take it away without anything
264
+ * else noticing.
265
+ */
266
+ test("the text can be selected", async () => {
267
+ const { canvas } = await mountStory(Default)
268
+
269
+ const article = canvas.getByRole("article")
270
+
271
+ await expect(
272
+ getComputedStyle(article).userSelect,
273
+ ).not.toBe("none")
274
+
275
+ const range = document.createRange()
276
+
277
+ range.selectNodeContents(article)
278
+
279
+ const selection = window.getSelection()
280
+
281
+ selection?.removeAllRanges()
282
+
283
+ selection?.addRange(range)
284
+
285
+ await expect(selection?.toString()).toContain(
286
+ "Rack move, phase two",
287
+ )
288
+ })