@charcuterie/ui 2.18.0 → 3.1.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 +10 -0
- package/dist/Badge/Badge.d.ts +33 -3
- package/dist/Badge/Badge.d.ts.map +1 -1
- package/dist/Badge/Badge.js +20 -2
- package/dist/Badge/Badge.js.map +1 -1
- package/dist/Board/Board.d.ts +119 -0
- package/dist/Board/Board.d.ts.map +1 -0
- package/dist/Board/Board.js +165 -0
- package/dist/Board/Board.js.map +1 -0
- package/dist/Board/BoardCard.d.ts +120 -0
- package/dist/Board/BoardCard.d.ts.map +1 -0
- package/dist/Board/BoardCard.js +77 -0
- package/dist/Board/BoardCard.js.map +1 -0
- package/dist/Board/BoardLaneList.d.ts +103 -0
- package/dist/Board/BoardLaneList.d.ts.map +1 -0
- package/dist/Board/BoardLaneList.js +69 -0
- package/dist/Board/BoardLaneList.js.map +1 -0
- package/dist/Board/boardMove.d.ts +116 -0
- package/dist/Board/boardMove.d.ts.map +1 -0
- package/dist/Board/boardMove.js +116 -0
- package/dist/Board/boardMove.js.map +1 -0
- package/dist/Board/useBoardDrag.d.ts +44 -0
- package/dist/Board/useBoardDrag.d.ts.map +1 -0
- package/dist/Board/useBoardDrag.js +237 -0
- package/dist/Board/useBoardDrag.js.map +1 -0
- package/dist/Checkbox/Checkbox.d.ts.map +1 -1
- package/dist/Checkbox/Checkbox.js +15 -4
- package/dist/Checkbox/Checkbox.js.map +1 -1
- package/dist/DataTable/DataTable.d.ts +159 -0
- package/dist/DataTable/DataTable.d.ts.map +1 -0
- package/dist/DataTable/DataTable.js +197 -0
- package/dist/DataTable/DataTable.js.map +1 -0
- package/dist/DatePicker/DateGrid.d.ts +79 -0
- package/dist/DatePicker/DateGrid.d.ts.map +1 -0
- package/dist/DatePicker/DateGrid.js +220 -0
- package/dist/DatePicker/DateGrid.js.map +1 -0
- package/dist/DatePicker/DatePicker.d.ts +138 -0
- package/dist/DatePicker/DatePicker.d.ts.map +1 -0
- package/dist/DatePicker/DatePicker.js +384 -0
- package/dist/DatePicker/DatePicker.js.map +1 -0
- package/dist/DatePicker/datePresets.d.ts +29 -0
- package/dist/DatePicker/datePresets.d.ts.map +1 -0
- package/dist/DatePicker/datePresets.js +28 -0
- package/dist/DatePicker/datePresets.js.map +1 -0
- package/dist/DatePicker/parseDateInput.d.ts +106 -0
- package/dist/DatePicker/parseDateInput.d.ts.map +1 -0
- package/dist/DatePicker/parseDateInput.js +419 -0
- package/dist/DatePicker/parseDateInput.js.map +1 -0
- package/dist/DatePicker/plainDate.d.ts +167 -0
- package/dist/DatePicker/plainDate.d.ts.map +1 -0
- package/dist/DatePicker/plainDate.js +272 -0
- package/dist/DatePicker/plainDate.js.map +1 -0
- package/dist/MarkdownEditor/MarkdownEditor.d.ts +147 -0
- package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -0
- package/dist/MarkdownEditor/MarkdownEditor.js +492 -0
- package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -0
- package/dist/MarkdownEditor/markdownCommands.d.ts +202 -0
- package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -0
- package/dist/MarkdownEditor/markdownCommands.js +379 -0
- package/dist/MarkdownEditor/markdownCommands.js.map +1 -0
- package/dist/MarkdownEditor/markdownSpans.d.ts +92 -0
- package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -0
- package/dist/MarkdownEditor/markdownSpans.js +303 -0
- package/dist/MarkdownEditor/markdownSpans.js.map +1 -0
- package/dist/Select/Select.d.ts +36 -1
- package/dist/Select/Select.d.ts.map +1 -1
- package/dist/Select/Select.js +38 -2
- package/dist/Select/Select.js.map +1 -1
- package/dist/categoricalStyles.d.ts +53 -0
- package/dist/categoricalStyles.d.ts.map +1 -0
- package/dist/categoricalStyles.js +195 -0
- package/dist/categoricalStyles.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/Badge/Badge.mdx +46 -0
- package/src/Badge/Badge.stories.tsx +98 -4
- package/src/Badge/Badge.test.tsx +157 -2
- package/src/Badge/Badge.tsx +49 -3
- package/src/Board/Board.mdx +199 -0
- package/src/Board/Board.stories.tsx +707 -0
- package/src/Board/Board.test.tsx +310 -0
- package/src/Board/Board.tsx +333 -0
- package/src/Board/BoardCard.tsx +290 -0
- package/src/Board/BoardLaneList.tsx +275 -0
- package/src/Board/boardMove.test.ts +213 -0
- package/src/Board/boardMove.ts +225 -0
- package/src/Board/useBoardDrag.ts +413 -0
- package/src/Checkbox/Checkbox.tsx +34 -3
- package/src/DataTable/DataTable.mdx +123 -0
- package/src/DataTable/DataTable.stories.tsx +411 -0
- package/src/DataTable/DataTable.test.tsx +386 -0
- package/src/DataTable/DataTable.tsx +497 -0
- package/src/DatePicker/DateGrid.tsx +467 -0
- package/src/DatePicker/DatePicker.mdx +170 -0
- package/src/DatePicker/DatePicker.stories.tsx +268 -0
- package/src/DatePicker/DatePicker.test.tsx +455 -0
- package/src/DatePicker/DatePicker.tsx +852 -0
- package/src/DatePicker/datePresets.ts +34 -0
- package/src/DatePicker/parseDateInput.test.ts +187 -0
- package/src/DatePicker/parseDateInput.ts +629 -0
- package/src/DatePicker/plainDate.test.ts +375 -0
- package/src/DatePicker/plainDate.ts +465 -0
- package/src/MarkdownEditor/MarkdownEditor.mdx +189 -0
- package/src/MarkdownEditor/MarkdownEditor.stories.tsx +267 -0
- package/src/MarkdownEditor/MarkdownEditor.test.tsx +627 -0
- package/src/MarkdownEditor/MarkdownEditor.tsx +862 -0
- package/src/MarkdownEditor/markdownCommands.test.ts +384 -0
- package/src/MarkdownEditor/markdownCommands.ts +566 -0
- package/src/MarkdownEditor/markdownSpans.test.ts +217 -0
- package/src/MarkdownEditor/markdownSpans.ts +458 -0
- package/src/Select/Select.mdx +44 -0
- package/src/Select/Select.stories.tsx +55 -0
- package/src/Select/Select.test.tsx +89 -1
- package/src/Select/Select.tsx +58 -2
- package/src/argTypes.storyHelpers.ts +39 -0
- package/src/categoricalStyles.ts +244 -0
- package/src/index.ts +89 -0
- package/src/sourceRules.test.ts +52 -1
|
@@ -0,0 +1,627 @@
|
|
|
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 "./MarkdownEditor.stories.tsx"
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
AllStates,
|
|
12
|
+
AllVariants,
|
|
13
|
+
Blank,
|
|
14
|
+
Default,
|
|
15
|
+
Interactive,
|
|
16
|
+
NoUpload,
|
|
17
|
+
Responsive,
|
|
18
|
+
} = composeStories(stories)
|
|
19
|
+
|
|
20
|
+
type Canvas = Awaited<
|
|
21
|
+
ReturnType<typeof mountStory>
|
|
22
|
+
>["canvas"]
|
|
23
|
+
|
|
24
|
+
const getEditor = (canvas: Canvas, name: string) =>
|
|
25
|
+
canvas.getByRole("textbox", {
|
|
26
|
+
name,
|
|
27
|
+
}) as HTMLTextAreaElement
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A **harness** artifact, and it exists nowhere in the product.
|
|
31
|
+
*
|
|
32
|
+
* `user-event` keeps its own shadow copy of a control's value and
|
|
33
|
+
* applies the next keystroke against *that* rather than against the
|
|
34
|
+
* DOM. An edit the component made through
|
|
35
|
+
* `document.execCommand("insertText")` — which is the entire reason
|
|
36
|
+
* the native undo stack survives — never passes through the `value`
|
|
37
|
+
* setter it intercepts, so its copy goes stale and the next typed
|
|
38
|
+
* character lands at an offset from two edits ago.
|
|
39
|
+
*
|
|
40
|
+
* Re-assigning `.value` is what resets that copy. A browser has no
|
|
41
|
+
* second opinion about what is in a textarea, so nothing like this
|
|
42
|
+
* is needed outside a test.
|
|
43
|
+
*/
|
|
44
|
+
const resyncHarness = (editor: HTMLTextAreaElement) => {
|
|
45
|
+
const { selectionEnd, selectionStart, value } = editor
|
|
46
|
+
|
|
47
|
+
editor.value = value
|
|
48
|
+
|
|
49
|
+
editor.setSelectionRange(selectionStart, selectionEnd)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The surface is a real `<textarea>`, which is where every keyboard
|
|
54
|
+
* and screen-reader property in this component comes from: caret
|
|
55
|
+
* movement, word-wise selection, `Home`/`End`, IME composition, the
|
|
56
|
+
* platform's own undo stack, `role="textbox"` and
|
|
57
|
+
* `aria-multiline="true"`. A `contenteditable` starts with none of
|
|
58
|
+
* them.
|
|
59
|
+
*/
|
|
60
|
+
test("the surface is a real textbox an agent can find", async () => {
|
|
61
|
+
const { canvas, canvasElement } =
|
|
62
|
+
await mountStory(Default)
|
|
63
|
+
|
|
64
|
+
expectAgentDrivable(canvas, {
|
|
65
|
+
name: "Description",
|
|
66
|
+
role: "textbox",
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const editor = getEditor(canvas, "Description")
|
|
70
|
+
|
|
71
|
+
await expect(editor.tagName).toBe("TEXTAREA")
|
|
72
|
+
|
|
73
|
+
await expect(editor.tabIndex).toBeGreaterThanOrEqual(0)
|
|
74
|
+
|
|
75
|
+
await expectNoAxeViolations(canvasElement)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test("the toolbar is one tab stop with arrow keys inside it", async () => {
|
|
79
|
+
const { canvas, canvasElement } =
|
|
80
|
+
await mountStory(Default)
|
|
81
|
+
|
|
82
|
+
expectAgentDrivable(canvas, {
|
|
83
|
+
name: "Markdown formatting",
|
|
84
|
+
role: "toolbar",
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
await expectNoAxeViolations(canvasElement)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* **The point of the whole component.**
|
|
92
|
+
*
|
|
93
|
+
* Type a value full of the characters that tempt HTML escaping,
|
|
94
|
+
* read the stored string back, and there must be no tag, no entity,
|
|
95
|
+
* and no transformation of any kind. The guarantee is structural
|
|
96
|
+
* rather than policed: a `<textarea>` takes `text/plain` off the
|
|
97
|
+
* clipboard, every command returns markdown, and the painted layer
|
|
98
|
+
* emits text nodes — so there is no code path that could produce a
|
|
99
|
+
* tag even if somebody wanted one.
|
|
100
|
+
*/
|
|
101
|
+
test("markdown goes in and the same markdown comes out", async () => {
|
|
102
|
+
const { canvas } = await mountStory(Blank)
|
|
103
|
+
|
|
104
|
+
const editor = getEditor(canvas, "Description")
|
|
105
|
+
|
|
106
|
+
const dangerous =
|
|
107
|
+
"<script>alert('x')</script> & <b>b</b> \"q\" 'a' 5 < 6 > 4 & <"
|
|
108
|
+
|
|
109
|
+
await userEvent.click(editor)
|
|
110
|
+
|
|
111
|
+
await userEvent.paste(dangerous)
|
|
112
|
+
|
|
113
|
+
await expect(editor.value).toBe(dangerous)
|
|
114
|
+
|
|
115
|
+
await expect(editor.value).not.toContain("<script")
|
|
116
|
+
|
|
117
|
+
// The painted layer beside it is text nodes, not markup — so the
|
|
118
|
+
// tag is on screen as four characters rather than executing.
|
|
119
|
+
const layer = editor.parentElement?.querySelector(
|
|
120
|
+
"[aria-hidden='true']",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
await expect(layer?.textContent).toBe(dangerous)
|
|
124
|
+
|
|
125
|
+
await expect(layer?.querySelector("script")).toBe(null)
|
|
126
|
+
|
|
127
|
+
await expect(layer?.querySelector("b")).toBe(null)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The invariant the painted layer rests on: it holds the *same
|
|
132
|
+
* characters in the same order* as the textarea, or the caret
|
|
133
|
+
* drifts away from the glyphs it is supposed to be sitting between.
|
|
134
|
+
*/
|
|
135
|
+
test("the painted layer holds exactly the textarea's text", async () => {
|
|
136
|
+
const { canvas } = await mountStory(Default)
|
|
137
|
+
|
|
138
|
+
const editor = getEditor(canvas, "Description")
|
|
139
|
+
|
|
140
|
+
const layer = editor.parentElement?.querySelector(
|
|
141
|
+
"[aria-hidden='true']",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
await expect(layer?.textContent).toBe(editor.value)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Metric neutrality, asserted rather than hoped for. The painted
|
|
149
|
+
* layer may only use styling that leaves a glyph's advance width
|
|
150
|
+
* alone — `text-shadow` faux bold, colour, underline — because
|
|
151
|
+
* anything that changes it shears the caret away from the letters.
|
|
152
|
+
* A future edit swapping the faux bold for a real `font-bold` is
|
|
153
|
+
* exactly what this catches.
|
|
154
|
+
*/
|
|
155
|
+
test("emphasis does not change the text's width", async () => {
|
|
156
|
+
const { canvas } = await mountStory(NoUpload)
|
|
157
|
+
|
|
158
|
+
const editor = getEditor(canvas, "Description")
|
|
159
|
+
|
|
160
|
+
const layer = editor.parentElement?.querySelector(
|
|
161
|
+
"[aria-hidden='true']",
|
|
162
|
+
) as HTMLElement
|
|
163
|
+
|
|
164
|
+
const measure = (className: string) => {
|
|
165
|
+
const probe = document.createElement("span")
|
|
166
|
+
|
|
167
|
+
probe.className = className
|
|
168
|
+
|
|
169
|
+
probe.textContent = "measurement"
|
|
170
|
+
|
|
171
|
+
probe.style.whiteSpace = "pre"
|
|
172
|
+
|
|
173
|
+
layer.append(probe)
|
|
174
|
+
|
|
175
|
+
const width = probe.getBoundingClientRect().width
|
|
176
|
+
|
|
177
|
+
probe.remove()
|
|
178
|
+
|
|
179
|
+
return width
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const plain = measure("text-content-primary")
|
|
183
|
+
|
|
184
|
+
for (const className of [
|
|
185
|
+
"text-content-primary [text-shadow:0.02em_0_0_currentColor,-0.02em_0_0_currentColor]",
|
|
186
|
+
"italic text-content-primary",
|
|
187
|
+
"text-intent-accent-content underline",
|
|
188
|
+
"text-content-secondary line-through",
|
|
189
|
+
"text-content-muted",
|
|
190
|
+
]) {
|
|
191
|
+
await expect(
|
|
192
|
+
Math.abs(measure(className) - plain),
|
|
193
|
+
).toBeLessThan(0.5)
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The "raw syntax on the cursor's line" behaviour, in the one form
|
|
199
|
+
* this medium allows: markers cannot be *hidden* without moving the
|
|
200
|
+
* text, so they change contrast instead — accent on the caret's
|
|
201
|
+
* line, muted everywhere else.
|
|
202
|
+
*/
|
|
203
|
+
test("the caret's line reveals its markers", async () => {
|
|
204
|
+
const { canvas } = await mountStory(Blank)
|
|
205
|
+
|
|
206
|
+
const editor = getEditor(canvas, "Description")
|
|
207
|
+
|
|
208
|
+
await userEvent.click(editor)
|
|
209
|
+
|
|
210
|
+
await userEvent.paste("**one**\n**two**")
|
|
211
|
+
|
|
212
|
+
const layer = editor.parentElement?.querySelector(
|
|
213
|
+
"[aria-hidden='true']",
|
|
214
|
+
) as HTMLElement
|
|
215
|
+
|
|
216
|
+
const getMarkerClasses = () =>
|
|
217
|
+
Array.from(layer.querySelectorAll("span"))
|
|
218
|
+
.filter((span) => span.textContent === "**")
|
|
219
|
+
.map((span) => span.className)
|
|
220
|
+
|
|
221
|
+
// The caret is on the last line after the paste.
|
|
222
|
+
await waitFor(() => {
|
|
223
|
+
const classes = getMarkerClasses()
|
|
224
|
+
|
|
225
|
+
expect(classes).toHaveLength(4)
|
|
226
|
+
|
|
227
|
+
expect(classes.slice(0, 2)).toEqual([
|
|
228
|
+
"text-content-muted",
|
|
229
|
+
"text-content-muted",
|
|
230
|
+
])
|
|
231
|
+
|
|
232
|
+
expect(classes.slice(2)).toEqual([
|
|
233
|
+
"text-intent-accent-content",
|
|
234
|
+
"text-intent-accent-content",
|
|
235
|
+
])
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
// Move to the first line and the reveal follows the caret.
|
|
239
|
+
editor.setSelectionRange(1, 1)
|
|
240
|
+
|
|
241
|
+
editor.dispatchEvent(
|
|
242
|
+
new KeyboardEvent("keyup", { bubbles: true }),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
await waitFor(() => {
|
|
246
|
+
expect(getMarkerClasses().slice(0, 2)).toEqual([
|
|
247
|
+
"text-intent-accent-content",
|
|
248
|
+
"text-intent-accent-content",
|
|
249
|
+
])
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Keyboard-only editing, driven the way a keyboard user drives it:
|
|
255
|
+
* no clicks on the toolbar, no pointer at all.
|
|
256
|
+
*/
|
|
257
|
+
test("every format has a shortcut, and Tab is never captured", async () => {
|
|
258
|
+
const { canvas } = await mountStory(Blank)
|
|
259
|
+
|
|
260
|
+
const editor = getEditor(canvas, "Description")
|
|
261
|
+
|
|
262
|
+
await userEvent.click(editor)
|
|
263
|
+
|
|
264
|
+
await userEvent.paste("word")
|
|
265
|
+
|
|
266
|
+
editor.setSelectionRange(0, 4)
|
|
267
|
+
|
|
268
|
+
await userEvent.keyboard("{Control>}b{/Control}")
|
|
269
|
+
|
|
270
|
+
await expect(editor.value).toBe("**word**")
|
|
271
|
+
|
|
272
|
+
await userEvent.keyboard("{Control>}b{/Control}")
|
|
273
|
+
|
|
274
|
+
await expect(editor.value).toBe("word")
|
|
275
|
+
|
|
276
|
+
editor.setSelectionRange(0, 4)
|
|
277
|
+
|
|
278
|
+
await userEvent.keyboard("{Control>}i{/Control}")
|
|
279
|
+
|
|
280
|
+
await expect(editor.value).toBe("_word_")
|
|
281
|
+
|
|
282
|
+
editor.setSelectionRange(0, 6)
|
|
283
|
+
|
|
284
|
+
await userEvent.keyboard("{Control>}k{/Control}")
|
|
285
|
+
|
|
286
|
+
await expect(editor.value).toBe("[_word_]()")
|
|
287
|
+
|
|
288
|
+
// Tab leaves. A multi-line field on a form is exactly where
|
|
289
|
+
// somebody tabs onward, and capturing it is WCAG 2.1.2's
|
|
290
|
+
// keyboard trap.
|
|
291
|
+
await userEvent.tab()
|
|
292
|
+
|
|
293
|
+
await expect(document.activeElement).not.toBe(editor)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
test("Enter continues a list and clears an empty item", async () => {
|
|
297
|
+
const { canvas } = await mountStory(Blank)
|
|
298
|
+
|
|
299
|
+
const editor = getEditor(canvas, "Description")
|
|
300
|
+
|
|
301
|
+
await userEvent.click(editor)
|
|
302
|
+
|
|
303
|
+
await userEvent.paste("- one")
|
|
304
|
+
|
|
305
|
+
await userEvent.keyboard("{Enter}")
|
|
306
|
+
|
|
307
|
+
await expect(editor.value).toBe("- one\n- ")
|
|
308
|
+
|
|
309
|
+
resyncHarness(editor)
|
|
310
|
+
|
|
311
|
+
await userEvent.keyboard("two{Enter}")
|
|
312
|
+
|
|
313
|
+
await expect(editor.value).toBe("- one\n- two\n- ")
|
|
314
|
+
|
|
315
|
+
await userEvent.keyboard("{Enter}")
|
|
316
|
+
|
|
317
|
+
await expect(editor.value).toBe("- one\n- two\n")
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* **Undo, asserted at the mechanism rather than the behaviour.**
|
|
322
|
+
*
|
|
323
|
+
* Assigning a whole new `value` wipes the browser's undo stack, and
|
|
324
|
+
* Ctrl+Z afterwards jumps past everything the user typed — so every
|
|
325
|
+
* programmatic edit goes through `document.execCommand("insertText")`,
|
|
326
|
+
* the one API that appends to that stack.
|
|
327
|
+
*
|
|
328
|
+
* The *behaviour* cannot be tested here and it is worth saying why
|
|
329
|
+
* rather than leaving a weaker test looking like a strong one:
|
|
330
|
+
* `user-event` has no undo implementation, so a
|
|
331
|
+
* `{Control>}z{/Control}` is three key events and nothing else. It
|
|
332
|
+
* would pass against a component that had no undo at all. What can
|
|
333
|
+
* be asserted is the route taken, and that is what a regression
|
|
334
|
+
* here would actually change.
|
|
335
|
+
*/
|
|
336
|
+
test("an edit is applied through the platform's own undo-aware path", async () => {
|
|
337
|
+
const { canvas } = await mountStory(Blank)
|
|
338
|
+
|
|
339
|
+
const editor = getEditor(canvas, "Description")
|
|
340
|
+
|
|
341
|
+
const calls: string[] = []
|
|
342
|
+
|
|
343
|
+
const original = document.execCommand.bind(document)
|
|
344
|
+
|
|
345
|
+
// `isShowingUi`, not the DOM's own `showUI`: the `is`/`has` rule
|
|
346
|
+
// has no external-API carve-out, and this is a parameter name we
|
|
347
|
+
// are choosing rather than a key on someone else's object.
|
|
348
|
+
document.execCommand = (
|
|
349
|
+
commandId: string,
|
|
350
|
+
isShowingUi?: boolean,
|
|
351
|
+
argument?: string,
|
|
352
|
+
) => {
|
|
353
|
+
calls.push(commandId)
|
|
354
|
+
|
|
355
|
+
return original(commandId, isShowingUi, argument)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
await userEvent.click(editor)
|
|
360
|
+
|
|
361
|
+
await userEvent.keyboard("word")
|
|
362
|
+
|
|
363
|
+
editor.setSelectionRange(0, 4)
|
|
364
|
+
|
|
365
|
+
await userEvent.keyboard("{Control>}b{/Control}")
|
|
366
|
+
|
|
367
|
+
await expect(editor.value).toBe("**word**")
|
|
368
|
+
|
|
369
|
+
await expect(calls).toContain("insertText")
|
|
370
|
+
} finally {
|
|
371
|
+
document.execCommand = original
|
|
372
|
+
}
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
test("the toolbar drives the same commands", async () => {
|
|
376
|
+
const { body, canvas, canvasElement } =
|
|
377
|
+
await mountStory(Blank)
|
|
378
|
+
|
|
379
|
+
const editor = getEditor(canvas, "Description")
|
|
380
|
+
|
|
381
|
+
await userEvent.click(editor)
|
|
382
|
+
|
|
383
|
+
await userEvent.paste("one\ntwo")
|
|
384
|
+
|
|
385
|
+
editor.setSelectionRange(0, 7)
|
|
386
|
+
|
|
387
|
+
// The bar collapses **by measurement**, so at this width most of
|
|
388
|
+
// the actions live in the overflow menu — which is exactly the
|
|
389
|
+
// Narrow View path and therefore the one worth driving.
|
|
390
|
+
const action =
|
|
391
|
+
canvas.queryByRole("button", {
|
|
392
|
+
name: "Bulleted list",
|
|
393
|
+
}) ??
|
|
394
|
+
(await (async () => {
|
|
395
|
+
await userEvent.click(
|
|
396
|
+
canvas.getByRole("button", {
|
|
397
|
+
name: "More actions",
|
|
398
|
+
}),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
return body.getByRole("menuitem", {
|
|
402
|
+
name: "Bulleted list",
|
|
403
|
+
})
|
|
404
|
+
})())
|
|
405
|
+
|
|
406
|
+
await userEvent.click(action)
|
|
407
|
+
|
|
408
|
+
await waitFor(() => {
|
|
409
|
+
expect(editor.value).toBe("- one\n- two")
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
// Focus comes back to the surface, so the next thing typed lands
|
|
413
|
+
// in the document rather than on the button.
|
|
414
|
+
await expect(document.activeElement).toBe(editor)
|
|
415
|
+
|
|
416
|
+
await expectNoAxeViolations(canvasElement)
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The component owns no storage. It hands over a `File`, waits, and
|
|
421
|
+
* inserts whatever URL comes back — with a **markdown** placeholder
|
|
422
|
+
* holding the spot meanwhile, because the user keeps typing during
|
|
423
|
+
* an upload.
|
|
424
|
+
*/
|
|
425
|
+
test("a pasted image uploads and lands at the caret", async () => {
|
|
426
|
+
const { canvas } = await mountStory(Interactive)
|
|
427
|
+
|
|
428
|
+
const editor = getEditor(canvas, "Task description")
|
|
429
|
+
|
|
430
|
+
await userEvent.click(editor)
|
|
431
|
+
|
|
432
|
+
editor.setSelectionRange(0, 0)
|
|
433
|
+
|
|
434
|
+
const dataTransfer = new DataTransfer()
|
|
435
|
+
|
|
436
|
+
dataTransfer.items.add(
|
|
437
|
+
new File(["binary"], "rack.png", {
|
|
438
|
+
type: "image/png",
|
|
439
|
+
}),
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
editor.dispatchEvent(
|
|
443
|
+
new ClipboardEvent("paste", {
|
|
444
|
+
bubbles: true,
|
|
445
|
+
cancelable: true,
|
|
446
|
+
clipboardData: dataTransfer,
|
|
447
|
+
}),
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
await waitFor(() => {
|
|
451
|
+
expect(editor.value).toContain("![Uploading rack.png]")
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
await waitFor(() => {
|
|
455
|
+
expect(editor.value).toContain(
|
|
456
|
+
"",
|
|
457
|
+
)
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
await expect(editor.value).not.toContain("Uploading")
|
|
461
|
+
|
|
462
|
+
// And it is still markdown, with no tag anywhere.
|
|
463
|
+
await expect(editor.value).not.toContain("<img")
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
test("a dropped image takes the same path", async () => {
|
|
467
|
+
const { canvas } = await mountStory(Interactive)
|
|
468
|
+
|
|
469
|
+
const editor = getEditor(canvas, "Task description")
|
|
470
|
+
|
|
471
|
+
const dataTransfer = new DataTransfer()
|
|
472
|
+
|
|
473
|
+
dataTransfer.items.add(
|
|
474
|
+
new File(["binary"], "shelf.png", {
|
|
475
|
+
type: "image/png",
|
|
476
|
+
}),
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
editor.dispatchEvent(
|
|
480
|
+
new DragEvent("dragover", {
|
|
481
|
+
bubbles: true,
|
|
482
|
+
cancelable: true,
|
|
483
|
+
dataTransfer,
|
|
484
|
+
}),
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
editor.dispatchEvent(
|
|
488
|
+
new DragEvent("drop", {
|
|
489
|
+
bubbles: true,
|
|
490
|
+
cancelable: true,
|
|
491
|
+
dataTransfer,
|
|
492
|
+
}),
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
await waitFor(() => {
|
|
496
|
+
expect(editor.value).toContain(
|
|
497
|
+
"",
|
|
498
|
+
)
|
|
499
|
+
})
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* With no `onUploadImage` the paste is left entirely to the
|
|
504
|
+
* browser, which is the correct fallback rather than a broken one:
|
|
505
|
+
* a `<textarea>` ignores a pasted image on its own.
|
|
506
|
+
*/
|
|
507
|
+
test("without an upload callback an image paste is left alone", async () => {
|
|
508
|
+
const { canvas } = await mountStory(NoUpload)
|
|
509
|
+
|
|
510
|
+
const editor = getEditor(canvas, "Description")
|
|
511
|
+
|
|
512
|
+
const before = editor.value
|
|
513
|
+
|
|
514
|
+
const dataTransfer = new DataTransfer()
|
|
515
|
+
|
|
516
|
+
dataTransfer.items.add(
|
|
517
|
+
new File(["binary"], "ignored.png", {
|
|
518
|
+
type: "image/png",
|
|
519
|
+
}),
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
editor.dispatchEvent(
|
|
523
|
+
new ClipboardEvent("paste", {
|
|
524
|
+
bubbles: true,
|
|
525
|
+
cancelable: true,
|
|
526
|
+
clipboardData: dataTransfer,
|
|
527
|
+
}),
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
await expect(editor.value).toBe(before)
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
test("a read-only editor still paints and refuses edits", async () => {
|
|
534
|
+
const { canvas, canvasElement } =
|
|
535
|
+
await mountStory(AllStates)
|
|
536
|
+
|
|
537
|
+
const editor = getEditor(canvas, "Read-only description")
|
|
538
|
+
|
|
539
|
+
await expect(editor).toHaveAttribute("readonly")
|
|
540
|
+
|
|
541
|
+
const before = editor.value
|
|
542
|
+
|
|
543
|
+
await userEvent.click(editor)
|
|
544
|
+
|
|
545
|
+
await userEvent.keyboard("{Control>}b{/Control}")
|
|
546
|
+
|
|
547
|
+
await expect(editor.value).toBe(before)
|
|
548
|
+
|
|
549
|
+
await expectNoAxeViolations(canvasElement)
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
test("a disabled editor is disabled", async () => {
|
|
553
|
+
const { canvas } = await mountStory(AllStates)
|
|
554
|
+
|
|
555
|
+
await expect(
|
|
556
|
+
getEditor(canvas, "Disabled description"),
|
|
557
|
+
).toBeDisabled()
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* `Field` clones onto its one child, so the wiring has to reach the
|
|
562
|
+
* `<textarea>` through `SlotProps` rather than stopping at this
|
|
563
|
+
* component — the silent prop-drop `slotProps.ts` exists for.
|
|
564
|
+
*/
|
|
565
|
+
test("a Field names, describes and invalidates the surface", async () => {
|
|
566
|
+
const { canvas, canvasElement } =
|
|
567
|
+
await mountStory(AllStates)
|
|
568
|
+
|
|
569
|
+
const editor = canvas.getByRole("textbox", {
|
|
570
|
+
name: "Task description",
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
await expect(editor).toHaveAttribute(
|
|
574
|
+
"aria-invalid",
|
|
575
|
+
"true",
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
await expect(editor).toHaveAttribute(
|
|
579
|
+
"aria-required",
|
|
580
|
+
"true",
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
const describedBy =
|
|
584
|
+
editor.getAttribute("aria-describedby") ?? ""
|
|
585
|
+
|
|
586
|
+
// The Field's error, and the editor's own hint, both — that join
|
|
587
|
+
// is the entire reason `mergeSlotProps` exists.
|
|
588
|
+
await expect(
|
|
589
|
+
describedBy.split(" ").length,
|
|
590
|
+
).toBeGreaterThan(1)
|
|
591
|
+
|
|
592
|
+
await expectNoAxeViolations(canvasElement)
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
test("every variant renders clean", async () => {
|
|
596
|
+
const { canvasElement } = await mountStory(AllVariants)
|
|
597
|
+
|
|
598
|
+
await expectNoAxeViolations(canvasElement)
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Three fixed container widths, side by side. The hint line is
|
|
603
|
+
* `sr-only` below `--cq-sm` and painted above it — one element,
|
|
604
|
+
* in the accessibility tree at every width, because
|
|
605
|
+
* `display: none` would take it out and an `aria-describedby`
|
|
606
|
+
* pointing at it would describe nothing.
|
|
607
|
+
*/
|
|
608
|
+
test("the hint stays described when the container is narrow", async () => {
|
|
609
|
+
const { canvas, canvasElement } =
|
|
610
|
+
await mountStory(Responsive)
|
|
611
|
+
|
|
612
|
+
const narrow = canvas.getByRole("textbox", {
|
|
613
|
+
name: "Description at 15rem",
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
const hint = document.getElementById(
|
|
617
|
+
narrow.getAttribute("aria-describedby") ?? "",
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
await expect(hint?.textContent).toContain("Ctrl+B")
|
|
621
|
+
|
|
622
|
+
await expect(
|
|
623
|
+
getComputedStyle(hint as Element).display,
|
|
624
|
+
).not.toBe("none")
|
|
625
|
+
|
|
626
|
+
await expectNoAxeViolations(canvasElement)
|
|
627
|
+
})
|