@charcuterie/ui 3.26.0 → 3.28.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 (42) 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 +18 -12
  7. package/dist/Board/BoardCard.d.ts.map +1 -1
  8. package/dist/Board/BoardCard.js +12 -12
  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/Stepper/Stepper.d.ts +10 -1
  19. package/dist/Stepper/Stepper.d.ts.map +1 -1
  20. package/dist/Stepper/Stepper.js +25 -5
  21. package/dist/Stepper/Stepper.js.map +1 -1
  22. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -1
  23. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +135 -85
  24. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -1
  25. package/package.json +1 -1
  26. package/src/Board/Board.mdx +12 -0
  27. package/src/Board/Board.stories.tsx +48 -0
  28. package/src/Board/Board.test.tsx +43 -0
  29. package/src/Board/Board.tsx +34 -8
  30. package/src/Board/BoardCard.tsx +26 -16
  31. package/src/Board/BoardLaneList.tsx +35 -5
  32. package/src/MarkdownEditor/MarkdownEditor.mdx +32 -4
  33. package/src/MarkdownEditor/MarkdownEditor.test.tsx +108 -0
  34. package/src/MarkdownEditor/MarkdownEditor.tsx +89 -0
  35. package/src/Stepper/Stepper.stories.tsx +27 -0
  36. package/src/Stepper/Stepper.test.tsx +17 -2
  37. package/src/Stepper/Stepper.tsx +51 -8
  38. package/src/icons.storyHelpers.tsx +11 -0
  39. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +3 -1
  40. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.stories.tsx +31 -0
  41. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.test.tsx +109 -1
  42. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +84 -0
@@ -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">
@@ -170,6 +170,33 @@ export const Responsive: Story = {
170
170
  ),
171
171
  }
172
172
 
173
+ /** Short labels can opt out of the collapse when the sequence is a
174
+ * compact status ladder inside a card. */
175
+ export const CompactCard: Story = {
176
+ args: {
177
+ appearance: "compact",
178
+ isResponsive: false,
179
+ orientation: "horizontal",
180
+ steps: [
181
+ { key: "ordered", label: "Ordered", status: "done" },
182
+ { key: "shipped", label: "Shipped", status: "done" },
183
+ { key: "out", label: "Out today", status: "current" },
184
+ {
185
+ key: "delivered",
186
+ label: "Delivered",
187
+ status: "upcoming",
188
+ },
189
+ ],
190
+ },
191
+ render: (stepperProps) => (
192
+ <div className="w-[22rem] max-w-full">
193
+ <Card>
194
+ <Stepper {...stepperProps} />
195
+ </Card>
196
+ </div>
197
+ ),
198
+ }
199
+
173
200
  /**
174
201
  * The whole keyboard path, which is the point of the headings: a
175
202
  * stepper is a document outline, not a widget. There is nothing to
@@ -6,8 +6,13 @@ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
6
  import { mountStory } from "../mountStory.testHelpers.ts"
7
7
  import * as stories from "./Stepper.stories.tsx"
8
8
 
9
- const { AllStates, Default, Interactive, Responsive } =
10
- composeStories(stories)
9
+ const {
10
+ AllStates,
11
+ CompactCard,
12
+ Default,
13
+ Interactive,
14
+ Responsive,
15
+ } = composeStories(stories)
11
16
 
12
17
  test("the sequence is an ordered list with a name", async () => {
13
18
  const { canvas } = await mountStory(Default)
@@ -127,3 +132,13 @@ test("no axe violations, in either orientation", async () => {
127
132
 
128
133
  await expectNoAxeViolations(horizontal.canvasElement)
129
134
  })
135
+
136
+ test("a compact card can keep the horizontal ladder below cq-md", async () => {
137
+ const { canvas } = await mountStory(CompactCard)
138
+
139
+ await expect(
140
+ getComputedStyle(
141
+ canvas.getByRole("list", { name: "Ingest sequence" }),
142
+ ).flexDirection,
143
+ ).toBe("row")
144
+ })
@@ -43,6 +43,8 @@ export type Step = {
43
43
  export type StepperOrientation = "horizontal" | "vertical"
44
44
 
45
45
  export type StepperProps = {
46
+ /** `compact` is the dot-and-line ladder for short status labels in cards. */
47
+ readonly appearance?: "default" | "compact"
46
48
  readonly className?: string
47
49
  /**
48
50
  * What level the step labels are. The caller's, because only the
@@ -55,6 +57,13 @@ export type StepperProps = {
55
57
  /** The list's accessible name. Required — an unnamed sequence of
56
58
  * numbers is a sequence of numbers. */
57
59
  readonly label: string
60
+ /**
61
+ * Whether a requested horizontal sequence collapses below `cq-md`.
62
+ * Keep this on for prose-sized steps. Turn it off only when the
63
+ * caller supplies short labels that are designed to remain a compact
64
+ * ladder inside a card.
65
+ */
66
+ readonly isResponsive?: boolean
58
67
  /**
59
68
  * `horizontal` is a REQUEST, not a promise: it collapses to
60
69
  * vertical below `cq-md`, because four steps side by side in a
@@ -99,14 +108,17 @@ export type StepperProps = {
99
108
  * `:not(:last-child)` selector nobody remembers to write.
100
109
  */
101
110
  export const Stepper = ({
111
+ appearance = "default",
102
112
  className,
103
113
  headingLevel = 3,
114
+ isResponsive = true,
104
115
  label,
105
116
  orientation = "vertical",
106
117
  steps,
107
118
  }: StepperProps) => {
108
119
  const Heading = `h${headingLevel}` as "h3"
109
120
  const isHorizontal = orientation === "horizontal"
121
+ const isCompact = appearance === "compact"
110
122
 
111
123
  return (
112
124
  // The container is a WRAPPER, never the `<ol>` itself.
@@ -131,7 +143,10 @@ export const Stepper = ({
131
143
  // `Board` follows and for the same reason: the owner
132
144
  // browses zoomed in, so a window's width is not the
133
145
  // number of pixels anything actually has.
134
- isHorizontal && "cq-md:flex-row cq-md:gap-0",
146
+ isHorizontal &&
147
+ (isResponsive
148
+ ? "cq-md:flex-row cq-md:gap-0"
149
+ : "flex-row gap-0"),
135
150
  )}
136
151
  >
137
152
  {steps.map((step, index) => {
@@ -163,7 +178,10 @@ export const Stepper = ({
163
178
  className={toClassName(
164
179
  "relative grid grid-cols-[auto_minmax(0,1fr)] grid-rows-[auto_minmax(0,1fr)] gap-x-3",
165
180
  isHorizontal &&
166
- "cq-md:flex cq-md:min-w-0 cq-md:flex-1 cq-md:flex-col cq-md:gap-y-2",
181
+ (isResponsive
182
+ ? "cq-md:flex cq-md:min-w-0 cq-md:flex-1 cq-md:flex-col cq-md:gap-y-2"
183
+ : "flex min-w-0 flex-1 flex-col gap-y-2"),
184
+ isCompact && "gap-y-1",
167
185
  // Blocked and upcoming recede; the step you can act
168
186
  // on is the one that keeps full contrast. Opacity
169
187
  // rather than a muted token so the step's own
@@ -177,12 +195,23 @@ export const Stepper = ({
177
195
  >
178
196
  <span
179
197
  className={toClassName(
180
- "col-start-1 row-start-1 grid size-7 place-items-center rounded-full border text-sm tabular-nums",
198
+ isCompact
199
+ ? "col-start-1 row-start-1 grid size-3 place-items-center rounded-full border text-xs tabular-nums"
200
+ : "col-start-1 row-start-1 grid size-7 place-items-center rounded-full border text-sm tabular-nums",
181
201
  STATUS_MARKER_CLASS[status],
182
- isHorizontal && "cq-md:col-start-1",
202
+ isHorizontal &&
203
+ (isResponsive
204
+ ? "cq-md:col-start-1"
205
+ : "col-start-1"),
183
206
  )}
184
207
  >
185
- {step.marker ?? index + 1}
208
+ {isCompact ? (
209
+ <VisuallyHidden>
210
+ {step.marker ?? index + 1}
211
+ </VisuallyHidden>
212
+ ) : (
213
+ (step.marker ?? index + 1)
214
+ )}
186
215
  </span>
187
216
 
188
217
  {/* Absent on the last step, so nothing draws a line
@@ -199,7 +228,13 @@ export const Stepper = ({
199
228
  ? "bg-intent-success-solid"
200
229
  : "bg-border-subtle",
201
230
  isHorizontal &&
202
- "cq-md:absolute cq-md:top-3.5 cq-md:start-7 cq-md:my-0 cq-md:h-0.5 cq-md:w-[calc(100%-1.75rem)]",
231
+ (isCompact
232
+ ? isResponsive
233
+ ? "cq-md:absolute cq-md:top-1.5 cq-md:start-3 cq-md:my-0 cq-md:h-0.5 cq-md:w-[calc(100%-0.75rem)]"
234
+ : "absolute top-1.5 start-3 my-0 h-0.5 w-[calc(100%-0.75rem)]"
235
+ : isResponsive
236
+ ? "cq-md:absolute cq-md:top-3.5 cq-md:start-7 cq-md:my-0 cq-md:h-0.5 cq-md:w-[calc(100%-1.75rem)]"
237
+ : "absolute top-3.5 start-7 my-0 h-0.5 w-[calc(100%-1.75rem)]"),
203
238
  )}
204
239
  />
205
240
  )}
@@ -207,10 +242,18 @@ export const Stepper = ({
207
242
  <div
208
243
  className={toClassName(
209
244
  "col-start-2 row-span-2 row-start-1 min-w-0",
210
- isHorizontal && "cq-md:col-start-1",
245
+ isHorizontal &&
246
+ (isResponsive
247
+ ? "cq-md:col-start-1"
248
+ : "col-start-1"),
211
249
  )}
212
250
  >
213
- <Heading className="m-0 flex flex-wrap items-center gap-2 text-md font-medium text-content-primary">
251
+ <Heading
252
+ className={toClassName(
253
+ "m-0 flex flex-wrap items-center gap-2 font-medium text-content-primary",
254
+ isCompact ? "text-xs" : "text-md",
255
+ )}
256
+ >
214
257
  {step.label}
215
258
 
216
259
  {/* The status as a WORD. Four marker colours is
@@ -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 />,
@@ -148,7 +148,9 @@ from either edge, or flip the Markdown source toggle, which is exactly what it i
148
148
 
149
149
  ## Pasting
150
150
 
151
- - **An image** (or a drop) runs `onUploadImage` and inserts `![alt](url)` at the caret, then
151
+ - **An image** — pasted, dropped, or picked with the toolbar's **Image** action, which
152
+ appears only when `onUploadImage` is wired and is the only way in on a tablet — runs
153
+ `onUploadImage` and inserts `![alt](url)` at the caret, then
152
154
  renders it in place. The component does not own storage — it hands you a `File`.
153
155
  - **A URL over selected text** becomes `[selection](url)`, the way it does in every other
154
156
  editor. Deliberately strict: one absolute `http(s)` URL and nothing else, because mangling
@@ -242,3 +242,34 @@ export const IconsVersusWords: Story = {
242
242
  </StoryGrid>
243
243
  ),
244
244
  }
245
+
246
+ /**
247
+ * With an upload wired, so the **Image** toolbar action appears.
248
+ *
249
+ * The editor already took a paste and a drop; neither exists on a
250
+ * tablet, and a `FileDropZone` beside the box appends to the end of
251
+ * the document because pressing it moves focus out. The toolbar
252
+ * button keeps the insertion at the caret, which is the whole point
253
+ * of an image in prose.
254
+ *
255
+ * The upload here is a fake that resolves after a beat, so the
256
+ * placeholder is visible while it runs. A real consumer returns a
257
+ * URL from its own blob storage.
258
+ */
259
+ export const Uploading: Story = {
260
+ args: {
261
+ defaultValue:
262
+ "Press Image, or paste one, and it lands where the caret is.\n",
263
+ label: "Task description",
264
+ onUploadImage: async (file: File) => {
265
+ await new Promise((resolve) => {
266
+ setTimeout(resolve, 600)
267
+ })
268
+
269
+ return {
270
+ alt: file.name,
271
+ url: `https://example.invalid/blobs/${encodeURIComponent(file.name)}`,
272
+ }
273
+ },
274
+ },
275
+ }