@charcuterie/ui 3.0.0 → 3.2.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 (130) hide show
  1. package/README.md +10 -0
  2. package/dist/Badge/Badge.d.ts +33 -3
  3. package/dist/Badge/Badge.d.ts.map +1 -1
  4. package/dist/Badge/Badge.js +20 -2
  5. package/dist/Badge/Badge.js.map +1 -1
  6. package/dist/Board/Board.d.ts +119 -0
  7. package/dist/Board/Board.d.ts.map +1 -0
  8. package/dist/Board/Board.js +165 -0
  9. package/dist/Board/Board.js.map +1 -0
  10. package/dist/Board/BoardCard.d.ts +120 -0
  11. package/dist/Board/BoardCard.d.ts.map +1 -0
  12. package/dist/Board/BoardCard.js +77 -0
  13. package/dist/Board/BoardCard.js.map +1 -0
  14. package/dist/Board/BoardLaneList.d.ts +103 -0
  15. package/dist/Board/BoardLaneList.d.ts.map +1 -0
  16. package/dist/Board/BoardLaneList.js +69 -0
  17. package/dist/Board/BoardLaneList.js.map +1 -0
  18. package/dist/Board/boardMove.d.ts +116 -0
  19. package/dist/Board/boardMove.d.ts.map +1 -0
  20. package/dist/Board/boardMove.js +116 -0
  21. package/dist/Board/boardMove.js.map +1 -0
  22. package/dist/Board/useBoardDrag.d.ts +44 -0
  23. package/dist/Board/useBoardDrag.d.ts.map +1 -0
  24. package/dist/Board/useBoardDrag.js +237 -0
  25. package/dist/Board/useBoardDrag.js.map +1 -0
  26. package/dist/Checkbox/Checkbox.d.ts.map +1 -1
  27. package/dist/Checkbox/Checkbox.js +15 -4
  28. package/dist/Checkbox/Checkbox.js.map +1 -1
  29. package/dist/DataTable/DataTable.d.ts +159 -0
  30. package/dist/DataTable/DataTable.d.ts.map +1 -0
  31. package/dist/DataTable/DataTable.js +197 -0
  32. package/dist/DataTable/DataTable.js.map +1 -0
  33. package/dist/DatePicker/DateGrid.d.ts +79 -0
  34. package/dist/DatePicker/DateGrid.d.ts.map +1 -0
  35. package/dist/DatePicker/DateGrid.js +220 -0
  36. package/dist/DatePicker/DateGrid.js.map +1 -0
  37. package/dist/DatePicker/DatePicker.d.ts +138 -0
  38. package/dist/DatePicker/DatePicker.d.ts.map +1 -0
  39. package/dist/DatePicker/DatePicker.js +384 -0
  40. package/dist/DatePicker/DatePicker.js.map +1 -0
  41. package/dist/DatePicker/datePresets.d.ts +29 -0
  42. package/dist/DatePicker/datePresets.d.ts.map +1 -0
  43. package/dist/DatePicker/datePresets.js +28 -0
  44. package/dist/DatePicker/datePresets.js.map +1 -0
  45. package/dist/DatePicker/parseDateInput.d.ts +106 -0
  46. package/dist/DatePicker/parseDateInput.d.ts.map +1 -0
  47. package/dist/DatePicker/parseDateInput.js +419 -0
  48. package/dist/DatePicker/parseDateInput.js.map +1 -0
  49. package/dist/DatePicker/plainDate.d.ts +167 -0
  50. package/dist/DatePicker/plainDate.d.ts.map +1 -0
  51. package/dist/DatePicker/plainDate.js +272 -0
  52. package/dist/DatePicker/plainDate.js.map +1 -0
  53. package/dist/Dialog/Dialog.d.ts +12 -2
  54. package/dist/Dialog/Dialog.d.ts.map +1 -1
  55. package/dist/Dialog/Dialog.js +2 -2
  56. package/dist/Dialog/Dialog.js.map +1 -1
  57. package/dist/MarkdownEditor/MarkdownEditor.d.ts +147 -0
  58. package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -0
  59. package/dist/MarkdownEditor/MarkdownEditor.js +492 -0
  60. package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -0
  61. package/dist/MarkdownEditor/markdownCommands.d.ts +202 -0
  62. package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -0
  63. package/dist/MarkdownEditor/markdownCommands.js +379 -0
  64. package/dist/MarkdownEditor/markdownCommands.js.map +1 -0
  65. package/dist/MarkdownEditor/markdownSpans.d.ts +92 -0
  66. package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -0
  67. package/dist/MarkdownEditor/markdownSpans.js +303 -0
  68. package/dist/MarkdownEditor/markdownSpans.js.map +1 -0
  69. package/dist/Modal/Modal.d.ts +6 -2
  70. package/dist/Modal/Modal.d.ts.map +1 -1
  71. package/dist/Modal/Modal.js +1 -1
  72. package/dist/Modal/Modal.js.map +1 -1
  73. package/dist/Overlay/OverlayPanel.d.ts +13 -2
  74. package/dist/Overlay/OverlayPanel.d.ts.map +1 -1
  75. package/dist/Overlay/OverlayPanel.js +5 -1
  76. package/dist/Overlay/OverlayPanel.js.map +1 -1
  77. package/dist/categoricalStyles.d.ts +53 -0
  78. package/dist/categoricalStyles.d.ts.map +1 -0
  79. package/dist/categoricalStyles.js +195 -0
  80. package/dist/categoricalStyles.js.map +1 -0
  81. package/dist/index.d.ts +22 -0
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +11 -0
  84. package/dist/index.js.map +1 -1
  85. package/package.json +2 -2
  86. package/src/Badge/Badge.mdx +46 -0
  87. package/src/Badge/Badge.stories.tsx +98 -4
  88. package/src/Badge/Badge.test.tsx +157 -2
  89. package/src/Badge/Badge.tsx +49 -3
  90. package/src/Board/Board.mdx +199 -0
  91. package/src/Board/Board.stories.tsx +707 -0
  92. package/src/Board/Board.test.tsx +310 -0
  93. package/src/Board/Board.tsx +333 -0
  94. package/src/Board/BoardCard.tsx +290 -0
  95. package/src/Board/BoardLaneList.tsx +275 -0
  96. package/src/Board/boardMove.test.ts +213 -0
  97. package/src/Board/boardMove.ts +225 -0
  98. package/src/Board/useBoardDrag.ts +413 -0
  99. package/src/Checkbox/Checkbox.tsx +34 -3
  100. package/src/DataTable/DataTable.mdx +123 -0
  101. package/src/DataTable/DataTable.stories.tsx +411 -0
  102. package/src/DataTable/DataTable.test.tsx +386 -0
  103. package/src/DataTable/DataTable.tsx +497 -0
  104. package/src/DatePicker/DateGrid.tsx +467 -0
  105. package/src/DatePicker/DatePicker.mdx +170 -0
  106. package/src/DatePicker/DatePicker.stories.tsx +268 -0
  107. package/src/DatePicker/DatePicker.test.tsx +455 -0
  108. package/src/DatePicker/DatePicker.tsx +852 -0
  109. package/src/DatePicker/datePresets.ts +34 -0
  110. package/src/DatePicker/parseDateInput.test.ts +187 -0
  111. package/src/DatePicker/parseDateInput.ts +629 -0
  112. package/src/DatePicker/plainDate.test.ts +375 -0
  113. package/src/DatePicker/plainDate.ts +465 -0
  114. package/src/Dialog/Dialog.stories.tsx +48 -0
  115. package/src/Dialog/Dialog.test.tsx +30 -0
  116. package/src/Dialog/Dialog.tsx +13 -1
  117. package/src/MarkdownEditor/MarkdownEditor.mdx +189 -0
  118. package/src/MarkdownEditor/MarkdownEditor.stories.tsx +267 -0
  119. package/src/MarkdownEditor/MarkdownEditor.test.tsx +627 -0
  120. package/src/MarkdownEditor/MarkdownEditor.tsx +862 -0
  121. package/src/MarkdownEditor/markdownCommands.test.ts +384 -0
  122. package/src/MarkdownEditor/markdownCommands.ts +566 -0
  123. package/src/MarkdownEditor/markdownSpans.test.ts +217 -0
  124. package/src/MarkdownEditor/markdownSpans.ts +458 -0
  125. package/src/Modal/Modal.tsx +7 -1
  126. package/src/Overlay/OverlayPanel.tsx +17 -1
  127. package/src/argTypes.storyHelpers.ts +39 -0
  128. package/src/categoricalStyles.ts +244 -0
  129. package/src/index.ts +89 -0
  130. 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 &amp; &lt;"
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("&lt;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
+ "![rack.png](https://example.invalid/blobs/rack.png)",
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
+ "![shelf.png](https://example.invalid/blobs/shelf.png)",
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
+ })