@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,862 @@
1
+ import { useUniqueId } from "@charcuterie/logic"
2
+ import type {
3
+ ClipboardEvent,
4
+ DragEvent,
5
+ KeyboardEvent,
6
+ ReactNode,
7
+ } from "react"
8
+ import { Fragment, useRef, useState } from "react"
9
+
10
+ import type { SlotProps } from "../slotProps.ts"
11
+ import { mergeSlotProps } from "../slotProps.ts"
12
+ import { Toolbar } from "../Toolbar/Toolbar.tsx"
13
+ import { toClassName } from "../toClassName.ts"
14
+ import type { MarkdownSelection } from "./markdownCommands.ts"
15
+ import {
16
+ continueList,
17
+ getMinimalEdit,
18
+ indentLines,
19
+ insertLink,
20
+ insertText,
21
+ MARKDOWN_LINE_PREFIXES,
22
+ outdentLines,
23
+ toggleHeading,
24
+ toggleInlineMarker,
25
+ toggleLinePrefix,
26
+ toMarkdownImage,
27
+ } from "./markdownCommands.ts"
28
+ import type { MarkdownSpanKind } from "./markdownSpans.ts"
29
+ import {
30
+ toLineIndex,
31
+ toMarkdownLines,
32
+ } from "./markdownSpans.ts"
33
+
34
+ export type MarkdownImageUpload = {
35
+ /** Alt text. Defaults to the file's name when omitted. */
36
+ alt?: string
37
+ /** Where the image now lives. Inserted verbatim. */
38
+ url: string
39
+ }
40
+
41
+ /**
42
+ * The toolbar's glyphs, by action.
43
+ *
44
+ * **No default, and none shipped** — this package ships no icons,
45
+ * and a `⋯`/`▨` default renders as nothing where the font lacks it
46
+ * ([decision](../../../../docs/decisions/2026-07-29-ship-no-icons-and-no-symbol-glyphs.md)).
47
+ * With no icons the toolbar is nine word buttons, which works
48
+ * everywhere and collapses into the overflow menu early; an app
49
+ * that brings lucide gets a compact bar and keeps the words as the
50
+ * accessible names.
51
+ */
52
+ export type MarkdownEditorIcons = Partial<
53
+ Record<
54
+ | "bold"
55
+ | "bulletedList"
56
+ | "code"
57
+ | "heading"
58
+ | "italic"
59
+ | "link"
60
+ | "numberedList"
61
+ | "quote"
62
+ | "taskList",
63
+ ReactNode
64
+ >
65
+ >
66
+
67
+ export type MarkdownEditorProps = SlotProps & {
68
+ className?: string
69
+ /**
70
+ * **Initial** only. This control is uncontrolled, and the long
71
+ * reason is in the doc comment below — it is the whole design.
72
+ */
73
+ defaultValue?: string
74
+ /** See `MarkdownEditorIcons`. */
75
+ icons?: MarkdownEditorIcons
76
+ isDisabled?: boolean
77
+ isReadOnly?: boolean
78
+ /**
79
+ * The accessible name, as `aria-label`. Omit it inside a `Field`,
80
+ * which supplies a real `<label htmlFor>` — same contract as
81
+ * `Select`.
82
+ */
83
+ label?: string
84
+ onChange?: (value: string) => void
85
+ /**
86
+ * A failed upload, after the placeholder has been taken back out
87
+ * of the text. Pair it with a `Toast`; this component does not
88
+ * own one.
89
+ */
90
+ onUploadError?: (file: File, error: unknown) => void
91
+ /**
92
+ * Paste or drop an image and this runs. **The component does not
93
+ * own storage** — it hands you a `File`, you put it somewhere,
94
+ * and the URL you return is inserted at the caret as
95
+ * `![alt](url)`. Docket keeps its own blob storage precisely so
96
+ * the URL stays stable and theirs.
97
+ *
98
+ * Omit it and image paste is simply not offered: a `<textarea>`
99
+ * ignores a pasted image on its own, which is the correct
100
+ * fallback rather than a broken one.
101
+ */
102
+ onUploadImage?: (
103
+ file: File,
104
+ ) => Promise<MarkdownImageUpload>
105
+ placeholder?: string
106
+ /** The editing surface's accessible name in the toolbar's label. */
107
+ toolbarLabel?: string
108
+ }
109
+
110
+ /**
111
+ * The paint for each span kind.
112
+ *
113
+ * **Written out, never interpolated** — `` `text-${kind}` ``
114
+ * generates nothing at all and fails silently, which is the rule
115
+ * `intentStyles.ts` is 48 literals for.
116
+ *
117
+ * ### Every one of these is metric-neutral, and that is the design
118
+ *
119
+ * The editing surface is a real `<textarea>` with this painted
120
+ * layer behind it in the same grid cell. The textarea owns the
121
+ * caret; the layer owns the colour. The two only stay registered
122
+ * while they **lay out identically**, so anything that changes a
123
+ * glyph's advance width — `font-bold`, `text-lg`, a different
124
+ * family, `tracking-wide` — shears the caret away from the letters
125
+ * it is supposed to be sitting between. Not subtly: by a character
126
+ * or two per emphasis run, cumulative across the line.
127
+ *
128
+ * So bold is faux bold. `text-shadow` at ±0.02em paints the glyph
129
+ * twice, a hair apart, which thickens it and moves nothing.
130
+ * Italic is real, because the surface is monospaced and a
131
+ * monospaced italic keeps the advance width by definition. Headings
132
+ * get weight and colour rather than size, for the same reason.
133
+ *
134
+ * This is the honest limit of the textarea-plus-layer approach and
135
+ * it is written down here rather than discovered later:
136
+ * **markers are dimmed, not hidden.** Concealing `**` would change
137
+ * the line's width and there is no way to do it in this medium.
138
+ * The staging decision record has the rest.
139
+ */
140
+ const SPAN_KIND_CLASS: Record<MarkdownSpanKind, string> = {
141
+ code: "text-intent-info-content",
142
+ emphasis: "italic text-content-primary",
143
+ heading:
144
+ "text-content-primary [text-shadow:0.02em_0_0_currentColor,-0.02em_0_0_currentColor]",
145
+ link: "text-intent-accent-content underline",
146
+ marker: "",
147
+ plain: "text-content-primary",
148
+ strikethrough: "text-content-secondary line-through",
149
+ strong:
150
+ "text-content-primary [text-shadow:0.02em_0_0_currentColor,-0.02em_0_0_currentColor]",
151
+ url: "text-content-secondary underline",
152
+ }
153
+
154
+ /**
155
+ * The one thing that changes with the caret.
156
+ *
157
+ * Obsidian's live preview reveals a line's raw syntax when the
158
+ * cursor enters it. Here the markers are always present — they have
159
+ * to be, see above — so "reveal" becomes a contrast change: muted
160
+ * everywhere, accent on the line you are editing. Both are real
161
+ * token roles that clear AA on their own, which an `opacity`
162
+ * treatment would not.
163
+ */
164
+ const ACTIVE_MARKER_CLASS = "text-intent-accent-content"
165
+
166
+ const RESTING_MARKER_CLASS = "text-content-muted"
167
+
168
+ const isImageFile = (file: File) =>
169
+ file.type.startsWith("image/")
170
+
171
+ /**
172
+ * A live hybrid markdown editor whose stored value is markdown and
173
+ * only ever markdown.
174
+ *
175
+ * Docket asked for this one and the requirement is unusually sharp,
176
+ * because the tracker it replaces destroyed data by storing HTML in
177
+ * a field it later tried to read back as text. So the constraint
178
+ * here is not "prefer markdown" — it is that **an HTML tag must not
179
+ * be able to reach the stored string**, and the design is chosen to
180
+ * make that mechanical rather than policed:
181
+ *
182
+ * - the editing surface is a `<textarea>`, which takes
183
+ * `text/plain` off the clipboard and nothing else. Copy a
184
+ * styled paragraph out of a browser and the `text/html` flavour
185
+ * is simply not read;
186
+ * - every command in `markdownCommands.ts` returns markdown, and
187
+ * there is no serialiser anywhere in the component;
188
+ * - the painted layer emits `{ kind, text }` spans that React
189
+ * renders as **text nodes**. No `innerHTML`, no sanitiser, and
190
+ * nothing to get wrong — a `<script>` in the source is four
191
+ * coloured characters on screen.
192
+ *
193
+ * `MarkdownEditor.test.tsx` round-trips a value full of the
194
+ * characters that tempt escaping and asserts no tag ever appears.
195
+ *
196
+ * ## Why a `<textarea>` and not an editor core
197
+ *
198
+ * Charcuterie is published, so a dependency here is a permanent
199
+ * cost to every consumer of the package — measured, for a markdown
200
+ * setup: CodeMirror 6 at 176 KB gzipped, Lexical at 148 KB, TipTap
201
+ * at 238 KB. This component adds **nothing**. The full argument,
202
+ * with the numbers and with what the choice gives up, is
203
+ * `docs/decisions/2026-08-19-the-markdown-editor-is-a-textarea-with-a-painted-layer.md`.
204
+ *
205
+ * The accessibility half of that argument is worth repeating here,
206
+ * because it is the half that is hard to buy back later. A
207
+ * `<textarea>` arrives with caret movement, word-wise selection,
208
+ * `Home`/`End`, IME composition, spellcheck, autocorrect, the
209
+ * platform's own undo stack, `role="textbox"` and
210
+ * `aria-multiline="true"` — all of it, in every browser, tested by
211
+ * the vendors. A `contenteditable` starts with none of those and
212
+ * re-earns each one, and the ones it never fully re-earns are IME
213
+ * and undo. This library's central claim is that an agent and a
214
+ * keyboard can drive every component; starting from the platform's
215
+ * own text control is the cheapest way to keep it.
216
+ *
217
+ * ## It is uncontrolled, and that is not a shortcut
218
+ *
219
+ * `defaultValue` in, `onChange` out — the same contract as `Select`
220
+ * ([decision](../../../../docs/decisions/2026-07-31-select-is-uncontrolled-because-the-platform-owns-the-value.md)),
221
+ * and here it protects something specific. A controlled React
222
+ * `<textarea>` round-trips every keystroke through render, and any
223
+ * programmatic `value` assignment **wipes the browser's undo
224
+ * stack**: Ctrl+Z after a toolbar click then skips the paragraph
225
+ * you typed before it, or does nothing at all.
226
+ *
227
+ * So the textarea is never handed a `value`. Its text is mirrored
228
+ * into state for *painting* only, and every programmatic edit — a
229
+ * toolbar action, a shortcut, an image insertion — is applied as a
230
+ * range replacement through `document.execCommand("insertText")`,
231
+ * which is deprecated, is the only API that appends to the native
232
+ * undo stack, and is exactly why `getMinimalEdit` exists.
233
+ *
234
+ * A consumer that must swap the whole document — Docket opening a
235
+ * different task — remounts with a `key`. That is one line, and it
236
+ * is honest about the fact that the old undo history belonged to
237
+ * the old document.
238
+ *
239
+ * ## Composition, and the one component that did not fit
240
+ *
241
+ * The toolbar is this package's `Toolbar`, so the row collapses
242
+ * into a menu **by measurement** rather than at a breakpoint —
243
+ * which is what the Narrow View actually needs, since a lane in a
244
+ * three-up board is narrow on a 4K display. `Field` wraps it
245
+ * through `SlotProps`, so a label, a description and an error wire
246
+ * themselves to the textarea.
247
+ *
248
+ * `FileDropZone` does **not** wrap it, and that is the interesting
249
+ * one. That component is a `<label>` around a file input covering
250
+ * its whole area — correct for "drop a disc image here", and wrong
251
+ * for a text surface, where the drop has to land *at a character
252
+ * offset* and the area has to keep taking clicks as caret
253
+ * placement. So the editor handles its own drop, and `FileDropZone`
254
+ * stays the right answer for the attachment list beside it.
255
+ */
256
+ export const MarkdownEditor = ({
257
+ className,
258
+ defaultValue = "",
259
+ icons,
260
+ isDisabled = false,
261
+ isReadOnly = false,
262
+ label,
263
+ onChange,
264
+ onUploadError,
265
+ onUploadImage,
266
+ placeholder,
267
+ toolbarLabel = "Markdown formatting",
268
+ ...receivedSlotProps
269
+ }: MarkdownEditorProps): ReactNode => {
270
+ const baseId = useUniqueId()
271
+
272
+ const hintId = `${baseId}-hint`
273
+
274
+ const statusId = `${baseId}-status`
275
+
276
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
277
+
278
+ const layerRef = useRef<HTMLDivElement>(null)
279
+
280
+ const uploadCount = useRef(0)
281
+
282
+ const [value, setValue] = useState(defaultValue)
283
+
284
+ const [caretOffset, setCaretOffset] = useState(0)
285
+
286
+ const [isFocused, setIsFocused] = useState(false)
287
+
288
+ const [uploadMessage, setUploadMessage] = useState("")
289
+
290
+ const readSelection = (): MarkdownSelection => {
291
+ const element = textareaRef.current
292
+
293
+ return {
294
+ selectionEnd: element?.selectionEnd ?? 0,
295
+ selectionStart: element?.selectionStart ?? 0,
296
+ text: element?.value ?? "",
297
+ }
298
+ }
299
+
300
+ const syncFromElement = () => {
301
+ const element = textareaRef.current
302
+
303
+ if (!element) {
304
+ return
305
+ }
306
+
307
+ setCaretOffset(element.selectionStart)
308
+
309
+ if (element.value === value) {
310
+ return
311
+ }
312
+
313
+ setValue(element.value)
314
+
315
+ onChange?.(element.value)
316
+ }
317
+
318
+ /**
319
+ * Apply a command's result to the live control **without**
320
+ * assigning `value`.
321
+ *
322
+ * `execCommand` is deprecated and is the only route that adds to
323
+ * the browser's own undo stack, so it is tried first and the
324
+ * modern `setRangeText` is the fallback for whatever eventually
325
+ * removes it. Losing undo is a degradation; losing the edit would
326
+ * not be.
327
+ */
328
+ const applyEdit = (next: MarkdownSelection) => {
329
+ const element = textareaRef.current
330
+
331
+ if (!element) {
332
+ return
333
+ }
334
+
335
+ const edit = getMinimalEdit(element.value, next.text)
336
+
337
+ element.focus()
338
+
339
+ element.setSelectionRange(edit.start, edit.end)
340
+
341
+ const isApplied = (() => {
342
+ try {
343
+ return edit.text === ""
344
+ ? document.execCommand("delete")
345
+ : document.execCommand(
346
+ "insertText",
347
+ false,
348
+ edit.text,
349
+ )
350
+ } catch {
351
+ return false
352
+ }
353
+ })()
354
+
355
+ if (!isApplied) {
356
+ element.setRangeText(
357
+ edit.text,
358
+ edit.start,
359
+ edit.end,
360
+ "end",
361
+ )
362
+ }
363
+
364
+ element.setSelectionRange(
365
+ next.selectionStart,
366
+ next.selectionEnd,
367
+ )
368
+
369
+ syncFromElement()
370
+ }
371
+
372
+ const runCommand = (
373
+ command: (
374
+ state: MarkdownSelection,
375
+ ) => MarkdownSelection,
376
+ ) => {
377
+ if (isDisabled || isReadOnly) {
378
+ return
379
+ }
380
+
381
+ applyEdit(command(readSelection()))
382
+ }
383
+
384
+ /**
385
+ * Upload one file, with a **markdown** placeholder holding the
386
+ * caret's place while the request is in flight.
387
+ *
388
+ * The placeholder is real text in the document rather than an
389
+ * overlay, because the user keeps typing during an upload and the
390
+ * insertion point has to move with what they type. It carries a
391
+ * counter so two files of the same name do not resolve into each
392
+ * other's slot, and it is markdown like everything else — a
393
+ * failed upload leaves the document in a state the user can read
394
+ * and fix, not a dangling widget.
395
+ */
396
+ const uploadImage = async (file: File) => {
397
+ if (!onUploadImage) {
398
+ return
399
+ }
400
+
401
+ uploadCount.current += 1
402
+
403
+ const placeholder = `![Uploading ${file.name}](#uploading-${uploadCount.current})`
404
+
405
+ applyEdit(insertText(readSelection(), placeholder))
406
+
407
+ setUploadMessage(`Uploading ${file.name}`)
408
+
409
+ const replacePlaceholder = (insertion: string) => {
410
+ const element = textareaRef.current
411
+
412
+ if (!element) {
413
+ return
414
+ }
415
+
416
+ const start = element.value.indexOf(placeholder)
417
+
418
+ if (start === -1) {
419
+ return
420
+ }
421
+
422
+ const end = start + placeholder.length
423
+
424
+ applyEdit({
425
+ selectionEnd: start + insertion.length,
426
+ selectionStart: start + insertion.length,
427
+ text:
428
+ element.value.slice(0, start) +
429
+ insertion +
430
+ element.value.slice(end),
431
+ })
432
+ }
433
+
434
+ try {
435
+ const uploaded = await onUploadImage(file)
436
+
437
+ replacePlaceholder(
438
+ toMarkdownImage({
439
+ alt: uploaded.alt ?? file.name,
440
+ url: uploaded.url,
441
+ }),
442
+ )
443
+
444
+ setUploadMessage(`Inserted ${file.name}`)
445
+ } catch (error) {
446
+ replacePlaceholder("")
447
+
448
+ setUploadMessage(`Could not upload ${file.name}`)
449
+
450
+ onUploadError?.(file, error)
451
+ }
452
+ }
453
+
454
+ const uploadImages = (files: readonly File[]) => {
455
+ for (const file of files.filter(isImageFile)) {
456
+ void uploadImage(file)
457
+ }
458
+ }
459
+
460
+ const handlePaste = (
461
+ pasteEvent: ClipboardEvent<HTMLTextAreaElement>,
462
+ ) => {
463
+ const files = Array.from(
464
+ pasteEvent.clipboardData.files,
465
+ ).filter(isImageFile)
466
+
467
+ if (files.length === 0 || !onUploadImage) {
468
+ // Left to the browser on purpose. A `<textarea>` pastes
469
+ // `text/plain` and nothing else, so the no-HTML guarantee
470
+ // needs no code here at all — intercepting the paste to
471
+ // "sanitise" it would be the version that can have a bug.
472
+ return
473
+ }
474
+
475
+ pasteEvent.preventDefault()
476
+
477
+ uploadImages(files)
478
+ }
479
+
480
+ const handleDrop = (
481
+ dropEvent: DragEvent<HTMLTextAreaElement>,
482
+ ) => {
483
+ const files = Array.from(
484
+ dropEvent.dataTransfer.files,
485
+ ).filter(isImageFile)
486
+
487
+ if (files.length === 0 || !onUploadImage) {
488
+ return
489
+ }
490
+
491
+ dropEvent.preventDefault()
492
+
493
+ uploadImages(files)
494
+ }
495
+
496
+ /**
497
+ * `Tab` is never captured, at any time.
498
+ *
499
+ * Indenting a list with Tab is the convention in editors that own
500
+ * the whole page, and inside a form it is a keyboard trap: WCAG
501
+ * 2.1.2 wants focus to be able to leave every component with the
502
+ * standard gesture, and a multi-line field on a task form is
503
+ * exactly where somebody tabs onward. Indent and outdent are
504
+ * `Ctrl+]` and `Ctrl+[` instead, which is what browsers'
505
+ * own editors bind them to.
506
+ */
507
+ const handleKeyDown = (
508
+ keyEvent: KeyboardEvent<HTMLTextAreaElement>,
509
+ ) => {
510
+ if (isReadOnly || isDisabled) {
511
+ return
512
+ }
513
+
514
+ if (
515
+ keyEvent.key === "Enter" &&
516
+ !keyEvent.shiftKey &&
517
+ !keyEvent.altKey &&
518
+ !keyEvent.ctrlKey &&
519
+ !keyEvent.metaKey
520
+ ) {
521
+ const continued = continueList(readSelection())
522
+
523
+ if (continued) {
524
+ keyEvent.preventDefault()
525
+
526
+ applyEdit(continued)
527
+ }
528
+
529
+ return
530
+ }
531
+
532
+ const isModified = keyEvent.ctrlKey || keyEvent.metaKey
533
+
534
+ if (!isModified) {
535
+ return
536
+ }
537
+
538
+ const command = (() => {
539
+ switch (keyEvent.key.toLowerCase()) {
540
+ case "[": {
541
+ return outdentLines
542
+ }
543
+
544
+ case "]": {
545
+ return indentLines
546
+ }
547
+
548
+ case "b": {
549
+ return (state: MarkdownSelection) =>
550
+ toggleInlineMarker(state, "**")
551
+ }
552
+
553
+ case "e": {
554
+ return (state: MarkdownSelection) =>
555
+ toggleInlineMarker(state, "`")
556
+ }
557
+
558
+ case "i": {
559
+ return (state: MarkdownSelection) =>
560
+ toggleInlineMarker(state, "_")
561
+ }
562
+
563
+ case "k": {
564
+ return (state: MarkdownSelection) =>
565
+ insertLink(state)
566
+ }
567
+
568
+ default: {
569
+ return null
570
+ }
571
+ }
572
+ })()
573
+
574
+ if (!command) {
575
+ return
576
+ }
577
+
578
+ keyEvent.preventDefault()
579
+
580
+ runCommand(command)
581
+ }
582
+
583
+ const lines = toMarkdownLines(value)
584
+
585
+ const activeLineIndex = toLineIndex(value, caretOffset)
586
+
587
+ const surfaceClassName =
588
+ "col-start-1 row-start-1 m-0 min-h-32 whitespace-pre-wrap break-words border-0 p-3 font-mono text-md leading-relaxed cq-md:min-h-48"
589
+
590
+ return (
591
+ <div
592
+ className={toClassName(
593
+ // `@container` here and not on a wrapper: a container query
594
+ // matches the container's *descendants*, never the
595
+ // container itself, so nothing in this string may be a
596
+ // `cq-*:` variant.
597
+ "@container flex flex-col gap-2 rounded-md border border-border-default bg-surface-base text-content-primary focus-within:outline-solid focus-within:outline-(length:--focus-ring-width) focus-within:outline-offset-(--focus-ring-offset) focus-within:outline-focus-ring",
598
+ // A token pair, never `opacity-*`. Fading the whole box
599
+ // fades the hint under it too, and axe measures the
600
+ // *composited* colour: `content-secondary` at 60% came out
601
+ // at 3.77:1 and failed AA on a `<p>` that is not itself
602
+ // disabled and owes no exemption.
603
+ isDisabled &&
604
+ "border-border-subtle bg-surface-sunken",
605
+ className,
606
+ )}
607
+ >
608
+ <div className="border-border-subtle border-b px-2 pt-2 pb-1">
609
+ <Toolbar
610
+ items={[
611
+ {
612
+ icon: icons?.bold,
613
+ isDisabled: isDisabled || isReadOnly,
614
+ key: "bold",
615
+ label: "Bold",
616
+ onSelect: () => {
617
+ runCommand((state) =>
618
+ toggleInlineMarker(state, "**"),
619
+ )
620
+ },
621
+ },
622
+ {
623
+ icon: icons?.italic,
624
+ isDisabled: isDisabled || isReadOnly,
625
+ key: "italic",
626
+ label: "Italic",
627
+ onSelect: () => {
628
+ runCommand((state) =>
629
+ toggleInlineMarker(state, "_"),
630
+ )
631
+ },
632
+ },
633
+ {
634
+ icon: icons?.heading,
635
+ isDisabled: isDisabled || isReadOnly,
636
+ key: "heading",
637
+ label: "Heading",
638
+ onSelect: () => {
639
+ runCommand((state) =>
640
+ toggleHeading(state, 2),
641
+ )
642
+ },
643
+ },
644
+ {
645
+ icon: icons?.bulletedList,
646
+ isDisabled: isDisabled || isReadOnly,
647
+ key: "bulleted-list",
648
+ label: "Bulleted list",
649
+ onSelect: () => {
650
+ runCommand((state) =>
651
+ toggleLinePrefix(
652
+ state,
653
+ MARKDOWN_LINE_PREFIXES.bulletList,
654
+ ),
655
+ )
656
+ },
657
+ },
658
+ {
659
+ icon: icons?.taskList,
660
+ isDisabled: isDisabled || isReadOnly,
661
+ key: "task-list",
662
+ label: "Task list",
663
+ onSelect: () => {
664
+ runCommand((state) =>
665
+ toggleLinePrefix(
666
+ state,
667
+ MARKDOWN_LINE_PREFIXES.taskList,
668
+ ),
669
+ )
670
+ },
671
+ },
672
+ {
673
+ icon: icons?.numberedList,
674
+ isDisabled: isDisabled || isReadOnly,
675
+ key: "numbered-list",
676
+ label: "Numbered list",
677
+ onSelect: () => {
678
+ runCommand((state) =>
679
+ toggleLinePrefix(
680
+ state,
681
+ MARKDOWN_LINE_PREFIXES.orderedList,
682
+ ),
683
+ )
684
+ },
685
+ },
686
+ {
687
+ icon: icons?.quote,
688
+ isDisabled: isDisabled || isReadOnly,
689
+ key: "quote",
690
+ label: "Quote",
691
+ onSelect: () => {
692
+ runCommand((state) =>
693
+ toggleLinePrefix(
694
+ state,
695
+ MARKDOWN_LINE_PREFIXES.blockquote,
696
+ ),
697
+ )
698
+ },
699
+ },
700
+ {
701
+ icon: icons?.code,
702
+ isDisabled: isDisabled || isReadOnly,
703
+ key: "code",
704
+ label: "Code",
705
+ onSelect: () => {
706
+ runCommand((state) =>
707
+ toggleInlineMarker(state, "`"),
708
+ )
709
+ },
710
+ },
711
+ {
712
+ icon: icons?.link,
713
+ isDisabled: isDisabled || isReadOnly,
714
+ key: "link",
715
+ label: "Link",
716
+ onSelect: () => {
717
+ runCommand((state) => insertLink(state))
718
+ },
719
+ },
720
+ ]}
721
+ label={toolbarLabel}
722
+ overflow="menu"
723
+ />
724
+ </div>
725
+
726
+ <div className="grid">
727
+ {/*
728
+ Behind the textarea, in the same grid cell, and
729
+ `aria-hidden` because every character in it is already in
730
+ the textarea's own value — a screen reader that read both
731
+ would hear the document twice.
732
+ */}
733
+ <div
734
+ aria-hidden="true"
735
+ className={toClassName(
736
+ surfaceClassName,
737
+ "pointer-events-none overflow-hidden",
738
+ )}
739
+ ref={layerRef}
740
+ >
741
+ {lines.map((line, lineIndex) => (
742
+ <Fragment
743
+ // The index *is* the identity here: line 3 is line 3,
744
+ // and its content is what changes.
745
+ // biome-ignore lint/suspicious/noArrayIndexKey: a line's position is its identity
746
+ key={lineIndex}
747
+ >
748
+ {lineIndex === 0 ? null : "\n"}
749
+
750
+ {line.spans.map((span, spanIndex) => (
751
+ <span
752
+ className={
753
+ span.kind === "marker"
754
+ ? isFocused &&
755
+ lineIndex === activeLineIndex
756
+ ? ACTIVE_MARKER_CLASS
757
+ : RESTING_MARKER_CLASS
758
+ : SPAN_KIND_CLASS[span.kind]
759
+ }
760
+ // biome-ignore lint/suspicious/noArrayIndexKey: a span's position is its identity
761
+ key={spanIndex}
762
+ >
763
+ {span.text}
764
+ </span>
765
+ ))}
766
+ </Fragment>
767
+ ))}
768
+ </div>
769
+
770
+ <textarea
771
+ {...mergeSlotProps(receivedSlotProps, {
772
+ "aria-describedby": hintId,
773
+ })}
774
+ aria-label={label}
775
+ className={toClassName(
776
+ surfaceClassName,
777
+ // The text is transparent and the layer behind supplies
778
+ // every glyph. The caret is not: `caret-color` is its
779
+ // own property precisely so a transparent-text overlay
780
+ // like this one can keep a visible cursor.
781
+ "resize-y bg-transparent text-transparent caret-content-primary outline-none placeholder:text-content-muted",
782
+ )}
783
+ defaultValue={defaultValue}
784
+ disabled={isDisabled}
785
+ onBlur={() => {
786
+ setIsFocused(false)
787
+ }}
788
+ onClick={syncFromElement}
789
+ onDragOver={(dragEvent) => {
790
+ if (onUploadImage) {
791
+ // The default action for `dragover` is to reject the
792
+ // drop, so without this `onDrop` never fires at all —
793
+ // the same footgun `FileDropZone` documents.
794
+ dragEvent.preventDefault()
795
+ }
796
+ }}
797
+ onDrop={handleDrop}
798
+ onFocus={() => {
799
+ setIsFocused(true)
800
+
801
+ syncFromElement()
802
+ }}
803
+ onInput={syncFromElement}
804
+ onKeyDown={handleKeyDown}
805
+ onKeyUp={syncFromElement}
806
+ onPaste={handlePaste}
807
+ onScroll={() => {
808
+ const element = textareaRef.current
809
+
810
+ if (element && layerRef.current) {
811
+ layerRef.current.scrollTop = element.scrollTop
812
+
813
+ layerRef.current.scrollLeft =
814
+ element.scrollLeft
815
+ }
816
+ }}
817
+ onSelect={syncFromElement}
818
+ placeholder={placeholder}
819
+ readOnly={isReadOnly}
820
+ ref={textareaRef}
821
+ spellCheck
822
+ />
823
+ </div>
824
+
825
+ <div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 px-3 pb-2">
826
+ {/*
827
+ `sr-only` rather than `hidden`, and the difference is the
828
+ whole point: `display: none` removes an element from the
829
+ accessibility tree, so an `aria-describedby` pointing at a
830
+ hidden hint silently describes nothing. The clip-rect
831
+ technique keeps it in the tree at every width and
832
+ `cq-sm:not-sr-only` paints it once the container has room
833
+ — one element, always described, sometimes visible.
834
+ */}
835
+ <p
836
+ className="sr-only text-content-secondary text-sm cq-sm:not-sr-only"
837
+ id={hintId}
838
+ >
839
+ Markdown. Ctrl+B bold, Ctrl+I italic, Ctrl+K link,
840
+ Ctrl+E code.
841
+ </p>
842
+
843
+ {/*
844
+ A live region names itself with `aria-label` as well as
845
+ its text — `role="status"` takes no accessible name from
846
+ its contents, so a region that announces correctly is
847
+ still unfindable without one. It is deliberately **not**
848
+ in `aria-describedby`: a description is standing help, and
849
+ this changes.
850
+ */}
851
+ <span
852
+ aria-label="Image upload"
853
+ className="text-content-secondary text-sm"
854
+ id={statusId}
855
+ role="status"
856
+ >
857
+ {uploadMessage}
858
+ </span>
859
+ </div>
860
+ </div>
861
+ )
862
+ }