@charcuterie/ui 3.26.0 → 3.27.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 (35) hide show
  1. package/README.md +4 -2
  2. package/dist/Board/Board.d.ts +20 -7
  3. package/dist/Board/Board.d.ts.map +1 -1
  4. package/dist/Board/Board.js +16 -10
  5. package/dist/Board/Board.js.map +1 -1
  6. package/dist/Board/BoardCard.d.ts +16 -10
  7. package/dist/Board/BoardCard.d.ts.map +1 -1
  8. package/dist/Board/BoardCard.js +11 -11
  9. package/dist/Board/BoardCard.js.map +1 -1
  10. package/dist/Board/BoardLaneList.d.ts +2 -1
  11. package/dist/Board/BoardLaneList.d.ts.map +1 -1
  12. package/dist/Board/BoardLaneList.js +13 -4
  13. package/dist/Board/BoardLaneList.js.map +1 -1
  14. package/dist/MarkdownEditor/MarkdownEditor.d.ts +1 -1
  15. package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -1
  16. package/dist/MarkdownEditor/MarkdownEditor.js +130 -74
  17. package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -1
  18. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -1
  19. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +135 -85
  20. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/Board/Board.mdx +12 -0
  23. package/src/Board/Board.stories.tsx +48 -0
  24. package/src/Board/Board.test.tsx +25 -0
  25. package/src/Board/Board.tsx +34 -8
  26. package/src/Board/BoardCard.tsx +17 -9
  27. package/src/Board/BoardLaneList.tsx +35 -5
  28. package/src/MarkdownEditor/MarkdownEditor.mdx +32 -4
  29. package/src/MarkdownEditor/MarkdownEditor.test.tsx +108 -0
  30. package/src/MarkdownEditor/MarkdownEditor.tsx +89 -0
  31. package/src/icons.storyHelpers.tsx +11 -0
  32. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +3 -1
  33. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.stories.tsx +31 -0
  34. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.test.tsx +109 -1
  35. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +84 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charcuterie/ui",
3
- "version": "3.26.0",
3
+ "version": "3.27.0",
4
4
  "description": "The Charcuterie components. Tokens for look, @charcuterie/logic for state, ARIA that agents can actually drive.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,6 +45,18 @@ The narrow panel there is **not a phone**. It is the same board in a sidebar, in
45
45
  window, or on a display somebody has zoomed to 175% — and a media query cannot tell any of
46
46
  those apart from a maximised 4K window.
47
47
 
48
+ ## Workflow columns and project-pool rows
49
+
50
+ The default `laneLayout="columns"` is for workflow stages. At `cq-lg` the lanes sit side by
51
+ side; below it, one selected lane stays on screen and a segmented control names the others.
52
+
53
+ `laneLayout="rows"` is for parallel project pools. Every lane becomes one horizontal band,
54
+ every lane stays visible, and the cards form a responsive grid inside their band. A consumer
55
+ can pass a short `items` array with the true `itemCount`; the remaining count stays explicit
56
+ instead of making one large pool determine the page height. Neither layout scrolls sideways.
57
+
58
+ <Canvas of={BoardStories.RowLayout} />
59
+
48
60
  ### The three card shapes
49
61
 
50
62
  Straight from the consumer requirement, which is specific because the owner answered *"which
@@ -404,6 +404,35 @@ const LANES: BoardLane[] = [
404
404
  },
405
405
  ]
406
406
 
407
+ /**
408
+ * Eight parallel project pools, not workflow steps. The repeated
409
+ * source items are cloned with lane-qualified keys so the story can
410
+ * put realistic card density in every row without claiming that one
411
+ * card belongs to eight pools.
412
+ */
413
+ const POOL_LANES: BoardLane[] = [
414
+ ["repair", "Repair source", 83],
415
+ ["add", "Add media", 78],
416
+ ["replace", "Replace copy", 66],
417
+ ["review", "Review retention", 38],
418
+ ["find", "Find source", 31],
419
+ ["extras", "Specials and films", 18],
420
+ ["difficult", "Difficult repair", 14],
421
+ ["unfiled", "Not in a lane", 1],
422
+ ].map(([key, label, itemCount]) => ({
423
+ itemCount: Number(itemCount),
424
+ items: TODO_ITEMS.slice(
425
+ 0,
426
+ Math.min(3, Number(itemCount)),
427
+ ).map((item) => ({
428
+ ...item,
429
+ key: `${String(key)}-${item.key}`,
430
+ })),
431
+ key: String(key),
432
+ label: String(label),
433
+ onShowMore: () => undefined,
434
+ }))
435
+
407
436
  /**
408
437
  * A **fixed** inline size, because the three-up layout is a
409
438
  * measurement of the board's own box. A story that inherited the
@@ -446,6 +475,25 @@ type Story = StoryObj<typeof meta>
446
475
  */
447
476
  export const Default: Story = {}
448
477
 
478
+ /**
479
+ * Parallel project pools as horizontal bands. Every lane stays on
480
+ * screen; the cards form a responsive grid inside their own lane,
481
+ * and the honest overflow keeps a large pool from making the page
482
+ * endless.
483
+ */
484
+ export const RowLayout: Story = {
485
+ render: (boardProps) => (
486
+ <Frame inlineSize="72rem">
487
+ <Board
488
+ {...boardProps}
489
+ label="Project pools"
490
+ laneLayout="rows"
491
+ lanes={POOL_LANES}
492
+ />
493
+ </Frame>
494
+ ),
495
+ }
496
+
449
497
  /**
450
498
  * Read-only against movable, side by side.
451
499
  *
@@ -23,6 +23,7 @@ const {
23
23
  LinkedLaneHeadings,
24
24
  MoveHandleByWidth,
25
25
  Responsive,
26
+ RowLayout,
26
27
  } = composeStories(stories)
27
28
 
28
29
  /**
@@ -75,6 +76,30 @@ test("a board wider than cq-lg shows every lane at once", async () => {
75
76
  ).toHaveLength(0)
76
77
  })
77
78
 
79
+ test("the row layout keeps every lane visible and grids the cards inside it", async () => {
80
+ const { canvas } = await mountStory(RowLayout)
81
+
82
+ await expect(canvas.getAllByRole("group")).toHaveLength(8)
83
+ await expect(
84
+ canvas.queryAllByRole("radiogroup"),
85
+ ).toHaveLength(0)
86
+
87
+ const repair = expectAgentDrivable(canvas, {
88
+ name: "Repair source",
89
+ role: "group",
90
+ })
91
+ const list = within(repair).getByRole("list")
92
+ const columns = getComputedStyle(list)
93
+ .gridTemplateColumns.split(" ")
94
+ .filter(Boolean)
95
+
96
+ await expect(getComputedStyle(list).display).toBe("grid")
97
+ await expect(columns.length).toBeGreaterThan(1)
98
+ await expect(
99
+ within(repair).getByText("+ 80 more in Repair source"),
100
+ ).toBeInTheDocument()
101
+ })
102
+
78
103
  /**
79
104
  * The count is the lane's **real** size, not the number of rows
80
105
  * painted. A board that truncates and then reports the truncated
@@ -42,6 +42,16 @@ export type BoardProps = {
42
42
  headingLevel?: 2 | 3 | 4 | 5
43
43
  /** The board's accessible name. Required — it is a landmark. */
44
44
  label: string
45
+ /**
46
+ * How the lanes themselves are arranged.
47
+ *
48
+ * `columns` is the workflow shape: lanes sit side by side above
49
+ * `cq-lg`, and the Narrow View selects one lane. `rows` is the
50
+ * project-pool shape: every lane is one horizontal band, its cards
51
+ * form a responsive grid inside that band, and every lane stays on
52
+ * screen at every width.
53
+ */
54
+ laneLayout?: "columns" | "rows"
45
55
  lanes: readonly BoardLane[]
46
56
  /**
47
57
  * What the move handle shows **while the lanes are side by side**.
@@ -91,9 +101,11 @@ export type BoardProps = {
91
101
  * So there are **two nested containers**, and they answer two
92
102
  * different questions:
93
103
  *
94
- * - The board's own box decides *how many lanes are on screen* —
95
- * three-up at `cq-lg` and wider, one lane plus a segmented
96
- * control below it.
104
+ * - The board's own box decides *how workflow lanes are shown* —
105
+ * side by side at `cq-lg` and wider, one lane plus a segmented
106
+ * control below it. A project-pool board with
107
+ * `laneLayout="rows"` keeps every lane on screen and grids the
108
+ * cards inside each horizontal band instead.
97
109
  * - Each lane's list decides *what shape a card is* — two lines,
98
110
  * one line, or its own card. See `BoardCard`.
99
111
  *
@@ -105,9 +117,10 @@ export type BoardProps = {
105
117
  * *"No horizontal scroll, ever. The board becomes a segmented
106
118
  * control, never a pan surface."* A board that pans sideways hides
107
119
  * lanes behind a gesture with no affordance, and on a trackpad it
108
- * fights the browser's own back-navigation. So the narrow layout
109
- * shows one lane at a time and names the others in a `radiogroup`
110
- * that says how many cards each holds.
120
+ * fights the browser's own back-navigation. So the narrow workflow
121
+ * layout shows one lane at a time and names the others in a
122
+ * `radiogroup` that says how many cards each holds. A row layout
123
+ * wraps its cards inside each lane and also never pans.
111
124
  *
112
125
  * ### Moving a card is a first-class operation, and it is not a drag
113
126
  *
@@ -137,6 +150,7 @@ export const Board = ({
137
150
  className,
138
151
  headingLevel = 3,
139
152
  label,
153
+ laneLayout = "columns",
140
154
  lanes,
141
155
  moveIcon,
142
156
  narrowLaneKey,
@@ -249,7 +263,12 @@ export const Board = ({
249
263
  * board has no stray radiogroup in it.
250
264
  */}
251
265
  <SegmentedControl
252
- className="max-w-full flex-wrap cq-lg:hidden"
266
+ className={toClassName(
267
+ "max-w-full flex-wrap",
268
+ laneLayout === "rows"
269
+ ? "hidden"
270
+ : "cq-lg:hidden",
271
+ )}
253
272
  items={lanes.map((lane) => ({
254
273
  // Stacked rather than `Todo (6)`, because the count is
255
274
  // the reason to look: a lane picker that reads
@@ -276,7 +295,13 @@ export const Board = ({
276
295
  selectedValue={visibleLaneKey}
277
296
  />
278
297
 
279
- <div className="flex flex-col gap-4 cq-lg:flex-row cq-lg:items-start">
298
+ <div
299
+ className={toClassName(
300
+ "flex flex-col gap-4",
301
+ laneLayout === "columns" &&
302
+ "cq-lg:flex-row cq-lg:items-start",
303
+ )}
304
+ >
280
305
  {lanes.map((lane) => (
281
306
  <BoardLaneList
282
307
  headingLevel={headingLevel}
@@ -290,6 +315,7 @@ export const Board = ({
290
315
  }
291
316
  key={lane.key}
292
317
  lane={lane}
318
+ laneLayout={laneLayout}
293
319
  moveIcon={moveIcon}
294
320
  moveTargets={lanes
295
321
  .filter((one) => one.key !== lane.key)
@@ -151,6 +151,12 @@ export type BoardItem = BoardItemAccent &
151
151
  BoardItemTitleLink
152
152
 
153
153
  export type BoardCardProps = {
154
+ /**
155
+ * A grid card measures its own inline size for its inner layout.
156
+ * A list card inherits the lane's measurement, which is almost
157
+ * the same width and avoids an unnecessary nested container.
158
+ */
159
+ isInGrid: boolean
154
160
  item: BoardItem
155
161
  laneLabel: string
156
162
  /**
@@ -182,25 +188,25 @@ export type BoardCardProps = {
182
188
  * One row on the board — and *row* is only what it is at some
183
189
  * widths.
184
190
  *
185
- * ### The three shapes are one component, chosen by the lane
191
+ * ### The three shapes are one component, chosen by available space
186
192
  *
187
193
  * The requirement this was built against is unusually specific, and
188
194
  * it is specific because the owner answered "which row style?" with
189
195
  * *"all three, depending"*:
190
196
  *
191
- * | The lane is | The card is |
197
+ * | The card's available width is | The card is |
192
198
  * | --- | --- |
193
199
  * | narrower than `cq-sm` (24rem) | **two lines** — title clamped to two, metadata underneath, only the trailing slot beside it |
194
200
  * | `cq-sm` to `cq-lg` | **one line** — title ellipsised, metadata right-aligned |
195
201
  * | `cq-lg` (48rem) and wider | **a card** — its own border, surface and elevation, instead of a divider in a list |
196
202
  *
197
- * Every one of those is a **container** query against the lane's
198
- * list, never a media query, and the difference is not pedantry: a
199
- * lane in a three-up board is narrow on a 4K display, and the owner
200
- * browses zoomed in, so a 1500px window at 175% zoom is ~860
201
- * effective CSS pixels. Window width is not a proxy for the space
202
- * this card has. A `@media (max-width: …)` anywhere in this file
203
- * would be a defect.
203
+ * Every one of those is a **container** query, never a media query.
204
+ * A list card reads the lane because it fills that lane. A grid card
205
+ * is its own container because several cards share a wide row. A
206
+ * lane in a three-up board is narrow on a 4K display, and a zoomed
207
+ * browser can report far fewer effective CSS pixels than its window
208
+ * has. Window width is not a proxy for the space this card has. A
209
+ * `@media (max-width: …)` anywhere in this file would be a defect.
204
210
  *
205
211
  * It also dissolved a bug rather than only satisfying a preference:
206
212
  * a *fixed* two-line row collapsed its title to `a…` inside a narrow
@@ -249,6 +255,7 @@ export type BoardCardProps = {
249
255
  * answers this question exactly backwards.
250
256
  */
251
257
  export const BoardCard = ({
258
+ isInGrid,
252
259
  item,
253
260
  laneLabel,
254
261
  moveIcon,
@@ -302,6 +309,7 @@ export const BoardCard = ({
302
309
  <li
303
310
  className={toClassName(
304
311
  "group relative flex gap-2 border-border-subtle border-b py-2 pe-1 last:border-b-0",
312
+ isInGrid && "@container",
305
313
  // At `cq-lg` the list stops being a list of rows: the
306
314
  // divider goes and each card grows its own box. The *list*
307
315
  // cannot do this to itself — it is the container, and a
@@ -89,6 +89,7 @@ export type BoardLaneListProps = {
89
89
  headingLevel: 2 | 3 | 4 | 5
90
90
  isVisibleWhenNarrow: boolean
91
91
  lane: BoardLane
92
+ laneLayout: "columns" | "rows"
92
93
  moveIcon?: ReactNode
93
94
  moveTargets: readonly { key: string; label: string }[]
94
95
  onMoveToLane?: (
@@ -144,6 +145,7 @@ export const BoardLaneList = ({
144
145
  headingLevel,
145
146
  isVisibleWhenNarrow,
146
147
  lane,
148
+ laneLayout,
147
149
  moveIcon,
148
150
  moveTargets,
149
151
  onMoveToLane,
@@ -189,7 +191,11 @@ export const BoardLaneList = ({
189
191
  // queries the **board**, not the list below — an element
190
192
  // carrying `container-type` still resolves its own container
191
193
  // queries against its nearest ancestor container.
192
- isVisibleWhenNarrow ? "flex" : "hidden cq-lg:flex",
194
+ laneLayout === "rows"
195
+ ? "flex"
196
+ : isVisibleWhenNarrow
197
+ ? "flex"
198
+ : "hidden cq-lg:flex",
193
199
  )}
194
200
  >
195
201
  {/*
@@ -201,8 +207,20 @@ export const BoardLaneList = ({
201
207
  * treatment becomes expressible.
202
208
  */}
203
209
  <div className="@container">
204
- <div className="flex flex-col gap-2 rounded-lg border border-border-subtle bg-surface-raised px-3 py-2 cq-lg:border-0 cq-lg:bg-transparent cq-lg:p-0">
205
- <header className="flex items-center gap-2">
210
+ <div
211
+ className={toClassName(
212
+ "gap-2 rounded-lg border border-border-subtle px-3 py-2",
213
+ laneLayout === "rows"
214
+ ? "grid bg-surface-raised cq-lg:grid-cols-[12rem_minmax(0,1fr)] cq-lg:items-start cq-lg:gap-x-3"
215
+ : "flex flex-col bg-surface-raised cq-lg:border-0 cq-lg:bg-transparent cq-lg:p-0",
216
+ )}
217
+ >
218
+ <header
219
+ className={toClassName(
220
+ "flex items-center gap-2",
221
+ laneLayout === "rows" && "flex-wrap",
222
+ )}
223
+ >
206
224
  <Heading
207
225
  className="font-semibold text-content-primary text-sm"
208
226
  id={headingId}
@@ -245,7 +263,12 @@ export const BoardLaneList = ({
245
263
  </header>
246
264
 
247
265
  <ul
248
- className="relative flex flex-col"
266
+ className={toClassName(
267
+ "relative",
268
+ laneLayout === "rows"
269
+ ? "grid grid-cols-[repeat(auto-fit,minmax(min(100%,14rem),1fr))] gap-2"
270
+ : "flex flex-col",
271
+ )}
249
272
  ref={registerListElement}
250
273
  >
251
274
  {lane.items.length === 0 ? (
@@ -266,6 +289,7 @@ export const BoardLaneList = ({
266
289
  ) : (
267
290
  lane.items.map((item, index) => (
268
291
  <BoardCard
292
+ isInGrid={laneLayout === "rows"}
269
293
  item={item}
270
294
  key={item.key}
271
295
  laneLabel={lane.label}
@@ -304,7 +328,13 @@ export const BoardLaneList = ({
304
328
  </ul>
305
329
 
306
330
  {hiddenCount > 0 ? (
307
- <p className="text-content-muted text-xs">
331
+ <p
332
+ className={toClassName(
333
+ "text-content-muted text-xs",
334
+ laneLayout === "rows" &&
335
+ "cq-lg:col-start-2",
336
+ )}
337
+ >
308
338
  {lane.onShowMore ? (
309
339
  <Button
310
340
  appearance="ghost"
@@ -114,7 +114,7 @@ A consumer that must swap the whole document — Docket opening a different task
114
114
  with a `key`. That is one line, and it is honest about the fact that the old undo history
115
115
  belonged to the old document.
116
116
 
117
- ## Paste an image at the cursor
117
+ ## Put an image at the cursor — paste, drop, or press **Image**
118
118
 
119
119
  ```tsx
120
120
  <MarkdownEditor
@@ -137,8 +137,35 @@ during an upload and the insertion point has to move with what they type. It car
137
137
  counter so two files of the same name do not resolve into each other's slot, and a failed
138
138
  upload leaves a document the user can read and fix rather than a dangling widget.
139
139
 
140
- Omit `onUploadImage` and image paste is simply not offered. A `<textarea>` ignores a pasted
141
- image on its own, which is the correct fallback rather than a broken one.
140
+ Omit `onUploadImage` and none of the three is offered — no toolbar button, and a paste left
141
+ to the browser. A `<textarea>` ignores a pasted image on its own, which is the correct
142
+ fallback rather than a broken one.
143
+
144
+ ### The **Image** button, and why it is in the toolbar
145
+
146
+ Paste and drop are both mouse-and-keyboard gestures. On a tablet there is nothing to paste
147
+ from and nothing to drag, so wiring `onUploadImage` also puts an **Image** action in the
148
+ toolbar: it opens the platform file picker, which on a phone is where the camera is offered.
149
+
150
+ It is a toolbar action rather than a `FileDropZone` under the box **because of the caret**.
151
+ Pressing anything outside the editor moves focus out of it, so a control beside the editor
152
+ can only append to the end of the document — and an image in prose belongs at the point it
153
+ is explained, not stapled to the bottom. The `<textarea>` keeps its `selectionStart` while
154
+ blurred, so the insertion lands where the user last was.
155
+
156
+ **It is the fourth action, and the position is load-bearing.** `Toolbar` overflows from the
157
+ **end** of the list, so the order in that list is the priority order. Placed last — beside
158
+ Link, where it reads most naturally — Image was the first thing to collapse, hidden behind
159
+ "More actions" at 900px. A control nobody can find does not fix "adding an image is buried";
160
+ it moves the problem. Fourth is visible wherever four actions fit, and it reads as a group:
161
+ emphasis, structure, insert.
162
+
163
+ ⚠️ **It still collapses on a genuinely narrow editor, and that is not a bug to route around.**
164
+ Measured on the `Interactive` story: at 900px the bar carries eight actions and Image is one
165
+ of them; at 460px it carries three, and everything after Heading is in the overflow. A
166
+ comment box in a rail is that width. Keep a `FileDropZone` beside such an editor as well —
167
+ appending to the end of the document is the right trade when the alternative is a control
168
+ nobody can reach.
142
169
 
143
170
  ## Composition, and the one component that did not fit
144
171
 
@@ -152,7 +179,8 @@ component.
152
179
  `<label>` around a file input covering its whole area — correct for "drop a disc image here",
153
180
  and wrong for a text surface, where the drop has to land *at a character offset* and the area
154
181
  has to keep taking clicks as caret placement. So the editor handles its own drop, and
155
- `FileDropZone` stays the right answer for the attachment list beside it.
182
+ `FileDropZone` stays the right answer for the attachment list beside it — and for a
183
+ narrow editor whose toolbar has collapsed the Image action away.
156
184
 
157
185
  <Canvas of={MarkdownEditorStories.Responsive} />
158
186
 
@@ -625,3 +625,111 @@ test("the hint stays described when the container is narrow", async () => {
625
625
 
626
626
  await expectNoAxeViolations(canvasElement)
627
627
  })
628
+
629
+ /**
630
+ * THE IMAGE BUTTON, and the reason it is in the toolbar rather
631
+ * than beside the editor.
632
+ *
633
+ * The editor already took a paste and a drop. Neither exists on a
634
+ * tablet, so the only way in there was a `FileDropZone` under the
635
+ * box — which appends to the END of the document, because pressing
636
+ * it moves focus out of the editor. An image in prose belongs at
637
+ * the point it is explained, so the control has to be one the
638
+ * editor owns.
639
+ *
640
+ * ⚠️ It is the FOURTH action, before the lists, because `Toolbar`
641
+ * overflows from the end and last put it behind "More actions" at
642
+ * 900px. It still collapses on a narrow enough surface, so a test
643
+ * cannot assume the button is on the bar.
644
+ */
645
+ const openImageButton = async (
646
+ canvas: Canvas,
647
+ body: Canvas,
648
+ ) => {
649
+ const onTheBar = canvas.queryByRole("button", {
650
+ name: "Image",
651
+ })
652
+
653
+ if (onTheBar) {
654
+ return onTheBar
655
+ }
656
+
657
+ await userEvent.click(
658
+ canvas.getByRole("button", { name: "More actions" }),
659
+ )
660
+
661
+ // The menu is PORTALLED, so it is on `body` and not inside the
662
+ // story's canvas.
663
+ return body.getByRole("menuitem", { name: "Image" })
664
+ }
665
+
666
+ test("the toolbar offers Image when uploads are wired", async () => {
667
+ const { body, canvas } = await mountStory(Interactive)
668
+
669
+ await expect(
670
+ await openImageButton(canvas, body),
671
+ ).not.toBeNull()
672
+ })
673
+
674
+ /** A button that opens a file picker and then has nowhere to send
675
+ * the file is worse than no button. */
676
+ test("the toolbar hides Image when nothing can take the file", async () => {
677
+ const { body, canvas } = await mountStory(NoUpload)
678
+
679
+ const overflow = canvas.queryByRole("button", {
680
+ name: "More actions",
681
+ })
682
+
683
+ if (overflow) {
684
+ await userEvent.click(overflow)
685
+ }
686
+
687
+ await expect(
688
+ body.queryByRole("button", { name: "Image" }),
689
+ ).toBeNull()
690
+
691
+ await expect(
692
+ body.queryByRole("menuitem", { name: "Image" }),
693
+ ).toBeNull()
694
+ })
695
+
696
+ test("a picked file uploads and lands at the caret", async () => {
697
+ const { body, canvas } = await mountStory(Interactive)
698
+
699
+ const editor = getEditor(canvas, "Task description")
700
+
701
+ await userEvent.click(editor)
702
+
703
+ editor.setSelectionRange(0, 0)
704
+
705
+ await userEvent.click(await openImageButton(canvas, body))
706
+
707
+ const picker = canvas
708
+ .getByRole("toolbar")
709
+ .parentElement?.querySelector(
710
+ 'input[type="file"]',
711
+ ) as HTMLInputElement
712
+
713
+ await expect(picker).not.toBeNull()
714
+
715
+ // The picker itself is the browser's; what this drives is the
716
+ // `change` it fires on the way back.
717
+ await userEvent.upload(
718
+ picker,
719
+ new File(["binary"], "jig.png", { type: "image/png" }),
720
+ )
721
+
722
+ await waitFor(() => {
723
+ expect(editor.value).toContain(
724
+ "![jig.png](https://example.invalid/blobs/jig.png)",
725
+ )
726
+ })
727
+
728
+ // At the caret — offset 0 — not appended to the end.
729
+ await expect(editor.value.startsWith("![jig.png](")).toBe(
730
+ true,
731
+ )
732
+
733
+ // And it is still markdown, with no tag anywhere.
734
+ await expect(editor.value).not.toContain("<img")
735
+ })
@@ -63,6 +63,7 @@ export type MarkdownEditorIcons = Partial<
63
63
  | "bulletedList"
64
64
  | "code"
65
65
  | "heading"
66
+ | "image"
66
67
  | "italic"
67
68
  | "link"
68
69
  | "numberedList"
@@ -286,6 +287,12 @@ export const MarkdownEditor = ({
286
287
 
287
288
  const layerRef = useRef<HTMLDivElement>(null)
288
289
 
290
+ /** The toolbar's Image button presses this. It is a real
291
+ * `<input type="file">` because nothing else opens a file
292
+ * picker, and it is the only way in on a touch device — there
293
+ * is nothing to paste from and nothing to drag. */
294
+ const imageInputRef = useRef<HTMLInputElement>(null)
295
+
289
296
  const uploadCount = useRef(0)
290
297
 
291
298
  const [value, setValue] = useState(defaultValue)
@@ -674,6 +681,54 @@ export const MarkdownEditor = ({
674
681
  )
675
682
  },
676
683
  },
684
+ /*
685
+ IMAGE, and only when the consumer can actually take
686
+ one. A button that opens a file picker and then has
687
+ nowhere to send the file is worse than no button.
688
+
689
+ Spread rather than a `filter(Boolean)` so the array
690
+ stays a `ToolbarItem[]` without a cast.
691
+
692
+ ⚠️ FOURTH, and the position is load-bearing rather
693
+ than aesthetic. `Toolbar` overflows from the END of
694
+ this list, so the order IS the priority order. Put
695
+ last, beside Link where it reads most naturally, and
696
+ Image was the first thing to collapse into "More
697
+ actions" — at 900px it was already hidden, and in a
698
+ comment box in a rail it never appeared at all. That
699
+ is the complaint this button exists to answer:
700
+ *"adding an image is way at the bottom"*. Hiding it
701
+ behind an overflow trigger moves the problem rather
702
+ than fixing it.
703
+
704
+ Fourth is visible wherever four actions fit — at
705
+ 900px the bar carries eight. It still collapses on a
706
+ genuinely narrow editor (at 460px the bar carries
707
+ three), which is why a comment box in a rail keeps a
708
+ `FileDropZone` beside it. It also reads: emphasis,
709
+ then structure, then insert.
710
+
711
+ The caret is why this belongs in the toolbar at all
712
+ rather than beside the editor. A `FileDropZone` under
713
+ the box appends to the end of the document, because
714
+ pressing it moves focus out; the textarea keeps its
715
+ `selectionStart` while blurred, so the insertion
716
+ lands where the user last was — which is the whole
717
+ point of an image in prose.
718
+ */
719
+ ...(onUploadImage
720
+ ? [
721
+ {
722
+ icon: icons?.image,
723
+ isDisabled: isDisabled || isReadOnly,
724
+ key: "image",
725
+ label: "Image",
726
+ onSelect: () => {
727
+ imageInputRef.current?.click()
728
+ },
729
+ },
730
+ ]
731
+ : []),
677
732
  {
678
733
  icon: icons?.bulletedList,
679
734
  isDisabled: isDisabled || isReadOnly,
@@ -755,6 +810,40 @@ export const MarkdownEditor = ({
755
810
  overflow="menu"
756
811
  overflowIcon={icons?.overflow}
757
812
  />
813
+
814
+ {/*
815
+ The Image button's actual control.
816
+
817
+ `hidden`, not `sr-only`: `display: none` takes it out of
818
+ the accessibility tree, which is what is wanted here —
819
+ the toolbar button is the named control, and a second
820
+ unlabelled file input announced beside it would be one
821
+ control too many. `.click()` on a hidden input opens the
822
+ picker in every browser the fleet runs.
823
+
824
+ `multiple`, because a drop already takes several and the
825
+ two ways in should not disagree. The value is cleared
826
+ after each pick so choosing the SAME file twice fires
827
+ `change` the second time.
828
+ */}
829
+ {onUploadImage ? (
830
+ <input
831
+ accept="image/*"
832
+ aria-hidden="true"
833
+ className="hidden"
834
+ multiple
835
+ onChange={(changeEvent) => {
836
+ uploadImages(
837
+ Array.from(changeEvent.target.files ?? []),
838
+ )
839
+
840
+ changeEvent.target.value = ""
841
+ }}
842
+ ref={imageInputRef}
843
+ tabIndex={-1}
844
+ type="file"
845
+ />
846
+ ) : null}
758
847
  </div>
759
848
 
760
849
  <div className="grid">
@@ -295,6 +295,16 @@ export const LinkIcon = (): ReactNode => (
295
295
  </svg>
296
296
  )
297
297
 
298
+ export const ImageIcon = (): ReactNode => (
299
+ <svg {...iconProps} aria-hidden="true">
300
+ <rect height="16" rx="2" width="18" x="3" y="4" />
301
+
302
+ <path d="M3 15l5-5 4 4 3-3 6 6" />
303
+
304
+ <circle cx="8.5" cy="9" r="1.2" />
305
+ </svg>
306
+ )
307
+
298
308
  /**
299
309
  * The whole `MarkdownEditorIcons` bag, ready to spread into either
300
310
  * editor's `icons` prop.
@@ -304,6 +314,7 @@ export const MARKDOWN_ICONS = {
304
314
  bulletedList: <BulletedListIcon />,
305
315
  code: <CodeIcon />,
306
316
  heading: <HeadingIcon />,
317
+ image: <ImageIcon />,
307
318
  italic: <ItalicIcon />,
308
319
  link: <LinkIcon />,
309
320
  numberedList: <NumberedListIcon />,