@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
@@ -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
  {/*