@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,189 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as MarkdownEditorStories from "./MarkdownEditor.stories";
4
+
5
+ <Meta of={MarkdownEditorStories} />
6
+
7
+ # MarkdownEditor
8
+
9
+ A live hybrid markdown editor whose stored value is markdown and **only** markdown.
10
+
11
+ Docket asked for this one and its requirement is unusually sharp, because the tracker it
12
+ replaces destroyed data by storing HTML in a field it later read back as text. So the
13
+ constraint is not "prefer markdown" — it is that an HTML tag must not be *able* to reach the
14
+ stored string.
15
+
16
+ <Canvas of={MarkdownEditorStories.Default} />
17
+
18
+ ## Props
19
+
20
+ <Controls />
21
+
22
+ ## No HTML can reach the value, by construction
23
+
24
+ Four properties, and none of them is a rule somebody has to remember:
25
+
26
+ - the editing surface is a `<textarea>`, which takes **`text/plain`** off the clipboard and
27
+ nothing else. Copy a styled paragraph out of a browser and the `text/html` flavour is
28
+ simply never read;
29
+ - every command in `markdownCommands.ts` returns markdown. There is no serialiser and no
30
+ intermediate document model anywhere in the component;
31
+ - the painted layer emits `{ kind, text }` spans that React renders as **text nodes**. No
32
+ `innerHTML`, no sanitiser, and nothing to get wrong — a `<script>` in the source is a
33
+ handful of coloured characters on screen;
34
+ - image insertion escapes its alt text and brackets a dirty URL, so a filename with a `]` in
35
+ it cannot end the construct early.
36
+
37
+ `markdownCommands.test.ts` runs every command over a value full of tags and entities and
38
+ asserts each fragment survives **verbatim** — not "contains no `&lt;`", because the fixture
39
+ deliberately contains `&lt;` as literal text and a naive absence check would pass on an
40
+ escaped value and fail on a correct one.
41
+
42
+ <Canvas of={MarkdownEditorStories.AllVariants} />
43
+
44
+ ## Why a `<textarea>` and not an editor core
45
+
46
+ Charcuterie is published, so a dependency here is a permanent cost to every consumer.
47
+ Measured on this machine — `esbuild --bundle --minify`, React external, gzip -9:
48
+
49
+ | Candidate | minified | gzipped |
50
+ | --- | --- | --- |
51
+ | TipTap 3.30 + starter kit + `tiptap-markdown` | 720 KB | **238 KB** |
52
+ | CodeMirror 6 (`basicSetup` + `lang-markdown`) | 609 KB | **206 KB** |
53
+ | CodeMirror 6, hand-assembled minimum | 512 KB | **176 KB** |
54
+ | Lexical 0.49 + markdown/list/link/code nodes | 449 KB | **148 KB** |
55
+ | **This component** | — | **0 KB added** |
56
+
57
+ The full argument, including what the choice gives up and what would justify reversing it,
58
+ is in
59
+ [`docs/decisions/2026-08-19-the-markdown-editor-is-a-textarea-with-a-painted-layer.md`](https://github.com/Sawtaytoes/charcuterie/blob/master/docs/decisions/2026-08-19-the-markdown-editor-is-a-textarea-with-a-painted-layer.md).
60
+
61
+ The accessibility half of it is the half that is hard to buy back later. A `<textarea>`
62
+ arrives with caret movement, word-wise selection, `Home`/`End`, IME composition, spellcheck,
63
+ autocorrect, the platform's own undo stack, `role="textbox"` and `aria-multiline="true"` —
64
+ all of it, in every browser, tested by the vendors. A `contenteditable` starts with none of
65
+ those and re-earns each one, and the ones it never fully re-earns are IME and undo.
66
+
67
+ ## The painted layer, and the geometry that constrains it
68
+
69
+ The surface is a real `<textarea>` with a painted layer behind it in the **same grid cell**.
70
+ The textarea owns the caret, the selection, the undo stack and the IME; the layer owns the
71
+ colour. Its text is transparent and its `caret-color` is not, which is what `caret-color`
72
+ exists for.
73
+
74
+ That only works while the two layers **lay out identically**, so:
75
+
76
+ - the tokenizer keeps **every character**. It never trims, never normalises a tab, never
77
+ drops an unterminated marker. `markdownSpans.test.ts` joins every span back together and
78
+ asserts it reproduces the source byte for byte;
79
+ - every span style is **metric-neutral**. Bold is faux bold — `text-shadow` at ±0.02em paints
80
+ the glyph twice, a hair apart, which thickens it and moves nothing. Italic is real, because
81
+ the surface is monospaced and a monospaced italic keeps the advance width by definition.
82
+ Headings get weight and colour rather than size. `MarkdownEditor.test.tsx` **measures** each
83
+ one against plain text and fails if any of them is wider.
84
+
85
+ The honest limit follows from the same constraint: **markers are dimmed, not hidden.**
86
+ Concealing `**` would change the line's width and there is no way to do it in this medium.
87
+ So "raw syntax on the cursor's line" becomes a contrast change — muted markers everywhere,
88
+ accent markers on the line the caret is on — and true concealment is the staged follow-up,
89
+ which needs a decoration-capable surface (CodeMirror 6) and would arrive as an **optional
90
+ subpath** so consumers that do not use it keep paying nothing.
91
+
92
+ <Canvas of={MarkdownEditorStories.NoUpload} />
93
+
94
+ ## Uncontrolled, and that is not a shortcut
95
+
96
+ `defaultValue` in, `onChange` out — the same contract as `Select`, and here it protects
97
+ something specific. A controlled React `<textarea>` round-trips every keystroke through
98
+ render, and any programmatic `value` assignment **wipes the browser's undo stack**: Ctrl+Z
99
+ after a toolbar click then skips the paragraph you typed before it, or does nothing at all.
100
+
101
+ So the textarea is never handed a `value`. Its text is mirrored into state for *painting*
102
+ only, and every programmatic edit is applied as a range replacement through
103
+ `document.execCommand("insertText")` — deprecated, and still the only API that appends to
104
+ the native undo stack. `getMinimalEdit` is what turns a command's whole-string result back
105
+ into that range, so a bold toggle is **one** undo step over four characters rather than a
106
+ document-sized one.
107
+
108
+ A consumer that must swap the whole document — Docket opening a different task — remounts
109
+ with a `key`. That is one line, and it is honest about the fact that the old undo history
110
+ belonged to the old document.
111
+
112
+ ## Paste an image at the cursor
113
+
114
+ ```tsx
115
+ <MarkdownEditor
116
+ label="Description"
117
+ onUploadImage={async (file) => {
118
+ const { url } = await uploadToBlobStore(file)
119
+
120
+ return { alt: file.name, url }
121
+ }}
122
+ />
123
+ ```
124
+
125
+ **The component owns no storage.** It hands you a `File`, waits, and inserts the URL you
126
+ return as `![alt](url)` at the caret. Docket keeps its own blob storage precisely so the URL
127
+ stays stable and theirs.
128
+
129
+ While the request is in flight the document holds a **markdown placeholder** —
130
+ `![Uploading rack.png](#uploading-1)` — rather than an overlay, because the user keeps typing
131
+ during an upload and the insertion point has to move with what they type. It carries a
132
+ counter so two files of the same name do not resolve into each other's slot, and a failed
133
+ upload leaves a document the user can read and fix rather than a dangling widget.
134
+
135
+ Omit `onUploadImage` and image paste is simply not offered. A `<textarea>` ignores a pasted
136
+ image on its own, which is the correct fallback rather than a broken one.
137
+
138
+ ## Composition, and the one component that did not fit
139
+
140
+ The toolbar is this package's `Toolbar`, so the row collapses into a **menu by measurement**
141
+ rather than at a breakpoint — which is what the Narrow View actually needs, since a lane in a
142
+ three-up board is narrow on a 4K display. `Field` wraps it through `SlotProps`, so a label, a
143
+ description and an error wire themselves to the `<textarea>` rather than stopping at this
144
+ component.
145
+
146
+ `FileDropZone` does **not** wrap it, and that is the interesting one. That component is a
147
+ `<label>` around a file input covering its whole area — correct for "drop a disc image here",
148
+ and wrong for a text surface, where the drop has to land *at a character offset* and the area
149
+ has to keep taking clicks as caret placement. So the editor handles its own drop, and
150
+ `FileDropZone` stays the right answer for the attachment list beside it.
151
+
152
+ <Canvas of={MarkdownEditorStories.Responsive} />
153
+
154
+ ## Keyboard
155
+
156
+ | Key | Does |
157
+ | --- | --- |
158
+ | `Ctrl`/`Cmd` + `B` | Bold, and un-bold |
159
+ | `Ctrl`/`Cmd` + `I` | Italic |
160
+ | `Ctrl`/`Cmd` + `E` | Inline code |
161
+ | `Ctrl`/`Cmd` + `K` | Link, caret parked in the destination |
162
+ | `Ctrl`/`Cmd` + `]` / `[` | Indent / outdent the selected lines |
163
+ | `Enter` | Continues a list, a task list or a quote — and **clears** an empty item |
164
+ | `Tab` | Moves focus. Always. |
165
+
166
+ **`Tab` is never captured**, at any time. Indenting with Tab is the convention in editors
167
+ that own the whole page, and inside a form it is a keyboard trap: WCAG 2.1.2 wants focus to
168
+ be able to leave every component with the standard gesture, and a multi-line field on a task
169
+ form is exactly where somebody tabs onward.
170
+
171
+ Enter returns *nothing to do* rather than an unchanged state when the caret is not in a list,
172
+ so the keypress falls through to the browser and lands on the native undo stack as an
173
+ ordinary typing step.
174
+
175
+ <Canvas of={MarkdownEditorStories.AllStates} />
176
+
177
+ ## What this deliberately does not parse
178
+
179
+ The tokenizer resolves **one line at a time**, carrying exactly one bit of cross-line state:
180
+ whether we are inside a fenced code block. Painting re-runs on every keystroke, so the cost
181
+ has to be linear in the line rather than in the document — and a mis-coloured marker is
182
+ cosmetic while a mis-*placed* one is a broken editor.
183
+
184
+ So these colour as plain text: setext headings (`Title` over `=====`), indented four-space
185
+ code blocks, reference links (`[a][b]`), autolinks in angle brackets, and emphasis that spans
186
+ a line break. They all still **store** correctly, because the stored value is the textarea's
187
+ and not the tokenizer's.
188
+
189
+ <Canvas of={MarkdownEditorStories.Interactive} />
@@ -0,0 +1,267 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import { useState } from "react"
3
+
4
+ import {
5
+ ContainerBoard,
6
+ StoryCell,
7
+ StoryGrid,
8
+ } from "../board.storyHelpers.tsx"
9
+ import { Field } from "../Field/Field.tsx"
10
+ import { MarkdownEditor } from "./MarkdownEditor.tsx"
11
+
12
+ /**
13
+ * Invented, like every fixture in this repo. A task in a made-up
14
+ * homelab project, exercising each construct the painted layer
15
+ * knows about.
16
+ */
17
+ const SAMPLE = `## Rack move, phase two
18
+
19
+ The **short** version: pull the _old_ switch, land the new one, and
20
+ keep the ~~three~~ two uplinks live throughout.
21
+
22
+ - [x] Label every patch lead
23
+ - [ ] Photograph the current cabling
24
+ - [ ] Cut over at 02:00
25
+
26
+ 1. Power down the shelf
27
+ 2. Swap the rails
28
+ 3. Bring it back up
29
+
30
+ > Nothing here touches the storage pool.
31
+
32
+ Run \`ping -c 3 gateway.invalid\` before and after, and read
33
+ [the runbook](https://example.invalid/runbook) first.
34
+
35
+ | Port | Goes to |
36
+ | --- | --- |
37
+ | 1 | uplink |
38
+ | 2 | shelf |
39
+
40
+ \`\`\`sh
41
+ ip -br addr show
42
+ \`\`\`
43
+ `
44
+
45
+ const noop = () => undefined
46
+
47
+ const meta = {
48
+ title: "Components/MarkdownEditor",
49
+ component: MarkdownEditor,
50
+ parameters: { layout: "padded" },
51
+ args: {
52
+ isDisabled: false,
53
+ isReadOnly: false,
54
+ label: "Description",
55
+ placeholder: "Write it in markdown…",
56
+ },
57
+ } satisfies Meta<typeof MarkdownEditor>
58
+
59
+ export default meta
60
+
61
+ type Story = StoryObj<typeof meta>
62
+
63
+ export const Default: Story = {
64
+ args: {
65
+ defaultValue: SAMPLE,
66
+ label: "Description",
67
+ },
68
+ }
69
+
70
+ export const AllVariants: Story = {
71
+ args: { label: "Description" },
72
+ render: () => (
73
+ <StoryGrid columns={2}>
74
+ <StoryCell align="stretch" label="empty">
75
+ <MarkdownEditor
76
+ label="Empty description"
77
+ placeholder="Write it in markdown…"
78
+ />
79
+ </StoryCell>
80
+
81
+ <StoryCell align="stretch" label="inside a Field">
82
+ <Field
83
+ description="Markdown is what gets stored."
84
+ label="Acceptance criteria"
85
+ >
86
+ <MarkdownEditor defaultValue="- [ ] It **works**" />
87
+ </Field>
88
+ </StoryCell>
89
+
90
+ <StoryCell align="stretch" label="a whole document">
91
+ <MarkdownEditor
92
+ defaultValue={SAMPLE}
93
+ label="Long description"
94
+ />
95
+ </StoryCell>
96
+
97
+ <StoryCell
98
+ align="stretch"
99
+ label="HTML is text, not markup"
100
+ >
101
+ <MarkdownEditor
102
+ defaultValue={
103
+ "<script>alert('xss')</script>\n\nA tag pasted in here is four coloured characters, because nothing in this component ever builds HTML.\n\n&amp; &lt; &gt; survive as literal text too."
104
+ }
105
+ label="HTML description"
106
+ />
107
+ </StoryCell>
108
+ </StoryGrid>
109
+ ),
110
+ }
111
+
112
+ export const AllStates: Story = {
113
+ args: { label: "Description" },
114
+ render: () => (
115
+ <StoryGrid columns={2}>
116
+ <StoryCell align="stretch" label="idle">
117
+ <MarkdownEditor
118
+ defaultValue="An **idle** editor."
119
+ label="Idle description"
120
+ />
121
+ </StoryCell>
122
+
123
+ <StoryCell align="stretch" label="read only">
124
+ <MarkdownEditor
125
+ defaultValue="A **read-only** editor still highlights."
126
+ isReadOnly
127
+ label="Read-only description"
128
+ />
129
+ </StoryCell>
130
+
131
+ <StoryCell align="stretch" label="disabled">
132
+ <MarkdownEditor
133
+ defaultValue="A **disabled** editor."
134
+ isDisabled
135
+ label="Disabled description"
136
+ />
137
+ </StoryCell>
138
+
139
+ <StoryCell
140
+ align="stretch"
141
+ label="invalid, in a Field"
142
+ >
143
+ <Field
144
+ error="A description is required before a task can leave Backlog."
145
+ isRequired
146
+ label="Task description"
147
+ >
148
+ <MarkdownEditor defaultValue="" />
149
+ </Field>
150
+ </StoryCell>
151
+ </StoryGrid>
152
+ ),
153
+ }
154
+
155
+ /**
156
+ * The only honest way to story a container query: three fixed
157
+ * inline sizes, side by side, while the browser window never moves.
158
+ *
159
+ * The toolbar is this package's `Toolbar`, so it collapses into a
160
+ * **menu by measurement** rather than at a breakpoint — which is
161
+ * what the Narrow View actually needs, since a lane in a three-up
162
+ * board is narrow on a 4K display. The hint line under the surface
163
+ * is the container-query half: `sr-only` below `--cq-sm`, painted
164
+ * above it, and in the accessibility tree at every width.
165
+ */
166
+ export const Responsive: Story = {
167
+ args: { label: "Description" },
168
+ render: () => (
169
+ <ContainerBoard>
170
+ {(width) => (
171
+ <MarkdownEditor
172
+ defaultValue={
173
+ "## Cut over\n\n- [ ] Label the leads\n- [ ] **Photograph** the cabling"
174
+ }
175
+ label={`Description at ${width}`}
176
+ />
177
+ )}
178
+ </ContainerBoard>
179
+ ),
180
+ }
181
+
182
+ /**
183
+ * Type into it. The syntax colours as you go, and the markers on
184
+ * the line the caret is on paint in the accent colour while every
185
+ * other line's stay muted — that is the "raw syntax on the cursor's
186
+ * line" behaviour, in a medium where hiding the markers would move
187
+ * the text out from under the caret.
188
+ *
189
+ * Everything the toolbar does has a shortcut: **Ctrl+B**, **Ctrl+I**,
190
+ * **Ctrl+K**, **Ctrl+E**, **Ctrl+]** and **Ctrl+[**, and Enter
191
+ * continues a list. **Tab is never captured** — it moves focus, at
192
+ * every time, because a multi-line field on a form is exactly where
193
+ * somebody tabs onward.
194
+ *
195
+ * The upload here is a fake that resolves after a beat with a data
196
+ * URL, so the placeholder is visible while it runs. A real consumer
197
+ * returns a URL from its own blob storage.
198
+ */
199
+ export const Interactive: Story = {
200
+ args: { label: "Description" },
201
+ render: function LiveEditor() {
202
+ const [stored, setStored] = useState(
203
+ "Paste an image, or press Ctrl+B and type.\n",
204
+ )
205
+
206
+ return (
207
+ <div className="flex flex-col gap-3">
208
+ <MarkdownEditor
209
+ label="Task description"
210
+ defaultValue={stored}
211
+ onChange={setStored}
212
+ onUploadImage={async (file) => {
213
+ await new Promise((resolve) => {
214
+ setTimeout(resolve, 600)
215
+ })
216
+
217
+ return {
218
+ alt: file.name,
219
+ url: `https://example.invalid/blobs/${encodeURIComponent(file.name)}`,
220
+ }
221
+ }}
222
+ placeholder="Write it in markdown…"
223
+ />
224
+
225
+ <div className="flex flex-col gap-1">
226
+ <span className="font-mono text-content-muted text-xs">
227
+ what gets stored
228
+ </span>
229
+
230
+ {/*
231
+ Wraps rather than scrolls. A scrollable region has to be
232
+ keyboard-reachable — axe's
233
+ `scrollable-region-focusable`, a real rule rather than a
234
+ nag — and `biome --write --unsafe` strips a `tabIndex`
235
+ off a non-interactive element, so the two fight. Not
236
+ being a scroll container settles it.
237
+ */}
238
+ <pre className="whitespace-pre-wrap rounded-md bg-surface-sunken p-3 font-mono text-content-secondary text-sm wrap-anywhere">
239
+ {stored}
240
+ </pre>
241
+ </div>
242
+ </div>
243
+ )
244
+ },
245
+ }
246
+
247
+ /**
248
+ * Nothing but the surface, for a screenshot of the painted layer on
249
+ * its own.
250
+ */
251
+ export const NoUpload: Story = {
252
+ args: {
253
+ defaultValue:
254
+ "# Heading\n\n**bold**, _italic_, `code`, ~~struck~~, [a link](https://example.invalid).\n\n- [ ] a task\n- [x] a done task\n\n> a quote\n",
255
+ label: "Description",
256
+ onUploadImage: undefined,
257
+ },
258
+ render: (args) => <MarkdownEditor {...args} />,
259
+ }
260
+
261
+ export const Blank: Story = {
262
+ args: {
263
+ label: "Description",
264
+ onChange: noop,
265
+ placeholder: "Write it in markdown…",
266
+ },
267
+ }