@charcuterie/ui 3.25.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 +23 -10
  7. package/dist/Board/BoardCard.d.ts.map +1 -1
  8. package/dist/Board/BoardCard.js +44 -22
  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 +17 -0
  23. package/src/Board/Board.stories.tsx +88 -0
  24. package/src/Board/Board.test.tsx +66 -0
  25. package/src/Board/Board.tsx +34 -8
  26. package/src/Board/BoardCard.tsx +66 -23
  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
@@ -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 />,
@@ -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
+ }
@@ -5,9 +5,13 @@ import { test } from "vitest"
5
5
  import { mountStory } from "../mountStory.testHelpers.ts"
6
6
  import * as stories from "./MarkdownEditorCodeMirror.stories.tsx"
7
7
 
8
- const { AllStates, CaretReveal, Default } =
8
+ const { AllStates, CaretReveal, Default, Uploading } =
9
9
  composeStories(stories)
10
10
 
11
+ type Canvas = Awaited<
12
+ ReturnType<typeof mountStory>
13
+ >["canvas"]
14
+
11
15
  /**
12
16
  * The two ways this surface stopped behaving like the `<textarea>`
13
17
  * it replaced. Both are invisible to every other gate: the stories
@@ -183,3 +187,107 @@ test("a document taller than the frame grows it", async () => {
183
187
  scroller.clientHeight,
184
188
  )
185
189
  })
190
+
191
+ /**
192
+ * THE IMAGE BUTTON, on the CodeMirror surface.
193
+ *
194
+ * Same control and same reason as the `<textarea>` sibling's: paste
195
+ * and drop do not exist on a tablet, and a `FileDropZone` beside the
196
+ * editor appends to the END of the document because pressing it
197
+ * moves focus out. Here the insertion goes through
198
+ * `readSelection(view)`, so it lands at the caret — CodeMirror keeps
199
+ * its selection while the file picker is open.
200
+ *
201
+ * ⚠️ Image is the FOURTH action, before the lists, because
202
+ * `Toolbar` overflows from the end and last put it behind the
203
+ * overflow trigger at 900px. It still collapses on a narrow enough
204
+ * surface, so a test cannot assume it is on the bar.
205
+ */
206
+ const openImageButton = async (
207
+ canvas: Canvas,
208
+ body: Canvas,
209
+ ) => {
210
+ const onTheBar = canvas.queryByRole("button", {
211
+ name: "Image",
212
+ })
213
+
214
+ if (onTheBar) {
215
+ return onTheBar
216
+ }
217
+
218
+ await userEvent.click(
219
+ canvas.getByRole("button", { name: "More actions" }),
220
+ )
221
+
222
+ // `overflow="panel"` here rather than `menu`, and the panel is
223
+ // PORTALLED — so it is on `body`, not inside the story canvas.
224
+ return body.getByRole("button", { name: "Image" })
225
+ }
226
+
227
+ test("the toolbar offers Image when uploads are wired", async () => {
228
+ const { body, canvas } = await mountStory(Uploading)
229
+
230
+ await expect(
231
+ await openImageButton(canvas, body),
232
+ ).not.toBeNull()
233
+ })
234
+
235
+ /** A button that opens a file picker and then has nowhere to send
236
+ * the file is worse than no button. */
237
+ test("the toolbar hides Image when nothing can take the file", async () => {
238
+ const { body, canvas } = await mountStory(Default)
239
+
240
+ const overflow = canvas.queryByRole("button", {
241
+ name: "More actions",
242
+ })
243
+
244
+ if (overflow) {
245
+ await userEvent.click(overflow)
246
+ }
247
+
248
+ await expect(
249
+ body.queryByRole("button", { name: "Image" }),
250
+ ).toBeNull()
251
+ })
252
+
253
+ test("a picked file uploads and lands in the document", async () => {
254
+ const { body, canvas } = await mountStory(Uploading)
255
+
256
+ await userEvent.click(await openImageButton(canvas, body))
257
+
258
+ const picker = canvas
259
+ .getByRole("toolbar")
260
+ .parentElement?.querySelector(
261
+ 'input[type="file"]',
262
+ ) as HTMLInputElement
263
+
264
+ await expect(picker).not.toBeNull()
265
+
266
+ // The picker itself is the browser's; what this drives is the
267
+ // `change` it fires on the way back.
268
+ await userEvent.upload(
269
+ picker,
270
+ new File(["binary"], "jig.png", { type: "image/png" }),
271
+ )
272
+
273
+ /*
274
+ The document is CodeMirror's, so the assertion reads the
275
+ editing surface's text rather than a `value`.
276
+
277
+ NOT `getByRole("img")`, which is what this first tried: live
278
+ preview reveals a construct's markers while the caret is inside
279
+ it, and the caret lands immediately after the insertion — so
280
+ the image is markdown on screen at exactly this moment and
281
+ becomes a picture when the caret leaves. That is the surface
282
+ working, not failing.
283
+ */
284
+ await waitFor(() => {
285
+ expect(
286
+ canvas.getByRole("textbox", {
287
+ name: "Task description",
288
+ }).textContent,
289
+ ).toContain(
290
+ "![jig.png](https://example.invalid/blobs/jig.png)",
291
+ )
292
+ })
293
+ })
@@ -300,6 +300,12 @@ export const MarkdownEditorCodeMirror = ({
300
300
 
301
301
  const uploadCount = useRef(0)
302
302
 
303
+ /** The toolbar's Image button presses this. A real
304
+ * `<input type="file">` because nothing else opens a file
305
+ * picker, and it is the only way in on a touch device — there
306
+ * is nothing to paste from and nothing to drag. */
307
+ const imageInputRef = useRef<HTMLInputElement>(null)
308
+
303
309
  const editabilityRef = useRef(new Compartment())
304
310
 
305
311
  const [isRawMode, setIsRawMode] = useState(
@@ -732,6 +738,34 @@ export const MarkdownEditorCodeMirror = ({
732
738
  )
733
739
  },
734
740
  },
741
+ /*
742
+ IMAGE, and only when the consumer can actually take
743
+ one. A button that opens a file picker and then has
744
+ nowhere to send the file is worse than no button.
745
+
746
+ Spread rather than a `filter(Boolean)`, so the array
747
+ stays a `ToolbarItem[]` without a cast.
748
+
749
+ ⚠️ FOURTH, and the position is load-bearing rather
750
+ than aesthetic — same reason as the textarea
751
+ sibling's. `Toolbar` overflows from the END, so the
752
+ order IS the priority order, and last put Image
753
+ behind "More actions" at 900px. The whole point of
754
+ the button is that adding an image is not buried.
755
+ */
756
+ ...(onUploadImage
757
+ ? [
758
+ {
759
+ icon: icons?.image,
760
+ isDisabled: isDisabled || isReadOnly,
761
+ key: "image",
762
+ label: "Image",
763
+ onSelect: () => {
764
+ imageInputRef.current?.click()
765
+ },
766
+ },
767
+ ]
768
+ : []),
735
769
  {
736
770
  icon: icons?.bulletedList,
737
771
  isDisabled: isDisabled || isReadOnly,
@@ -832,6 +866,56 @@ export const MarkdownEditorCodeMirror = ({
832
866
  overflow="panel"
833
867
  overflowIcon={icons?.overflow}
834
868
  />
869
+
870
+ {/*
871
+ The Image button's actual control.
872
+
873
+ `hidden`, not `sr-only`: `display: none` takes it out of
874
+ the accessibility tree, which is what is wanted — the
875
+ toolbar button is the named control, and a second
876
+ unlabelled file input announced beside it would be one
877
+ control too many.
878
+
879
+ The insertion lands at the CARET, not at the end of the
880
+ document, because `readSelection(view)` reads
881
+ CodeMirror's own selection and the view keeps it while
882
+ the picker is open. That is the whole reason this is a
883
+ toolbar button rather than a drop zone under the box.
884
+ */}
885
+ {onUploadImage ? (
886
+ <input
887
+ accept="image/*"
888
+ aria-hidden="true"
889
+ className="hidden"
890
+ multiple
891
+ onChange={(changeEvent) => {
892
+ const view = viewRef.current
893
+
894
+ if (view) {
895
+ for (const file of Array.from(
896
+ changeEvent.target.files ?? [],
897
+ ).filter(isImageFile)) {
898
+ uploadCount.current += 1
899
+
900
+ void uploadImage({
901
+ count: uploadCount.current,
902
+ file,
903
+ handlers: handlersRef.current,
904
+ setMessage: setUploadMessage,
905
+ view,
906
+ })
907
+ }
908
+ }
909
+
910
+ // Cleared so choosing the SAME file twice fires
911
+ // `change` the second time.
912
+ changeEvent.target.value = ""
913
+ }}
914
+ ref={imageInputRef}
915
+ tabIndex={-1}
916
+ type="file"
917
+ />
918
+ ) : null}
835
919
  </div>
836
920
 
837
921
  {/*