@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.
- 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 +243 -26
- package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js +49 -7
- 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 +157 -0
- package/src/markdownEditorCodeMirror/MarkdownView.stories.tsx +314 -0
- package/src/markdownEditorCodeMirror/MarkdownView.test.tsx +288 -0
- package/src/markdownEditorCodeMirror/MarkdownView.tsx +373 -0
- package/src/markdownEditorCodeMirror/index.ts +17 -2
- package/src/markdownEditorCodeMirror/livePreview.ts +329 -33
- package/src/markdownEditorCodeMirror/livePreviewRanges.ts +64 -7
- package/src/markdownEditorCodeMirror/safeUrls.test.ts +0 -0
- 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
|
+

|
|
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
|
+
</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
|
+
})
|