@charcuterie/ui 3.2.0 → 3.4.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 (58) hide show
  1. package/dist/Field/Field.d.ts +1 -1
  2. package/dist/Field/Field.d.ts.map +1 -1
  3. package/dist/Field/Field.js +19 -1
  4. package/dist/Field/Field.js.map +1 -1
  5. package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -1
  6. package/dist/MarkdownEditor/MarkdownEditor.js +21 -8
  7. package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -1
  8. package/dist/MarkdownEditor/markdownCommands.d.ts +32 -0
  9. package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -1
  10. package/dist/MarkdownEditor/markdownCommands.js +47 -0
  11. package/dist/MarkdownEditor/markdownCommands.js.map +1 -1
  12. package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -1
  13. package/dist/MarkdownEditor/markdownSpans.js +30 -0
  14. package/dist/MarkdownEditor/markdownSpans.js.map +1 -1
  15. package/dist/Select/Select.d.ts +43 -2
  16. package/dist/Select/Select.d.ts.map +1 -1
  17. package/dist/Select/Select.js +27 -2
  18. package/dist/Select/Select.js.map +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts +93 -0
  22. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -0
  23. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +545 -0
  24. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -0
  25. package/dist/markdownEditorCodeMirror/index.d.ts +16 -0
  26. package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -0
  27. package/dist/markdownEditorCodeMirror/index.js +14 -0
  28. package/dist/markdownEditorCodeMirror/index.js.map +1 -0
  29. package/dist/markdownEditorCodeMirror/livePreview.d.ts +40 -0
  30. package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -0
  31. package/dist/markdownEditorCodeMirror/livePreview.js +428 -0
  32. package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -0
  33. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts +128 -0
  34. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -0
  35. package/dist/markdownEditorCodeMirror/livePreviewRanges.js +345 -0
  36. package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -0
  37. package/package.json +36 -1
  38. package/src/Field/Field.mdx +2 -2
  39. package/src/Field/Field.stories.tsx +9 -5
  40. package/src/Field/Field.test.tsx +43 -6
  41. package/src/Field/Field.tsx +19 -1
  42. package/src/MarkdownEditor/MarkdownEditor.tsx +33 -7
  43. package/src/MarkdownEditor/markdownCommands.test.ts +55 -0
  44. package/src/MarkdownEditor/markdownCommands.ts +60 -0
  45. package/src/MarkdownEditor/markdownSpans.test.ts +65 -0
  46. package/src/MarkdownEditor/markdownSpans.ts +35 -0
  47. package/src/Select/Select.mdx +27 -1
  48. package/src/Select/Select.stories.tsx +6 -1
  49. package/src/Select/Select.tsx +43 -2
  50. package/src/index.ts +5 -0
  51. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +144 -0
  52. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.stories.tsx +147 -0
  53. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +866 -0
  54. package/src/markdownEditorCodeMirror/index.ts +26 -0
  55. package/src/markdownEditorCodeMirror/livePreview.ts +535 -0
  56. package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +431 -0
  57. package/src/markdownEditorCodeMirror/livePreviewRanges.ts +581 -0
  58. package/src/sourceRules.test.ts +20 -0
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Markdown → the ranges a live-preview surface decorates, as pure
3
+ * tree-in / descriptors-out.
4
+ *
5
+ * This is the half of the CodeMirror editor that decides what a
6
+ * document *looks* like, and it is deliberately the half with no
7
+ * `@codemirror/view` import in it. Descriptors are plain
8
+ * `{ from, to, type }` objects; turning them into `Decoration`s is
9
+ * `livePreview.ts`'s job. The split buys two things:
10
+ *
11
+ * - **These tests run in Node.** `packages/ui`'s Vitest project is
12
+ * `.ts`-only and has no DOM, on purpose. A test here parses real
13
+ * markdown with the real GFM parser and asserts real offsets —
14
+ * no jsdom, no mount, no story.
15
+ * - **The runtime stays incremental.** Tests hand in
16
+ * `markdownLanguage.parser.parse(text)`; the view hands in
17
+ * `syntaxTree(state)`, which CodeMirror has already reparsed
18
+ * incrementally. Same function, same offsets, and the expensive
19
+ * path is never the one under test.
20
+ *
21
+ * ### What separates this from `markdownSpans.ts`
22
+ *
23
+ * The sibling `MarkdownEditor` paints *behind* a `<textarea>`, so
24
+ * every span it emits has to be metric-neutral — same glyph
25
+ * advances, or the caret shears away from the letters. That is the
26
+ * constraint this module does **not** have. CodeMirror owns the
27
+ * caret and reflows around decorations, so a heading can genuinely
28
+ * be larger, `**` can genuinely be hidden, and an image can
29
+ * genuinely replace its own markup.
30
+ *
31
+ * That is the entire reason this subpath exists, and it is also why
32
+ * it is a subpath: the capability costs ~176 KB gz of optional peer
33
+ * and only a consumer that opts in should pay it.
34
+ *
35
+ * ### The one invariant
36
+ *
37
+ * **Concealment never touches the document.** Every descriptor here
38
+ * describes a *view* over an unmodified markdown string. Hiding
39
+ * `**` does not delete it; replacing `![alt](url)` with an image
40
+ * does not rewrite it. The stored value stays byte-for-byte what
41
+ * the user typed, which is the same no-HTML-in-the-data guarantee
42
+ * the textarea version gets structurally — kept here by never
43
+ * having a serialiser at all.
44
+ */
45
+ const HEADING_LINE_KINDS = {
46
+ ATXHeading1: "heading1",
47
+ ATXHeading2: "heading2",
48
+ ATXHeading3: "heading3",
49
+ ATXHeading4: "heading4",
50
+ ATXHeading5: "heading5",
51
+ ATXHeading6: "heading6",
52
+ };
53
+ const INLINE_MARK_KINDS = {
54
+ Emphasis: "emphasis",
55
+ InlineCode: "code",
56
+ Strikethrough: "strikethrough",
57
+ StrongEmphasis: "strong",
58
+ };
59
+ /** Marks that vanish with their construct once the caret leaves. */
60
+ const CONCEALABLE_MARK_NAMES = new Set([
61
+ "CodeMark",
62
+ "EmphasisMark",
63
+ "HeaderMark",
64
+ "LinkMark",
65
+ "QuoteMark",
66
+ "StrikethroughMark",
67
+ ]);
68
+ /**
69
+ * A fence is structure, not noise.
70
+ *
71
+ * `CodeMark` covers both `` ` `` and ```` ``` ````, and concealing
72
+ * the fenced kind leaves a blank line at the top and bottom of the
73
+ * block where the fence used to be — which reads as a rendering bug
74
+ * rather than as tidiness. The language tag goes with it, for the
75
+ * same reason Obsidian keeps it: it is the one part of a code block
76
+ * that says what the code *is*.
77
+ */
78
+ const FENCED_CODE_PARENTS = new Set(["FencedCode"]);
79
+ /**
80
+ * Marks that are always visible, only dimmed.
81
+ *
82
+ * Just the list bullet, and the distinction from `QuoteMark` is the
83
+ * interesting part. A blockquote gets a line decoration — an accent
84
+ * bar down its inline start — so the `>` is *redundant* once that
85
+ * bar is drawn, and Obsidian hides it for exactly that reason. A
86
+ * bullet has no such affordance: hide the `-` and an unordered list
87
+ * becomes indented prose. Obsidian substitutes a `•` glyph there,
88
+ * which means choosing a width, and this module has no business
89
+ * choosing one.
90
+ *
91
+ * So the rule is not "block marks persist" — it is **a mark
92
+ * persists unless something else already says what it said.**
93
+ */
94
+ const PERSISTENT_MARK_NAMES = new Set(["ListMark"]);
95
+ const isInside = (selections, from, to) => selections.some((selection) => selection.from <= to && selection.to >= from);
96
+ /** The start offset of the line containing `offset`. */
97
+ const toLineStart = (text, offset) => {
98
+ const previousBreak = text.lastIndexOf("\n", offset - 1);
99
+ return previousBreak + 1;
100
+ };
101
+ /** The end offset (exclusive of `\n`) of that line. */
102
+ const toLineEnd = (text, offset) => {
103
+ const nextBreak = text.indexOf("\n", offset);
104
+ return nextBreak === -1 ? text.length : nextBreak;
105
+ };
106
+ const toChildNamed = (node, name) => {
107
+ for (let child = node.firstChild; child; child = child.nextSibling) {
108
+ if (child.name === name) {
109
+ return child;
110
+ }
111
+ }
112
+ return null;
113
+ };
114
+ /**
115
+ * A heading's `#` run, plus the whitespace after it.
116
+ *
117
+ * Concealing `#` alone would leave the heading text indented by the
118
+ * space that used to separate them — a ragged left edge that reads
119
+ * as a bug. The parser hands back only the hashes, so the trailing
120
+ * run is walked here.
121
+ */
122
+ const toHeaderMarkEnd = (text, to) => {
123
+ let end = to;
124
+ while (end < text.length) {
125
+ const character = text[end];
126
+ if (character !== " " && character !== "\t") {
127
+ break;
128
+ }
129
+ end += 1;
130
+ }
131
+ return end;
132
+ };
133
+ /**
134
+ * Every decoration the document wants, in tree order.
135
+ *
136
+ * Order is not sorted here on purpose — `Decoration.set(ranges,
137
+ * true)` sorts, and doing it twice is the sort of thing that looks
138
+ * free until a document is long.
139
+ */
140
+ export const toLivePreviewRanges = ({ from, isRawMode = false, selections, text, to, tree, }) => {
141
+ const ranges = [];
142
+ const pushMarker = (from, to, isRevealed) => {
143
+ if (from >= to) {
144
+ return;
145
+ }
146
+ ranges.push({
147
+ from,
148
+ isConcealed: !isRawMode && !isRevealed,
149
+ to,
150
+ type: "marker",
151
+ });
152
+ };
153
+ tree.iterate({
154
+ from,
155
+ to,
156
+ enter: (nodeRef) => {
157
+ const { name } = nodeRef;
158
+ const headingKind = HEADING_LINE_KINDS[name];
159
+ if (headingKind) {
160
+ if (!isRawMode) {
161
+ const lineStart = toLineStart(text, nodeRef.from);
162
+ // Zero-length, at the line start. A line decoration is
163
+ // anchored to a position, not a span — give it a real
164
+ // range and CodeMirror throws rather than paints.
165
+ ranges.push({
166
+ from: lineStart,
167
+ lineKind: headingKind,
168
+ to: lineStart,
169
+ type: "line",
170
+ });
171
+ }
172
+ return true;
173
+ }
174
+ if (name === "Blockquote" || name === "FencedCode") {
175
+ if (!isRawMode) {
176
+ // One line decoration per line: CodeMirror anchors a line
177
+ // decoration at a line start, so a multi-line block needs
178
+ // one per line rather than one spanning range.
179
+ for (let offset = toLineStart(text, nodeRef.from); offset < nodeRef.to; offset = toLineEnd(text, offset) + 1) {
180
+ ranges.push({
181
+ from: offset,
182
+ lineKind: name === "Blockquote"
183
+ ? "blockquote"
184
+ : "code",
185
+ to: offset,
186
+ type: "line",
187
+ });
188
+ }
189
+ }
190
+ return true;
191
+ }
192
+ if (name === "Table") {
193
+ if (!isRawMode) {
194
+ for (let offset = toLineStart(text, nodeRef.from); offset < nodeRef.to; offset = toLineEnd(text, offset) + 1) {
195
+ ranges.push({
196
+ from: offset,
197
+ lineKind: "table",
198
+ to: offset,
199
+ type: "line",
200
+ });
201
+ }
202
+ }
203
+ return true;
204
+ }
205
+ const inlineKind = INLINE_MARK_KINDS[name];
206
+ if (inlineKind) {
207
+ ranges.push({
208
+ from: nodeRef.from,
209
+ markKind: inlineKind,
210
+ to: nodeRef.to,
211
+ type: "mark",
212
+ });
213
+ return true;
214
+ }
215
+ if (name === "Image") {
216
+ const node = nodeRef.node;
217
+ const urlNode = toChildNamed(node, "URL");
218
+ const openMark = node.firstChild;
219
+ const closeMark = openMark?.nextSibling;
220
+ // An image whose caret is inside it is markup again: you
221
+ // cannot edit a URL you cannot see.
222
+ if (!isRawMode &&
223
+ urlNode &&
224
+ openMark &&
225
+ closeMark &&
226
+ !isInside(selections, node.from, node.to)) {
227
+ ranges.push({
228
+ alt: text.slice(openMark.to, closeMark.from),
229
+ from: node.from,
230
+ to: node.to,
231
+ type: "image",
232
+ url: text.slice(urlNode.from, urlNode.to),
233
+ });
234
+ return false;
235
+ }
236
+ return true;
237
+ }
238
+ if (name === "Link") {
239
+ const node = nodeRef.node;
240
+ const urlNode = toChildNamed(node, "URL");
241
+ const isRevealed = isInside(selections, node.from, node.to);
242
+ const openMark = node.firstChild;
243
+ const closeMark = openMark?.nextSibling;
244
+ if (urlNode && openMark && closeMark) {
245
+ ranges.push({
246
+ from: openMark.to,
247
+ markKind: "linkText",
248
+ to: closeMark.from,
249
+ type: "mark",
250
+ url: text.slice(urlNode.from, urlNode.to),
251
+ });
252
+ }
253
+ // Children are walked so nested emphasis inside link text
254
+ // still paints, but the marks are emitted here where the
255
+ // whole construct's reveal state is known.
256
+ pushMarker(node.from, openMark?.to ?? node.from, isRevealed);
257
+ if (closeMark) {
258
+ pushMarker(closeMark.from, node.to, isRevealed);
259
+ }
260
+ return true;
261
+ }
262
+ if (name === "URL") {
263
+ // A bare URL. GFM's autolink emits a `URL` with no `Link`
264
+ // parent, which is the whole of gap 2 in Docket's handoff:
265
+ // paste a link, get a link, with no `[](…)` typed at all.
266
+ const parentName = nodeRef.node.parent?.name;
267
+ if (parentName !== "Link" &&
268
+ parentName !== "Image") {
269
+ ranges.push({
270
+ from: nodeRef.from,
271
+ markKind: "autolink",
272
+ to: nodeRef.to,
273
+ type: "mark",
274
+ url: text.slice(nodeRef.from, nodeRef.to),
275
+ });
276
+ }
277
+ else {
278
+ ranges.push({
279
+ from: nodeRef.from,
280
+ markKind: "url",
281
+ to: nodeRef.to,
282
+ type: "mark",
283
+ });
284
+ }
285
+ return true;
286
+ }
287
+ if (name === "TaskMarker") {
288
+ const isRevealed = isInside(selections, nodeRef.from, nodeRef.to);
289
+ if (!isRawMode && !isRevealed) {
290
+ ranges.push({
291
+ from: nodeRef.from,
292
+ isChecked: text[nodeRef.from + 1] !== " ",
293
+ label: text
294
+ .slice(nodeRef.to, toLineEnd(text, nodeRef.to))
295
+ .trim(),
296
+ to: nodeRef.to,
297
+ type: "task",
298
+ });
299
+ return false;
300
+ }
301
+ pushMarker(nodeRef.from, nodeRef.to, isRevealed);
302
+ return false;
303
+ }
304
+ if (PERSISTENT_MARK_NAMES.has(name)) {
305
+ pushMarker(nodeRef.from, nodeRef.to, true);
306
+ return false;
307
+ }
308
+ if (name === "TableDelimiter") {
309
+ pushMarker(nodeRef.from, nodeRef.to, true);
310
+ return false;
311
+ }
312
+ if (name === "CodeInfo") {
313
+ pushMarker(nodeRef.from, nodeRef.to, true);
314
+ return false;
315
+ }
316
+ if (CONCEALABLE_MARK_NAMES.has(name)) {
317
+ const parent = nodeRef.node.parent;
318
+ if (parent &&
319
+ FENCED_CODE_PARENTS.has(parent.name)) {
320
+ pushMarker(nodeRef.from, nodeRef.to, true);
321
+ return false;
322
+ }
323
+ // A link's marks were already emitted above, with the
324
+ // link's own reveal state spanning both sides of the text.
325
+ // An image's were not — it either replaced itself outright
326
+ // or fell through to here, where the generic parent-range
327
+ // rule is already the correct one.
328
+ if (parent?.name === "Link") {
329
+ return false;
330
+ }
331
+ const isRevealed = parent
332
+ ? isInside(selections, parent.from, parent.to)
333
+ : isInside(selections, nodeRef.from, nodeRef.to);
334
+ const to = name === "HeaderMark"
335
+ ? toHeaderMarkEnd(text, nodeRef.to)
336
+ : nodeRef.to;
337
+ pushMarker(nodeRef.from, to, isRevealed);
338
+ return false;
339
+ }
340
+ return true;
341
+ },
342
+ });
343
+ return ranges;
344
+ };
345
+ //# sourceMappingURL=livePreviewRanges.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"livePreviewRanges.js","sourceRoot":"","sources":["../../src/markdownEditorCodeMirror/livePreviewRanges.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charcuterie/ui",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
4
4
  "description": "The Charcuterie components. Tokens for look, @charcuterie/logic for state, ARIA that agents can actually drive.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,11 @@
19
19
  "types": "./dist/index.d.ts",
20
20
  "default": "./dist/index.js"
21
21
  },
22
+ "./markdown-editor-codemirror": {
23
+ "source": "./src/markdownEditorCodeMirror/index.ts",
24
+ "types": "./dist/markdownEditorCodeMirror/index.d.ts",
25
+ "default": "./dist/markdownEditorCodeMirror/index.js"
26
+ },
22
27
  "./react-router": {
23
28
  "source": "./src/reactRouter/index.ts",
24
29
  "types": "./dist/reactRouter/index.d.ts",
@@ -55,16 +60,46 @@
55
60
  "tailwind-merge": "^3.6.0"
56
61
  },
57
62
  "peerDependencies": {
63
+ "@codemirror/commands": "^6",
64
+ "@codemirror/lang-markdown": "^6",
65
+ "@codemirror/language": "^6",
66
+ "@codemirror/state": "^6",
67
+ "@codemirror/view": "^6",
68
+ "@lezer/common": "^1",
58
69
  "react": ">=19",
59
70
  "react-dom": ">=19",
60
71
  "react-router": ">=7"
61
72
  },
62
73
  "peerDependenciesMeta": {
74
+ "@codemirror/commands": {
75
+ "optional": true
76
+ },
77
+ "@codemirror/lang-markdown": {
78
+ "optional": true
79
+ },
80
+ "@codemirror/language": {
81
+ "optional": true
82
+ },
83
+ "@codemirror/state": {
84
+ "optional": true
85
+ },
86
+ "@codemirror/view": {
87
+ "optional": true
88
+ },
89
+ "@lezer/common": {
90
+ "optional": true
91
+ },
63
92
  "react-router": {
64
93
  "optional": true
65
94
  }
66
95
  },
67
96
  "devDependencies": {
97
+ "@codemirror/commands": "^6",
98
+ "@codemirror/lang-markdown": "^6",
99
+ "@codemirror/language": "^6",
100
+ "@codemirror/state": "^6",
101
+ "@codemirror/view": "^6",
102
+ "@lezer/common": "^1",
68
103
  "@storybook/react": "^10.3.6",
69
104
  "@types/react": "^19.2.14",
70
105
  "@types/react-dom": "^19.2.3",
@@ -27,7 +27,7 @@ in one place and cannot be spelled twice.
27
27
 
28
28
  <Canvas of={FieldStories.Default} />
29
29
 
30
- `Field` is also how a `Picker` (or `Button`, or `Select`) sits next to
30
+ `Field` is also how a `Picker` (or `Button`, or an app's own control) sits next to
31
31
  its label **without butting against the last word**. Those controls are
32
32
  flush — no outer margin — on purpose. The stacked `gap-1.5` here is the
33
33
  gutter. An inline label + trigger is a row with `gap-*`, not a margin
@@ -40,7 +40,7 @@ on the control. See [Picker](../Picker/Picker.mdx#the-trigger-is-flush--the-pare
40
40
  ## The control is a slot
41
41
 
42
42
  Cloned, not wrapped — the same contract as `Popover`'s trigger. An `<input>`, a
43
- `<textarea>`, a `Select`, or an app's own control all go in the same hole, and the `id`,
43
+ `<textarea>`, a `Picker`, or an app's own control all go in the same hole, and the `id`,
44
44
  `aria-describedby`, `aria-invalid` and `required` are injected onto whatever arrives.
45
45
 
46
46
  <Canvas of={FieldStories.AllVariants} />
@@ -5,7 +5,6 @@ import {
5
5
  StoryGrid,
6
6
  } from "../board.storyHelpers.tsx"
7
7
  import { Picker } from "../Picker/Picker.tsx"
8
- import { Select } from "../Select/Select.tsx"
9
8
  import { Tooltip } from "../Tooltip/Tooltip.tsx"
10
9
  import { Field } from "./Field.tsx"
11
10
  import { FieldGroup } from "./FieldGroup.tsx"
@@ -84,7 +83,7 @@ export const AdoptsOverlayTriggerId: Story = {
84
83
  }
85
84
 
86
85
  /**
87
- * The control is a slot, not a hardcoded `<input>` — `Select` goes
86
+ * The control is a slot, not a hardcoded `<input>` — a `Picker` goes
88
87
  * in the same hole, and so does an app's own control. The `id`,
89
88
  * `aria-describedby`, `aria-invalid`, and `required` are cloned onto
90
89
  * whatever is passed.
@@ -104,13 +103,16 @@ export const AllVariants: Story = {
104
103
  </Field>
105
104
  </StoryCell>
106
105
 
107
- <StoryCell label="select">
106
+ <StoryCell label="picker">
108
107
  <Field label="Rip profile">
109
- <Select
108
+ <Picker
109
+ label="Rip profile"
110
+ onChange={() => {}}
110
111
  options={[
111
112
  { label: "Lossless", value: "lossless" },
112
113
  { label: "Compressed", value: "compressed" },
113
114
  ]}
115
+ value="lossless"
114
116
  />
115
117
  </Field>
116
118
  </StoryCell>
@@ -305,12 +307,14 @@ export const Group: Story = {
305
307
  label="Chapter split"
306
308
  >
307
309
  <div className="flex gap-2">
308
- <Select
310
+ <Picker
309
311
  label="Unit"
312
+ onChange={() => {}}
310
313
  options={[
311
314
  { label: "Chapters", value: "chapters" },
312
315
  { label: "Minutes", value: "minutes" },
313
316
  ]}
317
+ value="chapters"
314
318
  />
315
319
 
316
320
  <input
@@ -96,6 +96,41 @@ test("the label names the control it points at", async () => {
96
96
  await expectNoAxeViolations(canvasElement)
97
97
  })
98
98
 
99
+ /**
100
+ * Pins a string coupling that lives in two files.
101
+ *
102
+ * `htmlFor` names every **labelable** element — `<input>`,
103
+ * `<textarea>`, `<select>`, `<button>` — and names a
104
+ * `div[role="textbox"]` not at all. So a `contenteditable` control
105
+ * inside a `Field` points `aria-labelledby` at this id instead, and
106
+ * `MarkdownEditorCodeMirror` builds it as `` `${id}-label` ``.
107
+ *
108
+ * Renaming the format here would leave that control with no
109
+ * accessible name — silently, because the markup still looks right
110
+ * and the label still renders. Only axe would notice, and only in
111
+ * the other component's suite.
112
+ */
113
+ test("the label carries a derivable id for non-labelable controls", async () => {
114
+ const { canvas } = await mountStory(Default)
115
+
116
+ const control = expectAgentDrivable(canvas, {
117
+ name: "Output directory",
118
+ role: "textbox",
119
+ })
120
+
121
+ const controlId = control.getAttribute("id")
122
+
123
+ await expect(controlId).not.toBeNull()
124
+
125
+ const label = document.getElementById(
126
+ `${controlId}-label`,
127
+ )
128
+
129
+ await expect(label).not.toBeNull()
130
+
131
+ await expect(label).toHaveAttribute("for", controlId)
132
+ })
133
+
99
134
  test("a description is announced with the control", async () => {
100
135
  const { canvas } = await mountStory(Default)
101
136
 
@@ -115,16 +150,18 @@ test("a description is announced with the control", async () => {
115
150
  ).toHaveTextContent("Where finished rips are moved.")
116
151
  })
117
152
 
118
- test("the slot takes a Select as readily as an input", async () => {
153
+ test("the slot takes a Picker as readily as an input", async () => {
119
154
  const { canvas, canvasElement } =
120
155
  await mountStory(AllVariants)
121
156
 
122
- // A native `<select>` reports `role="combobox"`, and it is named
123
- // by the same cloned `id`. The slot does not know what it is
124
- // holding, which is the contract.
157
+ // A `Picker` is a `<button>` trigger whose accessible name carries
158
+ // the current value, and it is named by the same cloned `id`. The
159
+ // slot does not know what it is holding, which is the contract —
160
+ // this cell held a native `Select` until that component was
161
+ // deprecated.
125
162
  expectAgentDrivable(canvas, {
126
- name: "Rip profile",
127
- role: "combobox",
163
+ name: "Rip profile: Lossless",
164
+ role: "button",
128
165
  })
129
166
 
130
167
  await expectNoAxeViolations(canvasElement)
@@ -12,7 +12,7 @@ export type FieldProps = SlotProps & {
12
12
  /**
13
13
  * The control. **Cloned, not wrapped** — the same slot contract
14
14
  * as `Popover`'s trigger, so a `Field` can go around an `<input>`,
15
- * a `<textarea>`, a `Select`, or an app's own control without
15
+ * a `<textarea>`, a `Picker`, or an app's own control without
16
16
  * changing that control's layout.
17
17
  */
18
18
  children: ReactElement
@@ -183,6 +183,24 @@ export const Field = ({
183
183
  <label
184
184
  className="font-medium text-content-primary text-sm"
185
185
  htmlFor={controlId}
186
+ /**
187
+ * `htmlFor` names the control for every **labelable**
188
+ * element — `<input>`, `<textarea>`, `<select>`, `<button>`
189
+ * — which is all this component wrapped until a
190
+ * `contenteditable` arrived. A `div[role="textbox"]` is not
191
+ * in that list, so `htmlFor` alone leaves it with **no
192
+ * accessible name at all**, silently: the markup looks
193
+ * right, the label renders, and axe is the only thing that
194
+ * notices.
195
+ *
196
+ * So the label also carries a derivable id, and such a
197
+ * control points `aria-labelledby` at it.
198
+ * `MarkdownEditorCodeMirror` is the first consumer;
199
+ * `Field.test.tsx` pins the format, because the coupling is
200
+ * a string convention and a rename here would break a name
201
+ * over in another file.
202
+ */
203
+ id={`${controlId}-label`}
186
204
  >
187
205
  {label}
188
206
 
@@ -18,12 +18,14 @@ import {
18
18
  indentLines,
19
19
  insertLink,
20
20
  insertText,
21
+ isLinkPaste,
21
22
  MARKDOWN_LINE_PREFIXES,
22
23
  outdentLines,
23
24
  toggleHeading,
24
25
  toggleInlineMarker,
25
26
  toggleLinePrefix,
26
27
  toMarkdownImage,
28
+ wrapSelectionInLink,
27
29
  } from "./markdownCommands.ts"
28
30
  import type { MarkdownSpanKind } from "./markdownSpans.ts"
29
31
  import {
@@ -464,17 +466,41 @@ export const MarkdownEditor = ({
464
466
  pasteEvent.clipboardData.files,
465
467
  ).filter(isImageFile)
466
468
 
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.
469
+ if (files.length > 0 && onUploadImage) {
470
+ pasteEvent.preventDefault()
471
+
472
+ uploadImages(files)
473
+
472
474
  return
473
475
  }
474
476
 
475
- pasteEvent.preventDefault()
477
+ const pasted =
478
+ pasteEvent.clipboardData.getData("text/plain")
476
479
 
477
- uploadImages(files)
480
+ const selection = readSelection()
481
+
482
+ // A URL pasted over selected text becomes a link, the way it
483
+ // does in every editor anyone uses. Reading `text/plain` here
484
+ // is not a hole in the no-HTML guarantee — that flavour is the
485
+ // only one a `<textarea>` would have taken anyway, and what
486
+ // goes back in is markdown built by a command.
487
+ if (
488
+ isLinkPaste(pasted) &&
489
+ selection.selectionStart !== selection.selectionEnd
490
+ ) {
491
+ pasteEvent.preventDefault()
492
+
493
+ applyEdit(
494
+ wrapSelectionInLink(selection, pasted.trim()),
495
+ )
496
+
497
+ return
498
+ }
499
+
500
+ // Everything else is left to the browser on purpose. A
501
+ // `<textarea>` pastes `text/plain` and nothing else, so the
502
+ // no-HTML guarantee needs no code here — intercepting the paste
503
+ // to "sanitise" it would be the version that can have a bug.
478
504
  }
479
505
 
480
506
  const handleDrop = (